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:
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user