feat: Introduce new LayoutSystem and spatial workspace architecture design document, refactor document card and workspace to use it, and simplify pointer event handling.

This commit is contained in:
2025-11-26 13:39:19 +01:00
parent 27eea08605
commit bbf7d23c96
6 changed files with 167 additions and 113 deletions
+5 -3
View File
@@ -16,6 +16,8 @@ interface PendingRemovalTag {
tagId?: string;
}
import { LayoutCard } from './LayoutSystem';
interface DesktopDocumentCardProps {
doc: Document;
style?: React.CSSProperties;
@@ -39,7 +41,7 @@ interface DesktopDocumentCardProps {
onDocTagDrag?: (event: React.DragEvent<HTMLElement>) => void;
onDocTagDragEnd?: (event: React.DragEvent<HTMLElement>) => void;
pendingRemovalTag?: PendingRemovalTag | null;
registerNode?: (node: HTMLDivElement | null) => void;
layoutCard?: LayoutCard;
}
const DesktopDocumentCard: React.FC<DesktopDocumentCardProps> = ({
@@ -65,7 +67,7 @@ const DesktopDocumentCard: React.FC<DesktopDocumentCardProps> = ({
onDocTagDrag,
onDocTagDragEnd,
pendingRemovalTag,
registerNode,
layoutCard,
}) => {
const correspondents = useMemo(() => resolveCorrespondents(doc), [doc]);
const tags = Array.isArray(doc?.tags) ? doc.tags : [];
@@ -88,7 +90,7 @@ const DesktopDocumentCard: React.FC<DesktopDocumentCardProps> = ({
data-doc-id={doc.id}
data-tag-ids={dataTagIds}
aria-hidden={ariaHidden}
ref={registerNode ?? undefined}
ref={(node) => layoutCard?.setRef(node)}
{...cardPointerHandlers}
onDragEnter={(event) => {
if (doc?.id == null) {
+49 -41
View File
@@ -1,15 +1,15 @@
import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { globalLayout, LayoutItem } from './LayoutSystem';
import { LayoutStore, LayoutCard } from './LayoutSystem';
import PreviewZoomOverlay from '../detail/PreviewZoomOverlay';
import DesktopDocumentCard from './DesktopDocumentCard';
import usePreviewMetadata from './hooks/usePreviewMetadata';
import useDeskTagInteractions from './tags/useDeskTagInteractions';
import '../styles/workspace/workspace-layout.css';
import '../styles/workspace/workspace-items.css';
import '../styles/workspace/workspace-cards.css';
@@ -42,7 +42,7 @@ interface DocumentSizeInfo {
// Fallback size computation
const computeFallbackCardSize = (_doc: DeskDocument): DocumentSizeInfo => {
return { width: 200, height: 280, source: 'fallback' };
return { width: 200, height: 200, source: 'fallback' };
};
export interface DesktopWorkspaceProps {
@@ -51,7 +51,7 @@ export interface DesktopWorkspaceProps {
getDocumentAsset?: (...args: any[]) => unknown;
onDocumentActivate?: (doc: DeskDocument, event?: unknown) => void;
onSelectionChange?: (selectedIds: Identifier[]) => void;
layout?: any[];
onDocumentTagDrop?: (docId: Identifier, tag: any) => void;
}
const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
@@ -60,10 +60,9 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
getDocumentAsset,
onDocumentActivate,
onSelectionChange,
layout: initialLayout,
onDocumentTagDrop,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<Map<string, HTMLElement>>(new Map());
const items = useMemo(() => {
return entries
@@ -74,7 +73,8 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
}, [entries]);
// Layout System Initialization
const layoutRef = useRef<Map<string, LayoutItem>>(new Map());
const layoutStore = useMemo(() => new LayoutStore(), []);
const layoutRef = useRef<Map<string, LayoutCard>>(new Map());
// Selection Context
const {
@@ -101,33 +101,46 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
const metadataMap = usePreviewMetadata(items, getDocumentAsset, ensureAssetUrl);
const ensureDocumentSize = useCallback((doc: DeskDocument | null): DocumentSizeInfo | null => {
if (!doc) return null;
const ensureDocumentSize = useCallback((doc: DeskDocument): DocumentSizeInfo => {
if (doc.id) {
const meta = metadataMap.get(String(doc.id));
if (meta) return { width: meta.width, height: meta.height, source: 'metadata' };
if (meta && meta.width && meta.height)
return { width: meta.width, height: meta.height, source: 'metadata' };
}
return computeFallbackCardSize(doc);
}, [metadataMap]);
// Initialize Layout System
useLayoutEffect(() => {
if (initialLayout) {
// Seeding logic if needed, but registration happens in render loop
}
}, [initialLayout]);
// Sync LayoutStore to layoutRef
useEffect(() => {
const sync = () => {
layoutRef.current = globalLayout.items;
layoutRef.current = layoutStore.items;
};
sync();
}, []);
}, [layoutStore.items]);
const handleShellKeyDown = useCallback(() => { }, []);
const focusShell = useCallback(() => { }, []);
// Tag Interactions
const [pendingRemovalTag, setPendingRemovalTag] = useState<{ docId?: string; tagId?: string } | null>(null);
const [tagDropTargetId, setTagDropTargetId] = useState<string | null>(null);
const [pendingTagDocId, setPendingTagDocId] = useState<string | null>(null);
const tagEngine = useMemo(() => ({
setPendingRemovalTag,
setTagDropTargetId,
setPendingTagDocId,
}), []);
const tagInteractions = useDeskTagInteractions({
engine: tagEngine,
onAssignTagToDocument: (docId: string, tag: any) => {
onDocumentTagDrop?.(docId, tag);
},
requestCanvasFocus: focusShell,
});
// Overlay State
const [overlayDisplay, setOverlayDisplay] = useState<OverlayDisplay | null>(null);
const closeOverlay = useCallback(() => setOverlayDisplay(null), []);
@@ -154,25 +167,10 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
const isSelected = selectedDocumentIds.includes(docId);
const size = ensureDocumentSize(doc);
// Register with LayoutStore
const registerNode = (node: HTMLDivElement) => {
if (node) {
itemRefs.current.set(docId, node);
const initItem = initialLayout?.find((i: any) => i.id === docId);
globalLayout.initialize(docId, node, {
x: initItem?.x,
y: initItem?.y,
rotation: initItem?.rotation,
z: index,
width: size?.width ?? 200,
height: size?.height ?? 200
});
} else {
itemRefs.current.delete(docId);
}
};
// const cardPointerHandlers = getCardPointerHandlers(doc);
const layoutCard = layoutStore.initialize(docId, null, {
width: size.width,
height: size.height
});
return (
<DesktopDocumentCard
@@ -187,8 +185,7 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
}}
shouldLoad={true}
matchesFilter={true}
tagTargetActive={false}
tagTargetPending={false}
selected={isSelected}
docTagTokens=""
ensureAssetUrl={ensureAssetUrl}
@@ -196,7 +193,18 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
handleNavigatorSnapshot={() => { }}
cardPointerHandlers={undefined}
onDocumentActivate={onDocumentActivate}
registerNode={registerNode}
layoutCard={layoutCard}
onTagDragEnter={tagInteractions.handleTagDragEnterDoc}
onTagDragOver={tagInteractions.handleTagDragOverDoc}
onTagDragLeave={tagInteractions.handleTagDragLeaveDoc}
onTagDrop={tagInteractions.handleTagDropOnDoc}
onDocTagPointerDown={tagInteractions.handleDocTagPointerDown}
onDocTagDragStart={tagInteractions.handleDocTagDragStart}
onDocTagDrag={tagInteractions.handleDocTagDrag}
onDocTagDragEnd={tagInteractions.handleDocTagDragEnd}
tagTargetActive={tagDropTargetId === docId}
tagTargetPending={pendingTagDocId === docId}
pendingRemovalTag={pendingRemovalTag}
/>
);
})}
+89 -62
View File
@@ -1,4 +1,6 @@
export interface LayoutItem {
import { constrainDimensions } from './utils/layoutUtils';
export interface LayoutCardState {
id: string;
x: number;
y: number;
@@ -6,79 +8,108 @@ export interface LayoutItem {
rotation: number;
width: number;
height: number;
ref: HTMLElement;
}
export class LayoutCard implements LayoutCardState {
id: string;
x: number = 0;
y: number = 0;
z: number = 0;
rotation: number = 0;
width: number = 0;
height: number = 0;
ref: HTMLElement | null = null;
constructor(id: string, initialData: Partial<LayoutCardState> = {}, ref: HTMLElement | null = null) {
this.id = id;
Object.assign(this, initialData);
this.ref = ref;
}
setRef(ref: HTMLElement | null) {
this.ref = ref;
this.applyTransform();
}
update(changes: Partial<LayoutCardState>) {
Object.assign(this, changes);
this.applyTransform();
}
private applyTransform() {
if (this.ref) {
this.ref.style.transform =
`translate3d(${this.x}px, ${this.y}px, 0) rotate(${this.rotation}deg)`;
this.ref.style.zIndex = String(this.z);
this.ref.style.width = `${this.width}px`;
this.ref.style.height = `${this.height}px`;
}
}
toSnapshot(): LayoutCardState {
return {
id: this.id,
x: this.x,
y: this.y,
z: this.z,
rotation: this.rotation,
width: this.width,
height: this.height
};
}
}
export class LayoutStore {
items = new Map<string, LayoutItem>();
items = new Map<string, LayoutCard>();
zCounter = 100;
register(id: string, ref: HTMLElement, initialData: Partial<LayoutItem>) {
const existing = this.items.get(id);
this.items.set(id, {
id,
ref,
x: existing?.x ?? 0,
y: existing?.y ?? 0,
z: existing?.z ?? 0,
rotation: existing?.rotation ?? 0,
width: 200,
height: 200,
...initialData
});
}
initialize(id: string, ref: HTMLElement, config: {
x?: number;
y?: number;
rotation?: number;
z: number;
initialize(id: string, ref: HTMLElement | null, config: {
width: number;
height: number;
}) {
if (this.items.has(id)) {
// Update ref if it changed
const item = this.items.get(id)!;
if (item.ref !== ref) {
item.ref = ref;
this.update(id, {}); // Re-apply styles
}
return;
let card = this.items.get(id);
const { width, height } = constrainDimensions(
config.width,
config.height,
280
);
if (!card) {
// Apply defaults if not provided
const x = Math.random() * 500;
const y = Math.random() * 500;
const rotation = Math.random() * 10 - 5;
card = new LayoutCard(id, {
x,
y,
rotation,
width,
height
}, ref);
this.items.set(id, card);
} else {
card.update({ width, height });
}
// Apply defaults if not provided
const x = config.x ?? Math.random() * 500;
const y = config.y ?? Math.random() * 500;
const rotation = config.rotation ?? (Math.random() * 10 - 5);
// Always update ref and ensure transform is applied
if (card.ref !== ref) {
card.setRef(ref);
}
this.register(id, ref, {
...config,
x,
y,
rotation
});
// Apply immediately
this.update(id, {});
return card;
}
unregister(id: string) {
this.items.delete(id);
}
// Fast Update: Updates internal state AND applies CSS transform immediately
update(id: string, updates: Partial<LayoutItem>) {
const item = this.items.get(id);
if (!item) return;
Object.assign(item, updates);
if (updates.z) this.zCounter = Math.max(this.zCounter, updates.z);
// Direct DOM manipulation (The "Engine" part)
if (item.ref) {
item.ref.style.transform =
`translate3d(${item.x}px, ${item.y}px, 0) rotate(${item.rotation}deg)`;
item.ref.style.zIndex = String(item.z);
update(id: string, updates: Partial<LayoutCardState>) {
const card = this.items.get(id);
if (card) {
if (updates.z) this.zCounter = Math.max(this.zCounter, updates.z);
card.update(updates);
}
}
@@ -87,10 +118,6 @@ export class LayoutStore {
}
getSnapshot() {
// Return serializable data for persistence
return Array.from(this.items.values()).map(({ ref: _ref, ...data }) => data);
return Array.from(this.items.values()).map(card => card.toSnapshot());
}
}
// Singleton or Context-provided instance
export const globalLayout = new LayoutStore();
@@ -41,7 +41,7 @@ const usePreviewMetadata = (
const docId = String(doc.id);
const resolveAsset = (type: string) => getDocumentAsset?.(doc, type) ?? null;
let asset = resolveAsset('preview') || resolveAsset('thumbnail');
let asset = resolveAsset('thumbnail');
let metadata = (asset?.metadata as { width?: number; height?: number } | null) || null;
const hasDimensions = (meta: { width?: number | string; height?: number | string } | null) =>
@@ -15,10 +15,6 @@ const preventAll = (event) => {
if (typeof event.stopPropagation === 'function') event.stopPropagation();
};
// TODO: Restore getPointerPosition
const getPointerPosition = (event) => {
return { x: event.clientX, y: event.clientY };
};
import { TAG_REMOVE_DISTANCE } from '../../constants/desktop';
const createDragPreview = (node, clientX, clientY) => {
@@ -150,7 +146,8 @@ export const useDeskTagInteractions = ({
event.dataTransfer.effectAllowed = 'move';
writeTagTransferData(event.dataTransfer, tag, doc.id);
const { x: pointerX, y: pointerY } = getPointerPosition(event, { fallbackToPage: false });
const pointerX = event.clientX;
const pointerY = event.clientY;
const { clone, offsetX, offsetY } = createDragPreview(event.currentTarget, pointerX, pointerY) || {};
if (clone) {
event.dataTransfer.setDragImage(clone, offsetX || 0, offsetY || 0);
@@ -181,7 +178,8 @@ export const useDeskTagInteractions = ({
if (!state) {
return;
}
const { x, y } = getPointerPosition(event);
const x = event.clientX;
const y = event.clientY;
const dx = x - (state.initialX || 0);
const dy = y - (state.initialY || 0);
state.distance = Math.sqrt(dx * dx + dy * dy);
+19
View File
@@ -33,3 +33,22 @@ export const computeCardBounds = ({
maxY: Math.max(padding + halfH, canvasHeight - padding - halfH),
};
};
export const constrainDimensions = (width: number, height: number, maxDimension: number) => {
if (width <= maxDimension && height <= maxDimension) {
return { width, height };
}
const aspect = width / height;
if (width > height) {
return {
width: maxDimension,
height: maxDimension / aspect
};
} else {
return {
width: maxDimension * aspect,
height: maxDimension
};
}
};