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