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 DesktopPreviewCard from './DesktopPreviewCard';
|
||||||
import { resolveCorrespondents } from '../documents/correspondents';
|
import { resolveCorrespondents } from '../documents/correspondents';
|
||||||
import { getTagColorStyle } from '../utils/colors';
|
import { getTagColorStyle } from '../utils/colors';
|
||||||
import { preventAll } from './events';
|
|
||||||
import type { DocumentId } from '../types/identifiers';
|
import type { DocumentId } from '../types/identifiers';
|
||||||
|
|
||||||
import type { Document } from '../types/documents';
|
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 {
|
interface PendingRemovalTag {
|
||||||
docId?: string;
|
docId?: string;
|
||||||
tagId?: string;
|
tagId?: string;
|
||||||
@@ -16,7 +20,6 @@ interface DesktopDocumentCardProps {
|
|||||||
doc: Document;
|
doc: Document;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
shouldLoad?: boolean;
|
shouldLoad?: boolean;
|
||||||
dragging?: boolean;
|
|
||||||
matchesFilter?: boolean;
|
matchesFilter?: boolean;
|
||||||
tagTargetActive?: boolean;
|
tagTargetActive?: boolean;
|
||||||
tagTargetPending?: boolean;
|
tagTargetPending?: boolean;
|
||||||
@@ -43,7 +46,6 @@ const DesktopDocumentCard: React.FC<DesktopDocumentCardProps> = ({
|
|||||||
doc,
|
doc,
|
||||||
style,
|
style,
|
||||||
shouldLoad,
|
shouldLoad,
|
||||||
dragging,
|
|
||||||
matchesFilter,
|
matchesFilter,
|
||||||
tagTargetActive,
|
tagTargetActive,
|
||||||
tagTargetPending,
|
tagTargetPending,
|
||||||
@@ -69,7 +71,6 @@ const DesktopDocumentCard: React.FC<DesktopDocumentCardProps> = ({
|
|||||||
const tags = Array.isArray(doc?.tags) ? doc.tags : [];
|
const tags = Array.isArray(doc?.tags) ? doc.tags : [];
|
||||||
|
|
||||||
const itemClasses = ['desk-item'];
|
const itemClasses = ['desk-item'];
|
||||||
if (dragging) itemClasses.push('is-dragging');
|
|
||||||
if (tagTargetActive) itemClasses.push('is-tag-target');
|
if (tagTargetActive) itemClasses.push('is-tag-target');
|
||||||
if (tagTargetPending) itemClasses.push('is-tag-pending');
|
if (tagTargetPending) itemClasses.push('is-tag-pending');
|
||||||
if (!matchesFilter) itemClasses.push('is-filtered-out');
|
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,
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { getPointerPosition, preventAll, safeInvoke } from '../events';
|
|
||||||
import {
|
import {
|
||||||
isTagTransferEvent,
|
isTagTransferEvent,
|
||||||
parseTagTransferPayload,
|
parseTagTransferPayload,
|
||||||
writeTagTransferData,
|
writeTagTransferData,
|
||||||
} from '../../documents/tagTransfer';
|
} 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';
|
import { TAG_REMOVE_DISTANCE } from '../../constants/desktop';
|
||||||
|
|
||||||
const createDragPreview = (node, clientX, clientY) => {
|
const createDragPreview = (node, clientX, clientY) => {
|
||||||
@@ -121,11 +131,13 @@ export const useDeskTagInteractions = ({
|
|||||||
|
|
||||||
requestCanvasFocus?.();
|
requestCanvasFocus?.();
|
||||||
|
|
||||||
void safeInvoke(onAssignTagToDocument, doc.id, {
|
if (onAssignTagToDocument) {
|
||||||
id: payload.id,
|
onAssignTagToDocument(doc.id, {
|
||||||
label: payload.label || '',
|
id: payload.id,
|
||||||
sourceDocId: payload.sourceDocId ?? null,
|
label: payload.label || '',
|
||||||
});
|
sourceDocId: payload.sourceDocId ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[engine, isTagTransfer, onAssignTagToDocument, requestCanvasFocus],
|
[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;
|
outline-offset: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.desk-item.is-dragging {
|
|
||||||
cursor: grabbing;
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.desk-item.is-tag-target .desk-item__card {
|
.desk-item.is-tag-target .desk-item__card {
|
||||||
outline: 0.35rem dashed var(--accent);
|
outline: 0.35rem dashed var(--accent);
|
||||||
|
|||||||
Reference in New Issue
Block a user