refactor: replace workspace engine and drag/pointer logic with a new layout system and physics model.refactor: replace the workspace engine and drag/pointer logic with a new layout system.

This commit is contained in:
2025-11-26 12:28:02 +01:00
parent e5e71cc5f8
commit 27eea08605
10 changed files with 264 additions and 3970 deletions
+6 -5
View File
@@ -2,11 +2,15 @@ import React, { useMemo } from 'react';
import DesktopPreviewCard from './DesktopPreviewCard';
import { resolveCorrespondents } from '../documents/correspondents';
import { getTagColorStyle } from '../utils/colors';
import { preventAll } from './events';
import type { DocumentId } from '../types/identifiers';
import type { Document } from '../types/documents';
const preventAll = (event?: React.SyntheticEvent | Event | null) => {
if (!event) return;
if (typeof event.preventDefault === 'function') event.preventDefault();
if (typeof event.stopPropagation === 'function') event.stopPropagation();
};
interface PendingRemovalTag {
docId?: string;
tagId?: string;
@@ -16,7 +20,6 @@ interface DesktopDocumentCardProps {
doc: Document;
style?: React.CSSProperties;
shouldLoad?: boolean;
dragging?: boolean;
matchesFilter?: boolean;
tagTargetActive?: boolean;
tagTargetPending?: boolean;
@@ -43,7 +46,6 @@ const DesktopDocumentCard: React.FC<DesktopDocumentCardProps> = ({
doc,
style,
shouldLoad,
dragging,
matchesFilter,
tagTargetActive,
tagTargetPending,
@@ -69,7 +71,6 @@ const DesktopDocumentCard: React.FC<DesktopDocumentCardProps> = ({
const tags = Array.isArray(doc?.tags) ? doc.tags : [];
const itemClasses = ['desk-item'];
if (dragging) itemClasses.push('is-dragging');
if (tagTargetActive) itemClasses.push('is-tag-target');
if (tagTargetPending) itemClasses.push('is-tag-pending');
if (!matchesFilter) itemClasses.push('is-filtered-out');
File diff suppressed because it is too large Load Diff
+96
View File
@@ -0,0 +1,96 @@
export interface LayoutItem {
id: string;
x: number;
y: number;
z: number;
rotation: number;
width: number;
height: number;
ref: HTMLElement;
}
export class LayoutStore {
items = new Map<string, LayoutItem>();
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;
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;
}
// 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);
this.register(id, ref, {
...config,
x,
y,
rotation
});
// Apply immediately
this.update(id, {});
}
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);
}
}
bringToFront(id: string) {
this.update(id, { z: ++this.zCounter });
}
getSnapshot() {
// Return serializable data for persistence
return Array.from(this.items.values()).map(({ ref: _ref, ...data }) => data);
}
}
// Singleton or Context-provided instance
export const globalLayout = new LayoutStore();
-45
View File
@@ -1,45 +0,0 @@
import type { PointerEvent as ReactPointerEvent } from 'react';
type PointerLikeEvent = MouseEvent & { pageX?: number; pageY?: number };
type PreventableEvent = Event | ReactPointerEvent | { preventDefault?: () => void; stopPropagation?: () => void };
export const preventAll = (event?: PreventableEvent | null): void => {
if (!event) {
return;
}
try {
event.preventDefault();
} catch (error) {
console.warn('[events] preventDefault failed', error);
}
try {
event.stopPropagation();
} catch (error) {
console.warn('[events] stopPropagation failed', error);
}
};
type AnyFn = (...args: unknown[]) => unknown;
export const safeInvoke = <Fn extends AnyFn>(
fn: Fn | null,
...args: Parameters<Fn>
): ReturnType<Fn> | undefined =>
(fn ? (fn(...args) as ReturnType<Fn>) : undefined);
export const getPointerPosition = (
event?: PointerLikeEvent | null,
{ fallbackToPage = true }: { fallbackToPage?: boolean } = {},
): { x: number; y: number } => {
if (!event) {
return { x: 0, y: 0 };
}
const clientX = Number.isFinite(event.clientX) ? event.clientX : null;
const clientY = Number.isFinite(event.clientY) ? event.clientY : null;
const pageX = fallbackToPage && Number.isFinite(event.pageX) ? event.pageX : null;
const pageY = fallbackToPage && Number.isFinite(event.pageY) ? event.pageY : null;
return {
x: clientX ?? pageX ?? 0,
y: clientY ?? pageY ?? 0,
};
};
@@ -1,203 +0,0 @@
import { safeInvoke } from '../events';
import type { DocumentId } from '../../types/identifiers';
import {
CLICK_ACTIONS,
DRAG_ACTIONS,
LONG_PRESS_DURATION_MS,
POINTER_DRAG_THRESHOLD_SQUARED,
STACK_HIT_EPSILON,
} from '../../constants/desktop';
export { CLICK_ACTIONS, DRAG_ACTIONS, STACK_HIT_EPSILON, POINTER_DRAG_THRESHOLD_SQUARED, LONG_PRESS_DURATION_MS };
export type ClickAction = (typeof CLICK_ACTIONS)[keyof typeof CLICK_ACTIONS];
export type DragAction = (typeof DRAG_ACTIONS)[keyof typeof DRAG_ACTIONS];
export const withinThreshold = (dx: number, dy: number, thresholdSquared: number): boolean => (dx * dx + dy * dy) <= thresholdSquared;
interface PointerIntentArgs {
doc: { id: string };
entryDescriptor: unknown;
selectedDocumentIds: Array<string>;
metaKey: boolean;
pointerButton?: number;
pointerType?: string;
stackHits?: string[] | null;
isTopMost?: boolean;
}
export interface PointerIntent {
docId: DocumentId;
entryDescriptor: unknown;
pointerType?: string;
pointerButton?: number;
selectedAtDown: boolean;
selectionCountAtDown: number;
metaKey: boolean;
clickAction: ClickAction;
dragAction: DragAction;
stackDocIdsForDrag: string[] | null;
stackDocIdsForClick: string[] | null;
stackReplaceOnClick: boolean;
stackReplaceOnDrag: boolean;
clickSelectionApplied: boolean;
stackSelectionApplied: boolean;
longPressTriggered: boolean;
}
export const createPointerIntent = ({
doc,
entryDescriptor,
selectedDocumentIds,
metaKey,
pointerButton,
pointerType,
stackHits,
isTopMost = true,
}: PointerIntentArgs): PointerIntent => {
const alreadySelected = selectedDocumentIds.includes(doc.id);
const selectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0;
let clickAction: ClickAction = CLICK_ACTIONS.none;
let dragAction: DragAction = DRAG_ACTIONS.none;
if (metaKey) {
clickAction = CLICK_ACTIONS.addStack;
dragAction = DRAG_ACTIONS.dragSelection;
} else if (alreadySelected) {
// Only open detail if it's already the top-most card
if (isTopMost) {
clickAction = CLICK_ACTIONS.openDetail;
} else {
// If not top-most, we don't trigger inspect.
// We also don't need to trigger selectSingle because it's already selected.
// The promotion logic (onPromoteSelection) handles bringing it to front.
clickAction = CLICK_ACTIONS.none;
}
dragAction = selectionCount > 1 ? DRAG_ACTIONS.dragSelection : DRAG_ACTIONS.dragSelectSingle;
} else {
clickAction = CLICK_ACTIONS.selectSingle;
dragAction = DRAG_ACTIONS.dragSelectSingle;
}
const stackList: string[] = Array.isArray(stackHits) && stackHits.length > 0
? stackHits.map((value) => String(value))
: [String(doc.id)];
const stackDocIdsForClick = clickAction === CLICK_ACTIONS.addStack ? stackList : null;
const stackDocIdsForDrag = metaKey ? stackList : null;
return {
docId: doc.id,
entryDescriptor,
pointerType,
pointerButton,
selectedAtDown: alreadySelected,
selectionCountAtDown: selectionCount,
metaKey,
clickAction,
dragAction,
stackDocIdsForDrag,
stackDocIdsForClick,
stackReplaceOnClick: clickAction === CLICK_ACTIONS.addStack,
stackReplaceOnDrag: false,
clickSelectionApplied: false,
stackSelectionApplied: false,
longPressTriggered: false,
};
};
export const applyClickPlanImmediately = ({ intent, event, onEntryPointer, onDocumentStackSelect, onSelect, force = false }: {
intent: PointerIntent;
event?: unknown;
onEntryPointer?: (descriptor: unknown, event?: unknown) => void;
onDocumentStackSelect?: (docIds: string[], event?: unknown, options?: { replace?: boolean }) => void;
onSelect?: (descriptor: unknown, event?: unknown) => void;
force?: boolean;
}) => {
switch (intent.clickAction) {
case CLICK_ACTIONS.selectSingle:
case CLICK_ACTIONS.addCard:
if (onSelect) {
safeInvoke(onSelect, intent.entryDescriptor, event);
} else {
safeInvoke(onEntryPointer, intent.entryDescriptor, event);
}
intent.clickSelectionApplied = true;
break;
case CLICK_ACTIONS.addStack:
if (!force && intent.selectedAtDown) {
return;
}
if (Array.isArray(intent.stackDocIdsForClick) && intent.stackDocIdsForClick.length > 0) {
// Use onSelect for stack selection (batch)
if (onSelect) {
// Map docIds to descriptors if necessary, or just pass IDs if onSelect handles it.
// The current onSelect adapter in DesktopWorkspace expects { id } objects or just IDs?
// Let's assume it expects descriptors like selectSingle.
const descriptors = intent.stackDocIdsForClick.map(id => ({
type: 'document',
id,
key: `document:${id}`,
}));
safeInvoke(onSelect, descriptors, event);
} else {
// Fallback to legacy if onSelect not provided (shouldn't happen in new flow)
safeInvoke(
onDocumentStackSelect,
intent.stackDocIdsForClick,
event,
{ replace: intent.stackReplaceOnClick },
);
}
intent.clickSelectionApplied = true;
intent.stackSelectionApplied = true;
}
break;
case CLICK_ACTIONS.openDetail:
default:
intent.clickSelectionApplied = true;
break;
}
};
export const finalizeClickSelection = ({ intent, event, onEntryPointer, onDocumentStackSelect, onSelect }: {
intent: PointerIntent;
event?: unknown;
onEntryPointer?: (descriptor: unknown, event?: unknown) => void;
onDocumentStackSelect?: (docIds: string[], event?: unknown, options?: { replace?: boolean }) => void;
onSelect?: (descriptor: unknown, event?: unknown) => void;
}) => {
if (!intent || intent.clickSelectionApplied) {
return;
}
applyClickPlanImmediately({ intent, event, onEntryPointer, onDocumentStackSelect, onSelect, force: true });
};
export const applyLongPressSelection = ({ intent, stackDocIds, syntheticEvent, onDocumentStackSelect }: {
intent: PointerIntent;
stackDocIds?: string[] | null;
syntheticEvent?: unknown;
onDocumentStackSelect?: (docIds: string[], event?: unknown, options?: { replace?: boolean }) => void;
}) => {
if (!intent) {
return;
}
const stackCopy: string[] = Array.isArray(stackDocIds) && stackDocIds.length > 0
? stackDocIds.map((value) => String(value))
: [String(intent.docId)];
safeInvoke(onDocumentStackSelect, stackCopy, syntheticEvent, { replace: true });
intent.clickAction = CLICK_ACTIONS.addStack;
intent.dragAction = DRAG_ACTIONS.dragSelectStack;
intent.stackDocIdsForClick = stackCopy;
intent.stackDocIdsForDrag = stackCopy;
intent.stackReplaceOnClick = true;
intent.stackReplaceOnDrag = true;
intent.clickSelectionApplied = true;
intent.stackSelectionApplied = true;
intent.longPressTriggered = true;
};
@@ -1,437 +0,0 @@
import {
useCallback,
useEffect,
useRef,
} from 'react';
import {
CLICK_ACTIONS,
LONG_PRESS_DURATION_MS,
POINTER_DRAG_THRESHOLD_SQUARED,
STACK_HIT_EPSILON,
applyClickPlanImmediately,
applyLongPressSelection,
createPointerIntent,
finalizeClickSelection,
withinThreshold,
} from './pointerUtils';
import { getPointerPosition, safeInvoke } from '../events';
const buildEntryDescriptor = (docId) => ({
type: 'document',
id: docId,
key: `document:${docId}`,
});
export const useDeskPointer = ({
containerRef,
items,
layoutRef,
ensureDocumentSize,
activeTagSet,
handlePointerDown,
handlePointerMove,
handlePointerUp,
handlePointerCancel,
onDocumentClick,
onPromoteSelection,
onDocumentActivate,
selectedDocumentIds,
openOverlayForDoc = null,
onSelect = null,
}) => {
const pointerIntentRef = useRef(null);
const pointerStartRef = useRef({ x: 0, y: 0 });
const pointerMovedRef = useRef(false);
const longPressTimerRef = useRef(null);
const longPressActiveRef = useRef(false);
const resetLongPressState = useCallback(() => {
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current);
longPressTimerRef.current = null;
}
longPressActiveRef.current = false;
}, []);
const resolveStackDocIds = useCallback(
(event, targetDocId = null) => {
const container = containerRef.current;
if (!container || !event) {
return [];
}
const rect = container.getBoundingClientRect();
const pointerCanvasX = event.clientX - rect.left;
const pointerCanvasY = event.clientY - rect.top;
if (!Number.isFinite(pointerCanvasX) || !Number.isFinite(pointerCanvasY)) {
return [];
}
const candidates = [];
items.forEach((doc) => {
if (!doc?.id) {
return;
}
const docKey = String(doc.id);
const layout = layoutRef.current.get(docKey);
if (!layout) {
return;
}
const sizeInfo = ensureDocumentSize(doc);
if (!sizeInfo) {
return;
}
const { width, height } = sizeInfo;
if (!width || !height) {
return;
}
if (activeTagSet.size) {
const docTagKeys = Array.isArray(doc.tags)
? doc.tags.map((tag) => (tag ? tag.id : null)).filter(Boolean)
: [];
if (!docTagKeys.some((key) => activeTagSet.has(key))) {
return;
}
}
const centerX = Number(layout.centerX);
const centerY = Number(layout.centerY);
if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) {
return;
}
const rotationDeg = Number(layout.rotation) || 0;
const rotationRad = (rotationDeg * Math.PI) / 180;
const dx = pointerCanvasX - centerX;
const dy = pointerCanvasY - centerY;
const cosRotation = Math.cos(-rotationRad);
const sinRotation = Math.sin(-rotationRad);
const localX = dx * cosRotation - dy * sinRotation;
const localY = dx * sinRotation + dy * cosRotation;
const halfWidth = width / 2;
const halfHeight = height / 2;
const containsPointer =
Math.abs(localX) <= halfWidth + STACK_HIT_EPSILON
&& Math.abs(localY) <= halfHeight + STACK_HIT_EPSILON;
candidates.push({
id: docKey,
z: Number.isFinite(layout.z) ? layout.z : 0,
centerX,
centerY,
width,
height,
halfWidth,
halfHeight,
containsPointer,
});
});
const pointerCandidates = candidates.filter((candidate) => candidate.containsPointer);
if (!pointerCandidates.length) {
return [];
}
const sortedByZ = [...pointerCandidates].sort((a, b) => (b.z ?? 0) - (a.z ?? 0));
const targetKey = targetDocId != null ? String(targetDocId) : sortedByZ[0].id;
const primary = sortedByZ.find((candidate) => candidate.id === targetKey) || sortedByZ[0];
if (!primary) {
return [];
}
const radius = Math.max(Math.min(primary.halfWidth, primary.halfHeight) * 1.2, 6);
const radiusSquared = radius * radius;
const selected = candidates
.filter((candidate) => {
if (!candidate?.id) {
return false;
}
const dx = candidate.centerX - primary.centerX;
const dy = candidate.centerY - primary.centerY;
return dx * dx + dy * dy <= radiusSquared + 1e-4;
})
.sort((a, b) => (b.z ?? 0) - (a.z ?? 0));
if (targetKey) {
const targetIndex = selected.findIndex((entry) => entry.id === targetKey);
if (targetIndex > 0) {
const [targetEntry] = selected.splice(targetIndex, 1);
selected.unshift(targetEntry);
}
}
return selected
.map((candidate) => candidate.id)
.filter((id, index, array) => array.indexOf(id) === index);
},
[activeTagSet, containerRef, ensureDocumentSize, items, layoutRef],
);
const scheduleLongPress = useCallback(
({ doc, modifierActive, pointerType }) => {
if (modifierActive || pointerType !== 'touch') {
longPressActiveRef.current = false;
return;
}
longPressActiveRef.current = true;
longPressTimerRef.current = window.setTimeout(() => {
if (!longPressActiveRef.current || pointerMovedRef.current) {
resetLongPressState();
return;
}
const intent = pointerIntentRef.current;
if (!intent || intent.docId !== doc.id) {
resetLongPressState();
return;
}
const syntheticEvent = {
clientX: pointerStartRef.current.x,
clientY: pointerStartRef.current.y,
};
const stackHits = resolveStackDocIds(syntheticEvent, doc.id);
applyLongPressSelection({
intent,
stackDocIds: stackHits,
syntheticEvent,
onDocumentStackSelect: null, // Deprecated, handled by onSelect if needed, or long press needs update
});
pointerIntentRef.current = intent;
resetLongPressState();
}, LONG_PRESS_DURATION_MS);
},
[resolveStackDocIds, resetLongPressState],
);
useEffect(() => () => resetLongPressState(), [resetLongPressState]);
const handleCardPointerDown = useCallback(
(event, doc) => {
if (!doc?.id) {
return;
}
pointerStartRef.current = getPointerPosition(event, { fallbackToPage: false });
pointerMovedRef.current = false;
resetLongPressState();
const pointerButton = Number.isFinite(event?.button) ? event.button : 0;
const pointerType = String(event?.pointerType ?? '');
const metaKey = (event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey;
const modifierActive = Boolean(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey);
const entryDescriptor = buildEntryDescriptor(doc.id);
const stackHits = metaKey ? resolveStackDocIds(event, doc.id) : null;
// Calculate if the clicked doc is the top-most among selected docs
let isTopMost = true;
if (selectedDocumentIds.includes(doc.id)) {
const docLayout = layoutRef.current.get(String(doc.id));
const docZ = docLayout?.z ?? 0;
// Check against other selected docs
for (const id of selectedDocumentIds) {
if (id === doc.id) continue;
const layout = layoutRef.current.get(String(id));
if (layout && (layout.z ?? 0) > docZ) {
isTopMost = false;
break;
}
}
}
const intent = createPointerIntent({
doc,
entryDescriptor,
selectedDocumentIds,
metaKey,
pointerButton,
pointerType,
stackHits,
isTopMost,
});
if (intent.selectedAtDown) {
safeInvoke(onPromoteSelection, doc.id, event);
}
applyClickPlanImmediately({
intent,
event,
onEntryPointer: onDocumentClick,
onSelect,
});
pointerIntentRef.current = intent;
handlePointerDown(event, doc.id, {
wasSelected: intent.selectedAtDown,
modifierActive,
stackHits,
});
scheduleLongPress({
doc,
modifierActive,
pointerType,
});
},
[
handlePointerDown,
onPromoteSelection,
onDocumentClick,
onSelect,
resolveStackDocIds,
resetLongPressState,
scheduleLongPress,
selectedDocumentIds,
layoutRef,
],
);
const handleCardPointerMove = useCallback(
(event) => {
const start = pointerStartRef.current;
const { x, y } = getPointerPosition(event, { fallbackToPage: false });
const dx = x - start.x;
const dy = y - start.y;
if (!withinThreshold(dx, dy, POINTER_DRAG_THRESHOLD_SQUARED)) {
pointerMovedRef.current = true;
resetLongPressState();
}
handlePointerMove(event);
},
[handlePointerMove, resetLongPressState],
);
const handleCardPointerUp = useCallback(
(event, doc) => {
const pointerState = pointerIntentRef.current;
const pointerMoved = pointerMovedRef.current;
resetLongPressState();
handlePointerUp(event);
if (!pointerMoved && pointerState) {
finalizeClickSelection({
intent: pointerState,
event,
onEntryPointer: onDocumentClick,
onSelect,
});
if (
pointerState.clickAction === CLICK_ACTIONS.openDetail
&& !pointerState.longPressTriggered
&& pointerState.docId === doc.id
) {
const expectedButton = Number.isFinite(pointerState?.pointerButton)
? pointerState.pointerButton
: 0;
const releasedButton = Number.isFinite(event?.button) ? event.button : expectedButton;
const isPrimaryRelease = expectedButton === 0 && releasedButton === 0;
const stillSelected = Array.isArray(selectedDocumentIds)
&& selectedDocumentIds.includes(doc.id);
if (isPrimaryRelease && stillSelected) {
safeInvoke(onDocumentActivate, doc.id);
}
}
}
pointerIntentRef.current = null;
pointerMovedRef.current = false;
},
[
handlePointerUp,
onDocumentActivate,
onDocumentClick,
onSelect,
resetLongPressState,
selectedDocumentIds,
],
);
const handleCardPointerCancel = useCallback(
(event) => {
pointerMovedRef.current = false;
resetLongPressState();
pointerIntentRef.current = null;
handlePointerCancel(event);
},
[handlePointerCancel, resetLongPressState],
);
const getCardPointerHandlers = useCallback(
(doc) => ({
onPointerDown: (event) => handleCardPointerDown(event, doc),
onPointerMove: handleCardPointerMove,
onPointerUp: (event) => handleCardPointerUp(event, doc),
onPointerCancel: handleCardPointerCancel,
}),
[
handleCardPointerCancel,
handleCardPointerDown,
handleCardPointerMove,
handleCardPointerUp,
],
);
const handleShellKeyDown = useCallback(
(event) => {
if (!event || event.defaultPrevented) {
return;
}
const { key } = event;
if (key !== ' ' && key !== 'Space' && key !== 'Spacebar') {
return;
}
const target = event.target;
if (target instanceof HTMLElement) {
const tagName = target.tagName ? target.tagName.toLowerCase() : '';
if (
target.isContentEditable
|| tagName === 'input'
|| tagName === 'textarea'
|| tagName === 'select'
|| tagName === 'button'
) {
return;
}
}
if (openOverlayForDoc && Array.isArray(selectedDocumentIds) && selectedDocumentIds.length > 0) {
event.preventDefault();
const targetId = selectedDocumentIds[selectedDocumentIds.length - 1];
if (targetId) {
openOverlayForDoc(targetId);
}
return;
}
},
[openOverlayForDoc, selectedDocumentIds],
);
return {
getCardPointerHandlers,
handleShellKeyDown,
focusShell: () => {
const shell = containerRef.current;
shell?.focus?.({ preventScroll: true });
},
};
};
export default useDeskPointer;
@@ -3,12 +3,22 @@ import {
useEffect,
useRef,
} from 'react';
import { getPointerPosition, preventAll, safeInvoke } from '../events';
import {
isTagTransferEvent,
parseTagTransferPayload,
writeTagTransferData,
} from '../../documents/tagTransfer';
const preventAll = (event) => {
if (!event) return;
if (typeof event.preventDefault === 'function') event.preventDefault();
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) => {
@@ -121,11 +131,13 @@ export const useDeskTagInteractions = ({
requestCanvasFocus?.();
void safeInvoke(onAssignTagToDocument, doc.id, {
id: payload.id,
label: payload.label || '',
sourceDocId: payload.sourceDocId ?? null,
});
if (onAssignTagToDocument) {
onAssignTagToDocument(doc.id, {
id: payload.id,
label: payload.label || '',
sourceDocId: payload.sourceDocId ?? null,
});
}
},
[engine, isTagTransfer, onAssignTagToDocument, requestCanvasFocus],
);
-656
View File
@@ -1,656 +0,0 @@
import {
useCallback,
useEffect,
useRef,
type MutableRefObject,
type RefObject,
} from 'react';
import type { PointerEvent as ReactPointerEvent } from 'react';
import { preventAll } from './events';
import usePointerTap from '../ui/usePointerTap';
import {
type WorkspaceEngine,
type ActiveDragSession,
type DragGroupItem,
type InertiaSimulationState,
CARD_BASE_WEIGHT_GRAMS,
CARD_PAGE_WEIGHT_GRAMS,
} from './workspaceEngine';
import { DRAG_HYSTERESIS_SQUARED } from '../constants/desktop';
import type { Identifier } from '../types/identifiers';
import type { Document } from '../types/documents';
import { getEntryId, isDocumentEntry } from '../app/entryKey';
interface DocumentSizeInfo {
width: number;
height: number;
}
interface LayoutEntry {
centerX?: number;
centerY?: number;
rotation?: number;
z?: number;
width?: number;
height?: number;
}
interface DragTransform {
centerX: number;
centerY: number;
rotation: number;
width?: number;
height?: number;
scale?: number;
}
type EnsureDocumentSizeFn = (doc: Document | null) => DocumentSizeInfo | null;
type ResolveBaseMetricsFn = (
doc: Document | null,
width: number,
height: number,
) => { baseWidth: number; baseHeight: number; baseScale: number };
interface DragSettings {
canvasPadding?: number;
defaultCanvasWidth?: number;
defaultCanvasHeight?: number;
debugDrag?: boolean;
}
export interface PointerDownOptions {
wasSelected?: boolean;
modifierActive?: boolean;
stackHits?: string[] | null;
}
interface UseDocumentDragOptions {
engine?: WorkspaceEngine | null;
layoutRef: MutableRefObject<Map<string, LayoutEntry>>;
dragTransformsRef: MutableRefObject<Map<string, DragTransform>>;
selectionOrderRef?: MutableRefObject<string[]>;
documentLookup: Map<string, Document>;
ensureDocumentSize: EnsureDocumentSizeFn;
resolveBaseMetrics: ResolveBaseMetricsFn;
bringToFront: (docId: Identifier | null) => void;
setDraggingId: (docKey: string | null) => void;
openOverlayForDoc?: (
docId: Identifier | null,
originInfo?: { rotation: number; scale: number; width: number; height: number },
) => void;
recalcVisibleDocIds: () => void;
settings?: DragSettings;
containerRef?: RefObject<HTMLElement>;
onDocumentActivate?: (docId: Identifier | null, event?: PointerEvent | ReactPointerEvent) => void;
markLayoutDirty?: () => void;
}
type PointerEventLike = PointerEvent | ReactPointerEvent<HTMLElement>;
interface DragTapMetadata {
docId: Identifier | null;
originInfo?: { rotation: number; scale: number; width: number; height: number };
docTitle: string;
}
const getDocumentPageCount = (doc?: Document | null): number | null => {
const raw = doc?.current_version?.metadata?.page_count ?? (doc?.metadata as { page_count?: unknown })?.page_count;
if (raw == null) {
return null;
}
const value = Number(raw);
return Number.isFinite(value) ? value : null;
};
const computeDocumentMassGrams = (doc?: Document | null): number => {
const pages = Math.max(1, Math.round(getDocumentPageCount(doc) ?? 1));
return CARD_BASE_WEIGHT_GRAMS + pages * CARD_PAGE_WEIGHT_GRAMS;
};
const getEventTargetElement = (event?: PointerEventLike | null): Element | null => {
if (!event) {
return null;
}
const nativeEvent = 'nativeEvent' in event ? (event as ReactPointerEvent).nativeEvent : null;
const candidate = (event.target as Element | null) || (nativeEvent ? (nativeEvent.target as Element | null) : null);
return candidate instanceof Element ? candidate : null;
};
interface PendingDrag {
pointerId: number;
startX: number;
startY: number;
docId: Identifier;
modifierActive: boolean;
stackHits?: string[] | null;
wasSelected: boolean;
}
const useDocumentDrag = (options: UseDocumentDragOptions & { selectedDocumentIds: Identifier[] }) => {
const {
engine,
layoutRef,
dragTransformsRef,
documentLookup,
ensureDocumentSize,
resolveBaseMetrics,
bringToFront,
setDraggingId,
openOverlayForDoc,
recalcVisibleDocIds,
settings,
containerRef: providedContainerRef,
onDocumentActivate,
markLayoutDirty,
selectionOrderRef,
selectedDocumentIds,
} = options;
const fallbackContainerRef = useRef<HTMLElement | null>(null);
const containerRef = providedContainerRef ?? fallbackContainerRef;
const {
canvasPadding = 24,
debugDrag = false,
} = settings || {};
useEffect(
() => () => {
engine?.disposeInertiaAnimations?.();
},
[engine],
);
const tapHandler = usePointerTap<DragTapMetadata>({
delay: 220,
onSingle: () => { },
onDouble: ({ data, event }) => {
if (!data?.docId) {
return;
}
if (event?.altKey) {
openOverlayForDoc?.(data.docId, data.originInfo);
return;
}
onDocumentActivate?.(data.docId, event);
},
});
const dragStateRef = useRef<ActiveDragSession | null>(null);
const pendingDragRef = useRef<PendingDrag | null>(null);
const clearDragTransforms = useCallback(() => {
const map = dragTransformsRef?.current;
if (!map?.clear) {
return;
}
map.clear();
}, [dragTransformsRef]);
const commitActiveDragTransforms = useCallback((docIds: Array<Identifier | null> | null = null) => {
const map = dragTransformsRef?.current;
if (!map || !map.size) {
return;
}
const keys = Array.isArray(docIds) && docIds.length
? docIds
.map((id) => (id != null ? String(id) : null))
.filter((value): value is string => Boolean(value))
: Array.from(map.keys());
keys.forEach((key) => {
const transform = map.get(key);
if (!transform) {
return;
}
const previous = layoutRef.current.get(key) || {};
layoutRef.current.set(key, {
...previous,
centerX: transform.centerX,
centerY: transform.centerY,
rotation: transform.rotation ?? previous.rotation ?? 0,
});
});
markLayoutDirty?.();
}, [dragTransformsRef, layoutRef, markLayoutDirty]);
const finishDrag = useCallback(
(pointerId: number, { clearTransforms = true }: { clearTransforms?: boolean } = {}) => {
const state = dragStateRef.current;
if (state && state.pointerId === pointerId) {
// Release capture if we have it (stored in a way we can access?
// ActiveDragSession doesn't store capturedTarget element reference because it's not serializable/safe for engine?
// Actually engine doesn't need it. But we might need it here.
// We can keep a local ref for capture or just let it go.
// For now, let's assume implicit release or we can store it in a separate ref if needed.
// But wait, ActiveDragSession in engine doesn't have capturedTarget.
// I should probably keep capturedTarget in a local ref or just ignore it as pointer capture is usually released automatically on up.
// Explicit release is better.
}
dragStateRef.current = null;
setDraggingId(null);
engine?.finalizeGroupDrag?.();
if (clearTransforms) {
clearDragTransforms();
}
},
[clearDragTransforms, engine, setDraggingId],
);
const startDragSession = useCallback((pending: PendingDrag, event: PointerEventLike) => {
const { docId: docIdInput, modifierActive } = pending;
const selectionFromRef: string[] = Array.isArray(selectionOrderRef?.current)
? selectionOrderRef.current
.map((key) => (isDocumentEntry(key) ? getEntryId(key) : null))
.filter((id): id is string => Boolean(id))
.map(String)
: [];
// 1. Get current global selection (prefer ref for immediate updates)
let selectionIds: string[] = selectionFromRef.length
? selectionFromRef
: (selectedDocumentIds || []).map(String);
// 3. Filter for valid documents
selectionIds = selectionIds.filter((id, index, array) => array.indexOf(id) === index && documentLookup.has(id));
if (!selectionIds.length) {
return;
}
// 3. Sort by Z-index (ascending)
const layout = layoutRef.current;
const sortedSelectionIds = [...selectionIds]
.sort((a, b) => {
const aZ = layout.get(a)?.z ?? 0;
const bZ = layout.get(b)?.z ?? 0;
return aZ - bZ;
});
// 4. Determine Anchor
let anchorId = sortedSelectionIds[sortedSelectionIds.length - 1];
if (docIdInput && sortedSelectionIds.includes(String(docIdInput)) && layout.has(String(docIdInput))) {
anchorId = String(docIdInput);
} else {
for (let i = sortedSelectionIds.length - 1; i >= 0; i--) {
if (layout.has(sortedSelectionIds[i])) {
anchorId = sortedSelectionIds[i];
break;
}
}
}
// 5. Promote Anchor to Top (End of List)
const finalSelectionIds = sortedSelectionIds.filter(id => id !== anchorId);
finalSelectionIds.push(anchorId);
const doc = documentLookup.get(anchorId);
if (!doc) {
return;
}
engine?.cancelInertiaAnimation?.(anchorId);
const isGroupDrag = finalSelectionIds.length > 1;
if (isGroupDrag) {
finalSelectionIds.forEach((id) => {
if (id !== anchorId) {
engine?.cancelInertiaAnimation?.(id);
}
});
}
const sizeInfo = ensureDocumentSize(doc) || { width: 0, height: 0 };
const docWidth = sizeInfo.width || 320;
const docHeight = sizeInfo.height || 240;
const { baseScale } = resolveBaseMetrics(doc, docWidth, docHeight);
const normalizedBaseScale =
Number.isFinite(baseScale) && baseScale > 0 ? baseScale : 1;
const entry = layoutRef.current.get(anchorId) || null;
const defaultCenterX = canvasPadding + docWidth / 2;
const defaultCenterY = canvasPadding + docHeight / 2;
const centerX = Number.isFinite(entry?.centerX) ? entry.centerX : defaultCenterX;
const centerY = Number.isFinite(entry?.centerY) ? entry.centerY : defaultCenterY;
const initialCenter = {
x: centerX,
y: centerY,
};
if (!modifierActive) {
if (isGroupDrag) {
finalSelectionIds.forEach((id) => {
bringToFront(id);
});
} else {
bringToFront(anchorId);
}
}
if (entry && (entry.centerX !== centerX || entry.centerY !== centerY)) {
layoutRef.current.set(anchorId, { ...entry, centerX, centerY });
}
const containerRect = containerRef.current?.getBoundingClientRect?.() || null;
const containerLeft = containerRect?.left || 0;
const containerTop = containerRect?.top || 0;
const pointerCanvasX = event.clientX - containerLeft;
const pointerCanvasY = event.clientY - containerTop;
const pointerOffsetX = pointerCanvasX - centerX;
const pointerOffsetY = pointerCanvasY - centerY;
const initialRotationDeg = entry?.rotation ?? 0;
const initialRotationRad = (initialRotationDeg * Math.PI) / 180;
const cosInitial = Math.cos(-initialRotationRad);
const sinInitial = Math.sin(-initialRotationRad);
const localPointerOffsetX = pointerOffsetX * cosInitial - pointerOffsetY * sinInitial;
const localPointerOffsetY = pointerOffsetX * sinInitial + pointerOffsetY * cosInitial;
const groupItems: DragGroupItem[] = finalSelectionIds.map((id) => {
const itemDoc = documentLookup.get(id);
const itemSize = ensureDocumentSize(itemDoc) || sizeInfo;
const itemWidth = itemSize.width || docWidth;
const itemHeight = itemSize.height || docHeight;
const itemEntry = layoutRef.current.get(id) || null;
const itemCenterX =
Number.isFinite(itemEntry?.centerX) ? itemEntry.centerX : canvasPadding + itemWidth / 2;
const itemCenterY =
Number.isFinite(itemEntry?.centerY) ? itemEntry.centerY : canvasPadding + itemHeight / 2;
const baseOffsetX = itemCenterX - initialCenter.x;
const baseOffsetY = itemCenterY - initialCenter.y;
const initialRotation = itemEntry?.rotation ?? 0;
const itemMass = computeDocumentMassGrams(itemDoc);
return {
docId: id,
width: itemWidth,
height: itemHeight,
currentCenterX: itemCenterX,
currentCenterY: itemCenterY,
baseOffsetX,
baseOffsetY,
initialRotation: initialRotation,
targetRotation: initialRotation,
displayRotation: initialRotation,
angularVelocity: 0,
dynamicRotation: 0,
massGrams: itemMass,
};
});
const eventTimestamp =
(Number.isFinite(event?.timeStamp))
? event.timeStamp
: performance?.now
? performance.now()
: Date.now();
const massGrams = computeDocumentMassGrams(doc);
const session: ActiveDragSession = {
pointerId: event.pointerId,
startX: pending.startX,
startY: pending.startY,
lastClientX: event.clientX,
lastClientY: event.clientY,
docKey: anchorId,
isGroup: true,
activeDocIds: finalSelectionIds,
originCenterX: initialCenter.x,
originCenterY: initialCenter.y,
currentCenterX: initialCenter.x,
currentCenterY: initialCenter.y,
rotation: entry?.rotation ?? 0,
restRotation: entry?.rotation ?? 0,
dynamicRotation: 0,
angularVelocity: 0,
moved: true, // It's moving now
width: docWidth,
height: docHeight,
dragScale: 1,
baseScale: normalizedBaseScale,
lastTimestamp: eventTimestamp,
localPointerOffsetX,
localPointerOffsetY,
containerRectLeft: containerLeft,
containerRectTop: containerTop,
groupItems,
groupElevated: !isGroupDrag,
stackSelectionApplied: true,
massGrams,
pointerRadiusScale: 1,
lastPointerCanvasX: pointerCanvasX,
lastPointerCanvasY: pointerCanvasY,
};
dragStateRef.current = session;
clearDragTransforms();
engine?.startDragSession(session);
setDraggingId(anchorId);
}, [
selectedDocumentIds,
selectionOrderRef,
documentLookup,
layoutRef,
ensureDocumentSize,
resolveBaseMetrics,
canvasPadding,
bringToFront,
containerRef,
clearDragTransforms,
engine,
setDraggingId
]);
const handlePointerDown = useCallback(
(event: PointerEventLike, docIdInput: Identifier | null, options: PointerDownOptions) => {
const targetElement = getEventTargetElement(event);
if (targetElement?.closest && targetElement.closest('[data-desk-tag-chip="true"]')) {
return;
}
preventAll(event);
if (!docIdInput) return;
const capturedTarget = event.currentTarget instanceof HTMLElement ? event.currentTarget : null;
if (capturedTarget?.setPointerCapture) {
try {
capturedTarget.setPointerCapture(event.pointerId);
} catch (error) {
if (debugDrag) {
void error;
}
}
}
pendingDragRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.startY || event.clientY,
docId: docIdInput,
modifierActive: Boolean(options.modifierActive),
stackHits: options.stackHits,
wasSelected: Boolean(options.wasSelected),
};
}, [debugDrag]);
const handlePointerMove = useCallback(
(event: PointerEventLike) => {
// Check for pending drag start
if (pendingDragRef.current && pendingDragRef.current.pointerId === event.pointerId) {
const pending = pendingDragRef.current;
const dx = event.clientX - pending.startX;
const dy = event.clientY - pending.startY;
const distSquared = dx * dx + dy * dy;
if (distSquared > DRAG_HYSTERESIS_SQUARED) {
// Threshold exceeded, start actual drag session
startDragSession(pending, event);
pendingDragRef.current = null;
}
}
const state = dragStateRef.current;
if (!state) {
return;
}
if (state.pointerId !== event.pointerId) {
return;
}
preventAll(event);
const currentTimestamp =
(Number.isFinite(event?.timeStamp))
? event.timeStamp
: performance?.now
? performance.now()
: Date.now();
engine?.updateDragSession(event.pointerId, event.clientX, event.clientY, currentTimestamp);
},
[engine, startDragSession],
);
const handlePointerUp = useCallback(
(event: PointerEventLike) => {
// Handle pending drag (click without drag)
if (pendingDragRef.current && pendingDragRef.current.pointerId === event.pointerId) {
const pending = pendingDragRef.current;
pendingDragRef.current = null;
// This was just a click/tap
const docId = pending.docId;
const metaPressed = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
if (!metaPressed) {
bringToFront(docId);
}
// Trigger tap handler
const docKey = String(docId);
const doc = documentLookup.get(docKey);
const sizeInfo = ensureDocumentSize(doc);
const entry = layoutRef.current.get(docKey);
const originInfo = {
rotation: entry?.rotation || 0,
scale: 1,
width: sizeInfo?.width || 0,
height: sizeInfo?.height || 0,
};
tapHandler(event, {
docId,
originInfo,
docTitle: doc?.title || 'document',
});
finishDrag(event.pointerId);
return;
}
const state = dragStateRef.current;
if (!state || state.pointerId !== event.pointerId) {
finishDrag(event.pointerId);
return;
}
if (state.isGroup) {
engine?.finalizeGroupDrag?.();
commitActiveDragTransforms(state.activeDocIds);
finishDrag(event.pointerId);
recalcVisibleDocIds();
return;
}
if (state.moved) {
commitActiveDragTransforms([state.docKey]);
const finalRotation = state.rotation ?? state.restRotation;
const inertiaState: InertiaSimulationState = {
docId: state.docKey,
restRotation: finalRotation,
dynamicRotation: 0,
angularVelocity: state.angularVelocity,
rotation: finalRotation,
width: state.width,
height: state.height,
dragScale: state.dragScale || 1,
lastTimestamp: state.lastTimestamp,
massGrams: state.massGrams,
};
const docId = state.docKey;
finishDrag(event.pointerId);
engine?.startInertiaAnimation?.(docId, inertiaState);
return;
}
finishDrag(event.pointerId);
},
[
bringToFront,
commitActiveDragTransforms,
documentLookup,
engine,
finishDrag,
recalcVisibleDocIds,
tapHandler,
ensureDocumentSize,
layoutRef
],
);
const handlePointerCancel = useCallback(
(event: PointerEventLike) => {
if (pendingDragRef.current && pendingDragRef.current.pointerId === event.pointerId) {
pendingDragRef.current = null;
finishDrag(event.pointerId);
return;
}
const state = dragStateRef.current;
if (state && state.pointerId === event.pointerId && state.moved) {
if (state.isGroup) {
engine?.finalizeGroupDrag?.();
commitActiveDragTransforms(state.activeDocIds);
finishDrag(event.pointerId);
recalcVisibleDocIds();
return;
}
commitActiveDragTransforms([state.docKey]);
const finalRotation = state.rotation ?? state.restRotation;
const inertiaState: InertiaSimulationState = {
docId: state.docKey,
restRotation: finalRotation,
dynamicRotation: 0,
angularVelocity: state.angularVelocity,
rotation: finalRotation,
width: state.width,
height: state.height,
dragScale: state.dragScale || 1,
lastTimestamp: state.lastTimestamp,
massGrams: state.massGrams,
};
const docId = state.docKey;
finishDrag(event.pointerId);
engine?.startInertiaAnimation?.(docId, inertiaState);
return;
}
finishDrag(event.pointerId);
},
[commitActiveDragTransforms, engine, finishDrag, recalcVisibleDocIds],
);
return {
handlePointerDown,
handlePointerMove,
handlePointerUp,
handlePointerCancel,
};
};
export default useDocumentDrag;
File diff suppressed because it is too large Load Diff
@@ -30,10 +30,7 @@
outline-offset: 4px;
}
.desk-item.is-dragging {
cursor: grabbing;
transition: none;
}
.desk-item.is-tag-target .desk-item__card {
outline: 0.35rem dashed var(--accent);
@@ -136,4 +133,4 @@
.desk-item__tags .tag-chip--tear-pending {
opacity: 0.35;
}
}