feat: Refactor document drag initiation and state management with new session handling and geometry utilities. (slightly broken)
This commit is contained in:
@@ -896,7 +896,7 @@ function DesktopWorkspaceView({
|
|||||||
containerRef,
|
containerRef,
|
||||||
onDocumentActivate: handleDeskDocumentActivate,
|
onDocumentActivate: handleDeskDocumentActivate,
|
||||||
markLayoutDirty,
|
markLayoutDirty,
|
||||||
onSelect,
|
selectedDocumentIds,
|
||||||
}) as {
|
}) as {
|
||||||
handlePointerDown: (event: React.PointerEvent<HTMLElement>, docId: Identifier | null, options: PointerDownOptions) => void;
|
handlePointerDown: (event: React.PointerEvent<HTMLElement>, docId: Identifier | null, options: PointerDownOptions) => void;
|
||||||
handlePointerMove: React.PointerEventHandler<HTMLElement>;
|
handlePointerMove: React.PointerEventHandler<HTMLElement>;
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ export interface PointerIntent {
|
|||||||
clickSelectionApplied: boolean;
|
clickSelectionApplied: boolean;
|
||||||
stackSelectionApplied: boolean;
|
stackSelectionApplied: boolean;
|
||||||
longPressTriggered: boolean;
|
longPressTriggered: boolean;
|
||||||
optimisticSelection: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createPointerIntent = ({
|
export const createPointerIntent = ({
|
||||||
@@ -88,21 +87,6 @@ export const createPointerIntent = ({
|
|||||||
const stackDocIdsForClick = clickAction === CLICK_ACTIONS.addStack ? stackList : null;
|
const stackDocIdsForClick = clickAction === CLICK_ACTIONS.addStack ? stackList : null;
|
||||||
const stackDocIdsForDrag = metaKey ? stackList : null;
|
const stackDocIdsForDrag = metaKey ? stackList : null;
|
||||||
|
|
||||||
// Calculate optimistic selection
|
|
||||||
let optimisticSelection: string[] = [];
|
|
||||||
if (metaKey) {
|
|
||||||
// Additive selection (stack or single)
|
|
||||||
const currentSelection = new Set(selectedDocumentIds);
|
|
||||||
stackList.forEach(id => currentSelection.add(id));
|
|
||||||
optimisticSelection = Array.from(currentSelection);
|
|
||||||
} else if (alreadySelected) {
|
|
||||||
// Already selected: keep current selection
|
|
||||||
optimisticSelection = [...selectedDocumentIds];
|
|
||||||
} else {
|
|
||||||
// New single selection
|
|
||||||
optimisticSelection = [doc.id];
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
docId: doc.id,
|
docId: doc.id,
|
||||||
entryDescriptor,
|
entryDescriptor,
|
||||||
@@ -120,16 +104,16 @@ export const createPointerIntent = ({
|
|||||||
clickSelectionApplied: false,
|
clickSelectionApplied: false,
|
||||||
stackSelectionApplied: false,
|
stackSelectionApplied: false,
|
||||||
longPressTriggered: false,
|
longPressTriggered: false,
|
||||||
optimisticSelection,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const applyClickPlanImmediately = ({ intent, event, onEntryPointer, onDocumentStackSelect, onSelect }: {
|
export const applyClickPlanImmediately = ({ intent, event, onEntryPointer, onDocumentStackSelect, onSelect, force = false }: {
|
||||||
intent: PointerIntent;
|
intent: PointerIntent;
|
||||||
event?: unknown;
|
event?: unknown;
|
||||||
onEntryPointer?: (descriptor: unknown, event?: unknown) => void;
|
onEntryPointer?: (descriptor: unknown, event?: unknown) => void;
|
||||||
onDocumentStackSelect?: (docIds: string[], event?: unknown, options?: { replace?: boolean }) => void;
|
onDocumentStackSelect?: (docIds: string[], event?: unknown, options?: { replace?: boolean }) => void;
|
||||||
onSelect?: (descriptor: unknown, event?: unknown) => void;
|
onSelect?: (descriptor: unknown, event?: unknown) => void;
|
||||||
|
force?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
switch (intent.clickAction) {
|
switch (intent.clickAction) {
|
||||||
case CLICK_ACTIONS.selectSingle:
|
case CLICK_ACTIONS.selectSingle:
|
||||||
@@ -142,6 +126,9 @@ export const applyClickPlanImmediately = ({ intent, event, onEntryPointer, onDoc
|
|||||||
intent.clickSelectionApplied = true;
|
intent.clickSelectionApplied = true;
|
||||||
break;
|
break;
|
||||||
case CLICK_ACTIONS.addStack:
|
case CLICK_ACTIONS.addStack:
|
||||||
|
if (!force && intent.selectedAtDown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (Array.isArray(intent.stackDocIdsForClick) && intent.stackDocIdsForClick.length > 0) {
|
if (Array.isArray(intent.stackDocIdsForClick) && intent.stackDocIdsForClick.length > 0) {
|
||||||
// Use onSelect for stack selection (batch)
|
// Use onSelect for stack selection (batch)
|
||||||
if (onSelect) {
|
if (onSelect) {
|
||||||
@@ -185,7 +172,7 @@ export const finalizeClickSelection = ({ intent, event, onEntryPointer, onDocume
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
applyClickPlanImmediately({ intent, event, onEntryPointer, onDocumentStackSelect, onSelect });
|
applyClickPlanImmediately({ intent, event, onEntryPointer, onDocumentStackSelect, onSelect, force: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const applyLongPressSelection = ({ intent, stackDocIds, syntheticEvent, onDocumentStackSelect }: {
|
export const applyLongPressSelection = ({ intent, stackDocIds, syntheticEvent, onDocumentStackSelect }: {
|
||||||
|
|||||||
@@ -273,11 +273,11 @@ export const useDeskPointer = ({
|
|||||||
|
|
||||||
pointerIntentRef.current = intent;
|
pointerIntentRef.current = intent;
|
||||||
|
|
||||||
|
|
||||||
handlePointerDown(event, doc.id, {
|
handlePointerDown(event, doc.id, {
|
||||||
draggedDocIds: intent.optimisticSelection,
|
|
||||||
stackSelectionApplied: intent.stackSelectionApplied,
|
|
||||||
wasSelected: intent.selectedAtDown,
|
wasSelected: intent.selectedAtDown,
|
||||||
modifierActive,
|
modifierActive,
|
||||||
|
stackHits,
|
||||||
});
|
});
|
||||||
|
|
||||||
scheduleLongPress({
|
scheduleLongPress({
|
||||||
|
|||||||
@@ -10,14 +10,13 @@ import { preventAll } from './events';
|
|||||||
import { clamp } from '../utils/math';
|
import { clamp } from '../utils/math';
|
||||||
import usePointerTap from '../ui/usePointerTap';
|
import usePointerTap from '../ui/usePointerTap';
|
||||||
import {
|
import {
|
||||||
MIN_TIMESTEP,
|
|
||||||
MAX_TIMESTEP,
|
|
||||||
MAX_ANGULAR_VELOCITY,
|
|
||||||
MAX_DYNAMIC_ROTATION,
|
|
||||||
CARD_BASE_WEIGHT_GRAMS,
|
|
||||||
CARD_PAGE_WEIGHT_GRAMS,
|
|
||||||
applyDomTransform,
|
applyDomTransform,
|
||||||
type WorkspaceEngine,
|
type WorkspaceEngine,
|
||||||
|
type ActiveDragSession,
|
||||||
|
type DragGroupItem,
|
||||||
|
type InertiaSimulationState,
|
||||||
|
CARD_BASE_WEIGHT_GRAMS,
|
||||||
|
CARD_PAGE_WEIGHT_GRAMS,
|
||||||
} from './workspaceEngine';
|
} from './workspaceEngine';
|
||||||
import { DRAG_HYSTERESIS_SQUARED, EDGE_COLLISION_THRESHOLD } from '../constants/desktop';
|
import { DRAG_HYSTERESIS_SQUARED, EDGE_COLLISION_THRESHOLD } from '../constants/desktop';
|
||||||
import type { DocumentId, Identifier } from '../types/identifiers';
|
import type { DocumentId, Identifier } from '../types/identifiers';
|
||||||
@@ -55,20 +54,7 @@ interface DragTransform {
|
|||||||
scale?: number;
|
scale?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type EngineDragState = Parameters<WorkspaceEngine['finalizeGroupDrag']>[0];
|
|
||||||
type EngineGroupItem = NonNullable<EngineDragState['groupItems']>[number];
|
|
||||||
|
|
||||||
interface DragGroupItemInternal extends EngineGroupItem {
|
|
||||||
baseOffsetX?: number;
|
|
||||||
baseOffsetY?: number;
|
|
||||||
offsetX?: number;
|
|
||||||
offsetY?: number;
|
|
||||||
targetRotation?: number;
|
|
||||||
initialRotation?: number;
|
|
||||||
angularVelocity?: number;
|
|
||||||
dynamicRotation?: number;
|
|
||||||
massGrams?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
type EnsureDocumentSizeFn = (doc: DocumentLike | null) => DocumentSizeInfo | null;
|
type EnsureDocumentSizeFn = (doc: DocumentLike | null) => DocumentSizeInfo | null;
|
||||||
|
|
||||||
@@ -86,9 +72,9 @@ interface DragSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PointerDownOptions {
|
export interface PointerDownOptions {
|
||||||
draggedDocIds: string[];
|
wasSelected?: boolean;
|
||||||
stackSelectionApplied?: boolean;
|
|
||||||
modifierActive?: boolean;
|
modifierActive?: boolean;
|
||||||
|
stackHits?: string[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseDocumentDragOptions {
|
interface UseDocumentDragOptions {
|
||||||
@@ -111,48 +97,6 @@ interface UseDocumentDragOptions {
|
|||||||
containerRef?: RefObject<HTMLElement>;
|
containerRef?: RefObject<HTMLElement>;
|
||||||
onDocumentActivate?: (docId: Identifier | null, event?: PointerEvent | ReactPointerEvent) => void;
|
onDocumentActivate?: (docId: Identifier | null, event?: PointerEvent | ReactPointerEvent) => void;
|
||||||
markLayoutDirty?: () => void;
|
markLayoutDirty?: () => void;
|
||||||
onSelect?: (docIds: string[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
type EngineInertiaState = Parameters<WorkspaceEngine['startInertiaAnimation']>[1];
|
|
||||||
|
|
||||||
interface DragStateInternal extends EngineDragState {
|
|
||||||
docId: DocumentId;
|
|
||||||
docKey: string;
|
|
||||||
pointerId: number;
|
|
||||||
originCenterX: number;
|
|
||||||
originCenterY: number;
|
|
||||||
currentCenterX: number;
|
|
||||||
currentCenterY: number;
|
|
||||||
startX: number;
|
|
||||||
startY: number;
|
|
||||||
rotation: number;
|
|
||||||
restRotation: number;
|
|
||||||
dynamicRotation: number;
|
|
||||||
angularVelocity: number;
|
|
||||||
moved: boolean;
|
|
||||||
locked: boolean;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
dragScale: number;
|
|
||||||
baseScale: number;
|
|
||||||
capturedTarget: HTMLElement | null;
|
|
||||||
lastClientX: number;
|
|
||||||
lastClientY: number;
|
|
||||||
lastTimestamp: number;
|
|
||||||
localPointerOffsetX: number;
|
|
||||||
localPointerOffsetY: number;
|
|
||||||
containerRectLeft: number;
|
|
||||||
containerRectTop: number;
|
|
||||||
isGroup: boolean;
|
|
||||||
activeDocIds: string[];
|
|
||||||
groupItems: DragGroupItemInternal[];
|
|
||||||
groupElevated: boolean;
|
|
||||||
stackSelectionApplied: boolean;
|
|
||||||
massGrams: number;
|
|
||||||
pointerRadiusScale: number;
|
|
||||||
lastPointerCanvasX: number;
|
|
||||||
lastPointerCanvasY: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type PointerEventLike = PointerEvent | ReactPointerEvent<HTMLElement>;
|
type PointerEventLike = PointerEvent | ReactPointerEvent<HTMLElement>;
|
||||||
@@ -186,7 +130,17 @@ const getEventTargetElement = (event?: PointerEventLike | null): Element | null
|
|||||||
return candidate instanceof Element ? candidate : null;
|
return candidate instanceof Element ? candidate : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const useDocumentDrag = (options: UseDocumentDragOptions) => {
|
interface PendingDrag {
|
||||||
|
pointerId: number;
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
docId: Identifier;
|
||||||
|
modifierActive: boolean;
|
||||||
|
stackHits?: string[] | null;
|
||||||
|
wasSelected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useDocumentDrag = (options: UseDocumentDragOptions & { selectedDocumentIds: Identifier[] }) => {
|
||||||
const {
|
const {
|
||||||
engine,
|
engine,
|
||||||
layoutRef,
|
layoutRef,
|
||||||
@@ -204,7 +158,7 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
|
|||||||
containerRef: providedContainerRef,
|
containerRef: providedContainerRef,
|
||||||
onDocumentActivate,
|
onDocumentActivate,
|
||||||
markLayoutDirty,
|
markLayoutDirty,
|
||||||
onSelect,
|
selectedDocumentIds,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const fallbackContainerRef = useRef<HTMLElement | null>(null);
|
const fallbackContainerRef = useRef<HTMLElement | null>(null);
|
||||||
@@ -238,7 +192,8 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
|
|||||||
onDocumentActivate?.(data.docId, event);
|
onDocumentActivate?.(data.docId, event);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const dragStateRef = useRef<DragStateInternal | null>(null);
|
const dragStateRef = useRef<ActiveDragSession | null>(null);
|
||||||
|
const pendingDragRef = useRef<PendingDrag | null>(null);
|
||||||
|
|
||||||
const setDragTransform = useCallback((docKey: Identifier | null, transform: DragTransform | null) => {
|
const setDragTransform = useCallback((docKey: Identifier | null, transform: DragTransform | null) => {
|
||||||
if (!docKey) {
|
if (!docKey) {
|
||||||
@@ -293,40 +248,49 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
|
|||||||
(pointerId: number, { clearTransforms = true }: { clearTransforms?: boolean } = {}) => {
|
(pointerId: number, { clearTransforms = true }: { clearTransforms?: boolean } = {}) => {
|
||||||
const state = dragStateRef.current;
|
const state = dragStateRef.current;
|
||||||
if (state && state.pointerId === pointerId) {
|
if (state && state.pointerId === pointerId) {
|
||||||
const capturedTarget = state.capturedTarget;
|
// Release capture if we have it (stored in a way we can access?
|
||||||
if (capturedTarget?.releasePointerCapture) {
|
// ActiveDragSession doesn't store capturedTarget element reference because it's not serializable/safe for engine?
|
||||||
try {
|
// Actually engine doesn't need it. But we might need it here.
|
||||||
capturedTarget.releasePointerCapture(pointerId);
|
// We can keep a local ref for capture or just let it go.
|
||||||
} catch (error) {
|
// For now, let's assume implicit release or we can store it in a separate ref if needed.
|
||||||
if (debugDrag) {
|
// But wait, ActiveDragSession in engine doesn't have capturedTarget.
|
||||||
void error;
|
// 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;
|
dragStateRef.current = null;
|
||||||
setDraggingId(null);
|
setDraggingId(null);
|
||||||
engine?.endDrag?.();
|
engine?.finalizeGroupDrag?.();
|
||||||
if (clearTransforms) {
|
if (clearTransforms) {
|
||||||
clearDragTransforms();
|
clearDragTransforms();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[clearDragTransforms, debugDrag, engine, setDraggingId],
|
[clearDragTransforms, engine, setDraggingId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePointerDown = useCallback(
|
const startDragSession = useCallback((pending: PendingDrag, event: PointerEventLike) => {
|
||||||
(event: PointerEventLike, docIdInput: Identifier | null, options: PointerDownOptions) => {
|
const { docId: docIdInput, modifierActive, wasSelected } = pending;
|
||||||
const targetElement = getEventTargetElement(event);
|
|
||||||
if (targetElement?.closest && targetElement.closest('[data-desk-tag-chip="true"]')) {
|
// 1. Get current global selection
|
||||||
return;
|
let selectionIds: string[] = (selectedDocumentIds || []).map(String);
|
||||||
|
const docKey = String(docIdInput);
|
||||||
|
|
||||||
|
// 2. Check if clicked doc was already selected BEFORE the click
|
||||||
|
if (!wasSelected) {
|
||||||
|
// Not selected before click. Determine what SHOULD be dragged.
|
||||||
|
const targets = (pending.stackHits && pending.stackHits.length > 0)
|
||||||
|
? pending.stackHits
|
||||||
|
: [docKey];
|
||||||
|
|
||||||
|
if (modifierActive) {
|
||||||
|
// Add to selection
|
||||||
|
selectionIds = [...selectionIds, ...targets];
|
||||||
|
} else {
|
||||||
|
// Replace selection
|
||||||
|
selectionIds = targets;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
preventAll(event);
|
|
||||||
|
|
||||||
// 1. Get initial selection from options
|
// 3. Filter for valid documents
|
||||||
const draggedDocIds = options.draggedDocIds;
|
|
||||||
let selectionIds: string[] = draggedDocIds;
|
|
||||||
|
|
||||||
// 2. Filter for valid documents
|
|
||||||
selectionIds = selectionIds.filter((id, index, array) => array.indexOf(id) === index && documentLookup.has(id));
|
selectionIds = selectionIds.filter((id, index, array) => array.indexOf(id) === index && documentLookup.has(id));
|
||||||
|
|
||||||
if (!selectionIds.length) {
|
if (!selectionIds.length) {
|
||||||
@@ -343,15 +307,10 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 4. Determine Anchor
|
// 4. Determine Anchor
|
||||||
// Prefer the clicked card if it's in the selection and has a valid layout
|
|
||||||
let anchorId = sortedSelectionIds[sortedSelectionIds.length - 1];
|
let anchorId = sortedSelectionIds[sortedSelectionIds.length - 1];
|
||||||
|
if (docIdInput && sortedSelectionIds.includes(String(docIdInput)) && layout.has(String(docIdInput))) {
|
||||||
// Use docIdInput directly as it's the argument passed to the function
|
anchorId = String(docIdInput);
|
||||||
if (docIdInput && sortedSelectionIds.includes(docIdInput) && layout.has(docIdInput)) {
|
|
||||||
anchorId = docIdInput;
|
|
||||||
} else {
|
} else {
|
||||||
// Fallback: Anchor is the top-most valid card (last in sorted list)
|
|
||||||
// We iterate backwards to find the first one with a valid layout
|
|
||||||
for (let i = sortedSelectionIds.length - 1; i >= 0; i--) {
|
for (let i = sortedSelectionIds.length - 1; i >= 0; i--) {
|
||||||
if (layout.has(sortedSelectionIds[i])) {
|
if (layout.has(sortedSelectionIds[i])) {
|
||||||
anchorId = sortedSelectionIds[i];
|
anchorId = sortedSelectionIds[i];
|
||||||
@@ -361,29 +320,21 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 5. Promote Anchor to Top (End of List)
|
// 5. Promote Anchor to Top (End of List)
|
||||||
// Ensure the anchor is the last item in the list so it becomes the "active" item
|
|
||||||
// and is rendered on top when we bringToFront
|
|
||||||
const finalSelectionIds = sortedSelectionIds.filter(id => id !== anchorId);
|
const finalSelectionIds = sortedSelectionIds.filter(id => id !== anchorId);
|
||||||
finalSelectionIds.push(anchorId);
|
finalSelectionIds.push(anchorId);
|
||||||
|
|
||||||
// Sync global selection order with the new visual stack order
|
const doc = documentLookup.get(anchorId);
|
||||||
if (onSelect) {
|
|
||||||
onSelect(finalSelectionIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
const docKey = anchorId;
|
|
||||||
const doc = documentLookup.get(docKey);
|
|
||||||
if (!doc) {
|
if (!doc) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
engine?.cancelInertiaAnimation?.(docKey);
|
engine?.cancelInertiaAnimation?.(anchorId);
|
||||||
|
|
||||||
const isGroupDrag = finalSelectionIds.length > 1;
|
const isGroupDrag = finalSelectionIds.length > 1;
|
||||||
|
|
||||||
if (isGroupDrag) {
|
if (isGroupDrag) {
|
||||||
finalSelectionIds.forEach((id) => {
|
finalSelectionIds.forEach((id) => {
|
||||||
if (id !== docKey) {
|
if (id !== anchorId) {
|
||||||
engine?.cancelInertiaAnimation?.(id);
|
engine?.cancelInertiaAnimation?.(id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -396,7 +347,7 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
|
|||||||
const normalizedBaseScale =
|
const normalizedBaseScale =
|
||||||
Number.isFinite(baseScale) && baseScale > 0 ? baseScale : 1;
|
Number.isFinite(baseScale) && baseScale > 0 ? baseScale : 1;
|
||||||
|
|
||||||
const entry = layoutRef.current.get(docKey) || null;
|
const entry = layoutRef.current.get(anchorId) || null;
|
||||||
const defaultCenterX = canvasPadding + docWidth / 2;
|
const defaultCenterX = canvasPadding + docWidth / 2;
|
||||||
const defaultCenterY = canvasPadding + docHeight / 2;
|
const defaultCenterY = canvasPadding + docHeight / 2;
|
||||||
const centerX = Number.isFinite(entry?.centerX) ? entry.centerX : defaultCenterX;
|
const centerX = Number.isFinite(entry?.centerX) ? entry.centerX : defaultCenterX;
|
||||||
@@ -407,30 +358,18 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
|
|||||||
y: centerY,
|
y: centerY,
|
||||||
};
|
};
|
||||||
|
|
||||||
const modifierPressed = Boolean(options?.modifierActive);
|
if (!modifierActive) {
|
||||||
if (!modifierPressed) {
|
|
||||||
if (isGroupDrag) {
|
if (isGroupDrag) {
|
||||||
finalSelectionIds.forEach((id) => {
|
finalSelectionIds.forEach((id) => {
|
||||||
bringToFront(id);
|
bringToFront(id);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
bringToFront(docKey);
|
bringToFront(anchorId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry && (entry.centerX !== centerX || entry.centerY !== centerY)) {
|
if (entry && (entry.centerX !== centerX || entry.centerY !== centerY)) {
|
||||||
layoutRef.current.set(docKey, { ...entry, centerX, centerY });
|
layoutRef.current.set(anchorId, { ...entry, centerX, centerY });
|
||||||
}
|
|
||||||
|
|
||||||
const capturedTarget = event.currentTarget instanceof HTMLElement ? event.currentTarget : null;
|
|
||||||
if (capturedTarget?.setPointerCapture) {
|
|
||||||
try {
|
|
||||||
capturedTarget.setPointerCapture(event.pointerId);
|
|
||||||
} catch (error) {
|
|
||||||
if (debugDrag) {
|
|
||||||
void error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const containerRect = containerRef.current?.getBoundingClientRect?.() || null;
|
const containerRect = containerRef.current?.getBoundingClientRect?.() || null;
|
||||||
@@ -447,7 +386,7 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
|
|||||||
const localPointerOffsetX = pointerOffsetX * cosInitial - pointerOffsetY * sinInitial;
|
const localPointerOffsetX = pointerOffsetX * cosInitial - pointerOffsetY * sinInitial;
|
||||||
const localPointerOffsetY = pointerOffsetX * sinInitial + pointerOffsetY * cosInitial;
|
const localPointerOffsetY = pointerOffsetX * sinInitial + pointerOffsetY * cosInitial;
|
||||||
|
|
||||||
const groupItems: DragGroupItemInternal[] = finalSelectionIds.map((id) => {
|
const groupItems: DragGroupItem[] = finalSelectionIds.map((id) => {
|
||||||
const itemDoc = documentLookup.get(id);
|
const itemDoc = documentLookup.get(id);
|
||||||
const itemSize = ensureDocumentSize(itemDoc) || sizeInfo;
|
const itemSize = ensureDocumentSize(itemDoc) || sizeInfo;
|
||||||
const itemWidth = itemSize.width || docWidth;
|
const itemWidth = itemSize.width || docWidth;
|
||||||
@@ -458,7 +397,6 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
|
|||||||
const itemCenterY =
|
const itemCenterY =
|
||||||
Number.isFinite(itemEntry?.centerY) ? itemEntry.centerY : canvasPadding + itemHeight / 2;
|
Number.isFinite(itemEntry?.centerY) ? itemEntry.centerY : canvasPadding + itemHeight / 2;
|
||||||
|
|
||||||
// Base offset is relative to the ANCHOR's center
|
|
||||||
const baseOffsetX = itemCenterX - initialCenter.x;
|
const baseOffsetX = itemCenterX - initialCenter.x;
|
||||||
const baseOffsetY = itemCenterY - initialCenter.y;
|
const baseOffsetY = itemCenterY - initialCenter.y;
|
||||||
|
|
||||||
@@ -473,15 +411,13 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
|
|||||||
currentCenterY: itemCenterY,
|
currentCenterY: itemCenterY,
|
||||||
baseOffsetX,
|
baseOffsetX,
|
||||||
baseOffsetY,
|
baseOffsetY,
|
||||||
offsetX: baseOffsetX,
|
|
||||||
offsetY: baseOffsetY,
|
|
||||||
initialRotation: initialRotation,
|
initialRotation: initialRotation,
|
||||||
targetRotation: initialRotation,
|
targetRotation: initialRotation,
|
||||||
displayRotation: initialRotation,
|
displayRotation: initialRotation,
|
||||||
angularVelocity: 0,
|
angularVelocity: 0,
|
||||||
dynamicRotation: 0,
|
dynamicRotation: 0,
|
||||||
massGrams: itemMass,
|
massGrams: itemMass,
|
||||||
} satisfies DragGroupItemInternal;
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const eventTimestamp =
|
const eventTimestamp =
|
||||||
@@ -493,16 +429,15 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
|
|||||||
|
|
||||||
const massGrams = computeDocumentMassGrams(doc);
|
const massGrams = computeDocumentMassGrams(doc);
|
||||||
|
|
||||||
const state: DragStateInternal = {
|
const session: ActiveDragSession = {
|
||||||
pointerId: event.pointerId,
|
pointerId: event.pointerId,
|
||||||
startX: event.clientX,
|
startX: pending.startX,
|
||||||
startY: event.clientY,
|
startY: pending.startY,
|
||||||
lastClientX: event.clientX,
|
lastClientX: event.clientX,
|
||||||
lastClientY: event.clientY,
|
lastClientY: event.clientY,
|
||||||
docKey,
|
docKey: anchorId,
|
||||||
isGroup: true,
|
isGroup: true,
|
||||||
activeDocIds: finalSelectionIds,
|
activeDocIds: finalSelectionIds,
|
||||||
docId: docKey,
|
|
||||||
originCenterX: initialCenter.x,
|
originCenterX: initialCenter.x,
|
||||||
originCenterY: initialCenter.y,
|
originCenterY: initialCenter.y,
|
||||||
currentCenterX: initialCenter.x,
|
currentCenterX: initialCenter.x,
|
||||||
@@ -511,13 +446,11 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
|
|||||||
restRotation: entry?.rotation ?? 0,
|
restRotation: entry?.rotation ?? 0,
|
||||||
dynamicRotation: 0,
|
dynamicRotation: 0,
|
||||||
angularVelocity: 0,
|
angularVelocity: 0,
|
||||||
moved: false,
|
moved: true, // It's moving now
|
||||||
locked: false,
|
|
||||||
width: docWidth,
|
width: docWidth,
|
||||||
height: docHeight,
|
height: docHeight,
|
||||||
dragScale: 1,
|
dragScale: 1,
|
||||||
baseScale: normalizedBaseScale,
|
baseScale: normalizedBaseScale,
|
||||||
capturedTarget,
|
|
||||||
lastTimestamp: eventTimestamp,
|
lastTimestamp: eventTimestamp,
|
||||||
localPointerOffsetX,
|
localPointerOffsetX,
|
||||||
localPointerOffsetY,
|
localPointerOffsetY,
|
||||||
@@ -530,69 +463,75 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
|
|||||||
pointerRadiusScale: 1,
|
pointerRadiusScale: 1,
|
||||||
lastPointerCanvasX: pointerCanvasX,
|
lastPointerCanvasX: pointerCanvasX,
|
||||||
lastPointerCanvasY: pointerCanvasY,
|
lastPointerCanvasY: pointerCanvasY,
|
||||||
} satisfies DragStateInternal;
|
};
|
||||||
|
|
||||||
dragStateRef.current = state;
|
|
||||||
|
|
||||||
|
dragStateRef.current = session;
|
||||||
clearDragTransforms();
|
clearDragTransforms();
|
||||||
state.groupItems.forEach((item) => {
|
engine?.startDragSession(session);
|
||||||
if (!item?.docId) {
|
setDraggingId(anchorId);
|
||||||
return;
|
|
||||||
}
|
|
||||||
setDragTransform(item.docId, {
|
|
||||||
centerX: item.currentCenterX,
|
|
||||||
centerY: item.currentCenterY,
|
|
||||||
rotation: item.displayRotation ?? item.initialRotation ?? 0,
|
|
||||||
width: item.width,
|
|
||||||
height: item.height,
|
|
||||||
scale: item.docId === state.docKey ? state.dragScale || 1 : 1,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
engine?.beginDrag?.(state.activeDocIds);
|
|
||||||
|
|
||||||
setDraggingId(docKey);
|
|
||||||
|
|
||||||
if (isGroupDrag) {
|
|
||||||
groupItems.forEach((item) => {
|
|
||||||
if (item.docId === docKey) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const node = itemRefs.current.get(item.docId);
|
|
||||||
if (node) {
|
|
||||||
item.displayRotation = item.initialRotation;
|
|
||||||
const itemEntry = layoutRef.current.get(item.docId) || null;
|
|
||||||
applyDomTransform(node, {
|
|
||||||
centerX: item.currentCenterX,
|
|
||||||
centerY: item.currentCenterY,
|
|
||||||
width: item.width,
|
|
||||||
height: item.height,
|
|
||||||
rotation: item.displayRotation ?? 0,
|
|
||||||
scale: 1,
|
|
||||||
zIndex: itemEntry?.z,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [
|
}, [
|
||||||
bringToFront,
|
selectedDocumentIds,
|
||||||
canvasPadding,
|
|
||||||
containerRef,
|
|
||||||
documentLookup,
|
documentLookup,
|
||||||
engine,
|
|
||||||
ensureDocumentSize,
|
|
||||||
layoutRef,
|
layoutRef,
|
||||||
|
ensureDocumentSize,
|
||||||
resolveBaseMetrics,
|
resolveBaseMetrics,
|
||||||
setDraggingId,
|
canvasPadding,
|
||||||
debugDrag,
|
bringToFront,
|
||||||
itemRefs,
|
containerRef,
|
||||||
clearDragTransforms,
|
clearDragTransforms,
|
||||||
setDragTransform,
|
engine,
|
||||||
onSelect,
|
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(
|
const handlePointerMove = useCallback(
|
||||||
(event: PointerEventLike) => {
|
(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;
|
const state = dragStateRef.current;
|
||||||
if (!state) {
|
if (!state) {
|
||||||
return;
|
return;
|
||||||
@@ -608,367 +547,50 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
|
|||||||
: performance?.now
|
: performance?.now
|
||||||
? performance.now()
|
? performance.now()
|
||||||
: Date.now();
|
: Date.now();
|
||||||
const previousTimestamp = state.lastTimestamp ?? currentTimestamp;
|
|
||||||
let dt = (currentTimestamp - previousTimestamp) / 1000;
|
|
||||||
if (!Number.isFinite(dt) || dt <= 0) {
|
|
||||||
dt = MIN_TIMESTEP;
|
|
||||||
}
|
|
||||||
dt = clamp(dt, MIN_TIMESTEP, MAX_TIMESTEP);
|
|
||||||
|
|
||||||
state.lastClientX = event.clientX;
|
engine?.updateDragSession(event.pointerId, event.clientX, event.clientY, currentTimestamp);
|
||||||
state.lastClientY = event.clientY;
|
|
||||||
state.lastTimestamp = currentTimestamp;
|
|
||||||
|
|
||||||
// Helper for updating angular velocity based on pointer movement
|
|
||||||
const updatePointerAngularVelocity = (
|
|
||||||
pX: number,
|
|
||||||
pY: number,
|
|
||||||
cX: number,
|
|
||||||
cY: number,
|
|
||||||
dtSec: number,
|
|
||||||
targetState: DragStateInternal | DragGroupItemInternal = state
|
|
||||||
) => {
|
|
||||||
if (!Number.isFinite(dtSec) || dtSec <= 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const leverX = pX - cX;
|
|
||||||
const leverY = pY - cY;
|
|
||||||
if (!Number.isFinite(leverX) || !Number.isFinite(leverY)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use shared state for previous pointer position to calculate velocity
|
|
||||||
// Note: For group items, we use the same pointer velocity
|
|
||||||
const prevCanvasX = Number.isFinite(state.lastPointerCanvasX)
|
|
||||||
? state.lastPointerCanvasX
|
|
||||||
: pX;
|
|
||||||
const prevCanvasY = Number.isFinite(state.lastPointerCanvasY)
|
|
||||||
? state.lastPointerCanvasY
|
|
||||||
: pY;
|
|
||||||
|
|
||||||
const velocityCanvasX = (pX - prevCanvasX) / dtSec;
|
|
||||||
const velocityCanvasY = (pY - prevCanvasY) / dtSec;
|
|
||||||
|
|
||||||
if (!Number.isFinite(velocityCanvasX) || !Number.isFinite(velocityCanvasY)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const torque = leverX * velocityCanvasY - leverY * velocityCanvasX;
|
|
||||||
const influenceRadius = Math.max(targetState.width, targetState.height) / 2 || 1;
|
|
||||||
const radiusScale = clamp(Math.hypot(leverX, leverY) / influenceRadius, 0.2, 2.5);
|
|
||||||
|
|
||||||
// If it's the main state, update pointerRadiusScale
|
|
||||||
if (targetState === state) {
|
|
||||||
state.pointerRadiusScale = radiusScale;
|
|
||||||
}
|
|
||||||
|
|
||||||
const torqueResponse = 0.0025 * radiusScale;
|
|
||||||
const angularVelocityDeg = clamp(
|
|
||||||
torque * torqueResponse,
|
|
||||||
-MAX_ANGULAR_VELOCITY,
|
|
||||||
MAX_ANGULAR_VELOCITY,
|
|
||||||
);
|
|
||||||
|
|
||||||
const mass = Math.max(targetState.massGrams || CARD_BASE_WEIGHT_GRAMS, CARD_BASE_WEIGHT_GRAMS);
|
|
||||||
const massScale = Math.max(mass / CARD_BASE_WEIGHT_GRAMS, 1);
|
|
||||||
targetState.angularVelocity = angularVelocityDeg / massScale;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper for applying dynamic rotation
|
|
||||||
const applyDynamicRotation = (
|
|
||||||
dtSec: number,
|
|
||||||
targetState: DragStateInternal | DragGroupItemInternal = state,
|
|
||||||
dampingFactor = 0.94
|
|
||||||
) => {
|
|
||||||
if (!Number.isFinite(dtSec) || dtSec <= 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Use state.pointerRadiusScale as a proxy for influence if not available on item?
|
|
||||||
// Actually, let's just use 1 if not available, or recalculate.
|
|
||||||
// For simplicity, we'll use the one calculated in updatePointerAngularVelocity if available,
|
|
||||||
// or default.
|
|
||||||
const radiusInfluence = clamp(state.pointerRadiusScale || 1, 0.3, 3);
|
|
||||||
const response = 1.1 * radiusInfluence;
|
|
||||||
|
|
||||||
let nextDynamic = (targetState.dynamicRotation || 0) + (targetState.angularVelocity || 0) * dtSec * response;
|
|
||||||
nextDynamic = clamp(nextDynamic, -MAX_DYNAMIC_ROTATION, MAX_DYNAMIC_ROTATION);
|
|
||||||
const adjustedDamping = Math.pow(dampingFactor, 1 / Math.max(radiusInfluence, 0.8));
|
|
||||||
targetState.dynamicRotation = nextDynamic * adjustedDamping;
|
|
||||||
|
|
||||||
if (targetState === state) {
|
|
||||||
state.rotation = state.restRotation + state.dynamicRotation;
|
|
||||||
} else {
|
|
||||||
// For group items
|
|
||||||
const item = targetState as DragGroupItemInternal;
|
|
||||||
item.displayRotation = (item.initialRotation || 0) + item.dynamicRotation;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (state.isGroup) {
|
|
||||||
const containerRect = containerRef.current?.getBoundingClientRect?.();
|
|
||||||
if (containerRect) {
|
|
||||||
state.containerRectLeft = containerRect.left;
|
|
||||||
state.containerRectTop = containerRect.top;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pointerCanvasX = event.clientX - state.containerRectLeft;
|
|
||||||
const pointerCanvasY = event.clientY - state.containerRectTop;
|
|
||||||
const deltaX = event.clientX - state.startX;
|
|
||||||
const deltaY = event.clientY - state.startY;
|
|
||||||
|
|
||||||
if (!state.moved) {
|
|
||||||
const distanceSquared = deltaX * deltaX + deltaY * deltaY;
|
|
||||||
if (distanceSquared < DRAG_HYSTERESIS_SQUARED) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
state.moved = true;
|
|
||||||
if (!state.stackSelectionApplied) {
|
|
||||||
state.stackSelectionApplied = true;
|
|
||||||
}
|
|
||||||
if (!state.groupElevated) {
|
|
||||||
// Ensure Z-order is preserved during drag
|
|
||||||
state.activeDocIds.forEach((id) => bringToFront(id));
|
|
||||||
state.groupElevated = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const canvasWidth = canvasSize.width || defaultCanvasWidth;
|
|
||||||
const canvasHeight = canvasSize.height || defaultCanvasHeight;
|
|
||||||
|
|
||||||
// Independent Physics for each item
|
|
||||||
state.groupItems.forEach((item) => {
|
|
||||||
// 1. Calculate Physics (Torque & Rotation)
|
|
||||||
updatePointerAngularVelocity(
|
|
||||||
pointerCanvasX,
|
|
||||||
pointerCanvasY,
|
|
||||||
item.currentCenterX,
|
|
||||||
item.currentCenterY,
|
|
||||||
dt,
|
|
||||||
item
|
|
||||||
);
|
|
||||||
|
|
||||||
applyDynamicRotation(dt, item, 0.96);
|
|
||||||
|
|
||||||
// 2. Calculate Target Position
|
|
||||||
// Gravitation: Decay base offsets towards 0 (anchor center)
|
|
||||||
// This makes the stack collapse towards the anchor as it moves
|
|
||||||
const gravitationDecay = 0.92;
|
|
||||||
item.baseOffsetX = (item.baseOffsetX || 0) * gravitationDecay;
|
|
||||||
item.baseOffsetY = (item.baseOffsetY || 0) * gravitationDecay;
|
|
||||||
|
|
||||||
// Stop decaying if very small to avoid endless micro-updates
|
|
||||||
if (Math.abs(item.baseOffsetX) < 0.5) item.baseOffsetX = 0;
|
|
||||||
if (Math.abs(item.baseOffsetY) < 0.5) item.baseOffsetY = 0;
|
|
||||||
|
|
||||||
const targetX = pointerCanvasX - state.localPointerOffsetX + (item.baseOffsetX || 0);
|
|
||||||
const targetY = pointerCanvasY - state.localPointerOffsetY + (item.baseOffsetY || 0);
|
|
||||||
|
|
||||||
// 3. Apply Smoothing / Damping
|
|
||||||
const smoothing = 0.18; // Base smoothing
|
|
||||||
const stackFriction = 0.85; // Additional damping for stack feel
|
|
||||||
const effectiveSmoothing = smoothing * stackFriction;
|
|
||||||
|
|
||||||
item.currentCenterX += (targetX - item.currentCenterX) * effectiveSmoothing;
|
|
||||||
item.currentCenterY += (targetY - item.currentCenterY) * effectiveSmoothing;
|
|
||||||
|
|
||||||
// Clamp to canvas
|
|
||||||
const halfW = item.width / 2;
|
|
||||||
const halfH = item.height / 2;
|
|
||||||
const minX = canvasPadding + halfW;
|
|
||||||
const maxX = Math.max(minX, canvasWidth - canvasPadding - halfW);
|
|
||||||
const minY = canvasPadding + halfH;
|
|
||||||
const maxY = Math.max(minY, canvasHeight - canvasPadding - halfH);
|
|
||||||
item.currentCenterX = clamp(item.currentCenterX, minX, maxX);
|
|
||||||
item.currentCenterY = clamp(item.currentCenterY, minY, maxY);
|
|
||||||
|
|
||||||
// Apply transform
|
|
||||||
const entry = layoutRef.current.get(item.docId) || null;
|
|
||||||
const payload = {
|
|
||||||
centerX: item.currentCenterX,
|
|
||||||
centerY: item.currentCenterY,
|
|
||||||
rotation: item.displayRotation ?? 0,
|
|
||||||
width: item.width,
|
|
||||||
height: item.height,
|
|
||||||
scale: item.docId === state.docKey ? state.dragScale || 1 : 1,
|
|
||||||
zIndex: entry?.z,
|
|
||||||
};
|
|
||||||
|
|
||||||
setDragTransform(item.docId, payload);
|
|
||||||
const node = itemRefs.current.get(item.docId);
|
|
||||||
applyDomTransform(node, payload);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update shared state for next frame velocity calculation
|
|
||||||
state.lastPointerCanvasX = pointerCanvasX;
|
|
||||||
state.lastPointerCanvasY = pointerCanvasY;
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Single Item Drag Logic ---
|
|
||||||
|
|
||||||
if (state.locked) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const deltaX = event.clientX - state.startX;
|
|
||||||
const deltaY = event.clientY - state.startY;
|
|
||||||
|
|
||||||
const containerRect = containerRef.current?.getBoundingClientRect?.();
|
|
||||||
if (containerRect) {
|
|
||||||
state.containerRectLeft = containerRect.left;
|
|
||||||
state.containerRectTop = containerRect.top;
|
|
||||||
}
|
|
||||||
|
|
||||||
const containerLeft = state.containerRectLeft;
|
|
||||||
const containerTop = state.containerRectTop;
|
|
||||||
const pointerCanvasX = event.clientX - containerLeft;
|
|
||||||
const pointerCanvasY = event.clientY - containerTop;
|
|
||||||
|
|
||||||
const previousCenterX = Number.isFinite(state.currentCenterX)
|
|
||||||
? state.currentCenterX
|
|
||||||
: state.originCenterX;
|
|
||||||
const previousCenterY = Number.isFinite(state.currentCenterY)
|
|
||||||
? state.currentCenterY
|
|
||||||
: state.originCenterY;
|
|
||||||
|
|
||||||
const torqueCenterX = Number.isFinite(previousCenterX) ? previousCenterX : state.originCenterX;
|
|
||||||
const torqueCenterY = Number.isFinite(previousCenterY) ? previousCenterY : state.originCenterY;
|
|
||||||
|
|
||||||
updatePointerAngularVelocity(pointerCanvasX, pointerCanvasY, torqueCenterX, torqueCenterY, dt, state);
|
|
||||||
applyDynamicRotation(dt, state, 0.96);
|
|
||||||
|
|
||||||
state.lastPointerCanvasX = pointerCanvasX;
|
|
||||||
state.lastPointerCanvasY = pointerCanvasY;
|
|
||||||
|
|
||||||
// Position Calculation
|
|
||||||
const docWidth = state.width;
|
|
||||||
const docHeight = state.height;
|
|
||||||
const halfWidth = docWidth / 2;
|
|
||||||
const halfHeight = docHeight / 2;
|
|
||||||
const canvasWidth = canvasSize.width || defaultCanvasWidth;
|
|
||||||
const canvasHeight = canvasSize.height || defaultCanvasHeight;
|
|
||||||
|
|
||||||
|
|
||||||
const rotationDeg = state.rotation ?? 0;
|
|
||||||
const rotationRad = (rotationDeg * Math.PI) / 180;
|
|
||||||
const cosRot = Math.cos(rotationRad);
|
|
||||||
const sinRot = Math.sin(rotationRad);
|
|
||||||
const rotatedOffsetX =
|
|
||||||
state.localPointerOffsetX * cosRot - state.localPointerOffsetY * sinRot;
|
|
||||||
const rotatedOffsetY =
|
|
||||||
state.localPointerOffsetX * sinRot + state.localPointerOffsetY * cosRot;
|
|
||||||
|
|
||||||
const absCos = Math.abs(cosRot);
|
|
||||||
const absSin = Math.abs(sinRot);
|
|
||||||
const rotatedHalfWidth = absCos * halfWidth + absSin * halfHeight;
|
|
||||||
const rotatedHalfHeight = absSin * halfWidth + absCos * halfHeight;
|
|
||||||
|
|
||||||
const minCenterXRotated = canvasPadding + rotatedHalfWidth;
|
|
||||||
const maxCenterXRotated = Math.max(minCenterXRotated, canvasWidth - canvasPadding - rotatedHalfWidth);
|
|
||||||
const minCenterYRotated = canvasPadding + rotatedHalfHeight;
|
|
||||||
const maxCenterYRotated = Math.max(minCenterYRotated, canvasHeight - canvasPadding - rotatedHalfHeight);
|
|
||||||
|
|
||||||
const desiredCenterX = pointerCanvasX - rotatedOffsetX;
|
|
||||||
const desiredCenterY = pointerCanvasY - rotatedOffsetY;
|
|
||||||
const clampedCenterX = clamp(desiredCenterX, minCenterXRotated, maxCenterXRotated);
|
|
||||||
const clampedCenterY = clamp(desiredCenterY, minCenterYRotated, maxCenterYRotated);
|
|
||||||
|
|
||||||
if (!state.moved) {
|
|
||||||
const distanceSquared = deltaX * deltaX + deltaY * deltaY;
|
|
||||||
if (distanceSquared < DRAG_HYSTERESIS_SQUARED) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
bringToFront(state.docKey);
|
|
||||||
state.moved = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Edge collision logic
|
|
||||||
const collidedWithHorizontalEdge =
|
|
||||||
Math.abs(desiredCenterY - clampedCenterY) > EDGE_COLLISION_THRESHOLD;
|
|
||||||
const collidedWithVerticalEdge =
|
|
||||||
Math.abs(desiredCenterX - clampedCenterX) > EDGE_COLLISION_THRESHOLD;
|
|
||||||
const collidedWithEdge = collidedWithHorizontalEdge || collidedWithVerticalEdge;
|
|
||||||
|
|
||||||
// Check if pointer is inside the card relative to previous position
|
|
||||||
const pointerRelativePrevX = pointerCanvasX - previousCenterX;
|
|
||||||
const pointerRelativePrevY = pointerCanvasY - previousCenterY;
|
|
||||||
const cosInversePrev = Math.cos(-rotationRad);
|
|
||||||
const sinInversePrev = Math.sin(-rotationRad);
|
|
||||||
const pointerLocalPrevX = pointerRelativePrevX * cosInversePrev - pointerRelativePrevY * sinInversePrev;
|
|
||||||
const pointerLocalPrevY = pointerRelativePrevX * sinInversePrev + pointerRelativePrevY * cosInversePrev;
|
|
||||||
const pointerInsideRelativeToPrev =
|
|
||||||
Math.abs(pointerLocalPrevX) <= halfWidth && Math.abs(pointerLocalPrevY) <= halfHeight;
|
|
||||||
|
|
||||||
let currentCenterX = clampedCenterX;
|
|
||||||
let currentCenterY = clampedCenterY;
|
|
||||||
if (collidedWithEdge && !pointerInsideRelativeToPrev) {
|
|
||||||
currentCenterX = previousCenterX;
|
|
||||||
currentCenterY = previousCenterY;
|
|
||||||
}
|
|
||||||
|
|
||||||
state.currentCenterX = currentCenterX;
|
|
||||||
state.currentCenterY = currentCenterY;
|
|
||||||
|
|
||||||
const layoutEntry = layoutRef.current.get(state.docKey) || null;
|
|
||||||
const transformPayload = {
|
|
||||||
centerX: currentCenterX,
|
|
||||||
centerY: currentCenterY,
|
|
||||||
rotation: rotationDeg,
|
|
||||||
width: state.width,
|
|
||||||
height: state.height,
|
|
||||||
scale: state.dragScale || 1,
|
|
||||||
zIndex: layoutEntry?.z,
|
|
||||||
};
|
|
||||||
|
|
||||||
setDragTransform(state.docKey, transformPayload);
|
|
||||||
const primaryNode = itemRefs.current.get(state.docKey);
|
|
||||||
applyDomTransform(primaryNode, transformPayload);
|
|
||||||
|
|
||||||
// Update local pointer offset if we didn't collide or pointer is inside
|
|
||||||
const offsetX = pointerCanvasX - currentCenterX;
|
|
||||||
const offsetY = pointerCanvasY - currentCenterY;
|
|
||||||
const cosInverseCurrent = Math.cos(-rotationRad);
|
|
||||||
const sinInverseCurrent = Math.sin(-rotationRad);
|
|
||||||
const pointerLocalX = offsetX * cosInverseCurrent - offsetY * sinInverseCurrent;
|
|
||||||
const pointerLocalY = offsetX * sinInverseCurrent + offsetY * cosInverseCurrent;
|
|
||||||
const pointerInsideCard =
|
|
||||||
Math.abs(pointerLocalX) <= halfWidth && Math.abs(pointerLocalY) <= halfHeight;
|
|
||||||
|
|
||||||
if (!collidedWithEdge || pointerInsideCard) {
|
|
||||||
// Recalculate local offset based on current rotation
|
|
||||||
const rotationForOffsetDeg = state.rotation || 0;
|
|
||||||
const rotationForOffsetRad = (rotationForOffsetDeg * Math.PI) / 180;
|
|
||||||
const cosInverse = Math.cos(-rotationForOffsetRad);
|
|
||||||
const sinInverse = Math.sin(-rotationForOffsetRad);
|
|
||||||
const pointerRelativeX = pointerCanvasX - currentCenterX;
|
|
||||||
const pointerRelativeY = pointerCanvasY - currentCenterY;
|
|
||||||
const updatedLocalOffsetX = pointerRelativeX * cosInverse - pointerRelativeY * sinInverse;
|
|
||||||
const updatedLocalOffsetY = pointerRelativeX * sinInverse + pointerRelativeY * cosInverse;
|
|
||||||
|
|
||||||
state.localPointerOffsetX = updatedLocalOffsetX;
|
|
||||||
state.localPointerOffsetY = updatedLocalOffsetY;
|
|
||||||
}
|
|
||||||
|
|
||||||
void debugDrag;
|
|
||||||
},
|
},
|
||||||
[
|
[engine, startDragSession],
|
||||||
bringToFront,
|
|
||||||
canvasPadding,
|
|
||||||
canvasSize.height,
|
|
||||||
canvasSize.width,
|
|
||||||
defaultCanvasHeight,
|
|
||||||
defaultCanvasWidth,
|
|
||||||
containerRef,
|
|
||||||
layoutRef,
|
|
||||||
itemRefs,
|
|
||||||
debugDrag,
|
|
||||||
setDragTransform,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
const handlePointerUp = useCallback(
|
const handlePointerUp = useCallback(
|
||||||
(event: PointerEventLike) => {
|
(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;
|
const state = dragStateRef.current;
|
||||||
if (!state || state.pointerId !== event.pointerId) {
|
if (!state || state.pointerId !== event.pointerId) {
|
||||||
finishDrag(event.pointerId);
|
finishDrag(event.pointerId);
|
||||||
@@ -976,7 +598,7 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (state.isGroup) {
|
if (state.isGroup) {
|
||||||
engine?.finalizeGroupDrag?.(state);
|
engine?.finalizeGroupDrag?.();
|
||||||
commitActiveDragTransforms(state.activeDocIds);
|
commitActiveDragTransforms(state.activeDocIds);
|
||||||
finishDrag(event.pointerId);
|
finishDrag(event.pointerId);
|
||||||
recalcVisibleDocIds();
|
recalcVisibleDocIds();
|
||||||
@@ -986,7 +608,7 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
|
|||||||
if (state.moved) {
|
if (state.moved) {
|
||||||
commitActiveDragTransforms([state.docKey]);
|
commitActiveDragTransforms([state.docKey]);
|
||||||
const finalRotation = state.rotation ?? state.restRotation;
|
const finalRotation = state.rotation ?? state.restRotation;
|
||||||
const inertiaState: EngineInertiaState = {
|
const inertiaState: InertiaSimulationState = {
|
||||||
docId: state.docKey,
|
docId: state.docKey,
|
||||||
restRotation: finalRotation,
|
restRotation: finalRotation,
|
||||||
dynamicRotation: 0,
|
dynamicRotation: 0,
|
||||||
@@ -1004,24 +626,6 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const docId = state.docKey;
|
|
||||||
const metaPressed = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
|
|
||||||
if (!metaPressed) {
|
|
||||||
bringToFront(docId);
|
|
||||||
}
|
|
||||||
const originInfo = {
|
|
||||||
rotation: state.rotation || 0,
|
|
||||||
scale: state.baseScale || 1,
|
|
||||||
width: state.width,
|
|
||||||
height: state.height,
|
|
||||||
};
|
|
||||||
const docKey = docId != null ? String(docId) : null;
|
|
||||||
const doc = docKey ? documentLookup.get(docKey) : null;
|
|
||||||
tapHandler(event, {
|
|
||||||
docId,
|
|
||||||
originInfo,
|
|
||||||
docTitle: doc?.title || 'document',
|
|
||||||
});
|
|
||||||
finishDrag(event.pointerId);
|
finishDrag(event.pointerId);
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
@@ -1032,15 +636,23 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
|
|||||||
finishDrag,
|
finishDrag,
|
||||||
recalcVisibleDocIds,
|
recalcVisibleDocIds,
|
||||||
tapHandler,
|
tapHandler,
|
||||||
|
ensureDocumentSize,
|
||||||
|
layoutRef
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePointerCancel = useCallback(
|
const handlePointerCancel = useCallback(
|
||||||
(event: PointerEventLike) => {
|
(event: PointerEventLike) => {
|
||||||
|
if (pendingDragRef.current && pendingDragRef.current.pointerId === event.pointerId) {
|
||||||
|
pendingDragRef.current = null;
|
||||||
|
finishDrag(event.pointerId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const state = dragStateRef.current;
|
const state = dragStateRef.current;
|
||||||
if (state && state.pointerId === event.pointerId && state.moved) {
|
if (state && state.pointerId === event.pointerId && state.moved) {
|
||||||
if (state.isGroup) {
|
if (state.isGroup) {
|
||||||
engine?.finalizeGroupDrag?.(state);
|
engine?.finalizeGroupDrag?.();
|
||||||
commitActiveDragTransforms(state.activeDocIds);
|
commitActiveDragTransforms(state.activeDocIds);
|
||||||
finishDrag(event.pointerId);
|
finishDrag(event.pointerId);
|
||||||
recalcVisibleDocIds();
|
recalcVisibleDocIds();
|
||||||
@@ -1049,7 +661,7 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
|
|||||||
|
|
||||||
commitActiveDragTransforms([state.docKey]);
|
commitActiveDragTransforms([state.docKey]);
|
||||||
const finalRotation = state.rotation ?? state.restRotation;
|
const finalRotation = state.rotation ?? state.restRotation;
|
||||||
const inertiaState: EngineInertiaState = {
|
const inertiaState: InertiaSimulationState = {
|
||||||
docId: state.docKey,
|
docId: state.docKey,
|
||||||
restRotation: finalRotation,
|
restRotation: finalRotation,
|
||||||
dynamicRotation: 0,
|
dynamicRotation: 0,
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
export interface Point {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Polygon = Point[];
|
||||||
|
|
||||||
|
export const signedDistanceToEdge = (edgeStart: Point, edgeEnd: Point, point: Point): number =>
|
||||||
|
(edgeEnd.x - edgeStart.x) * (point.y - edgeStart.y)
|
||||||
|
- (edgeEnd.y - edgeStart.y) * (point.x - edgeStart.x);
|
||||||
|
|
||||||
|
export const iterateEdges = (
|
||||||
|
polygon: Polygon,
|
||||||
|
callback: (current: Point, next: Point, index: number) => boolean | void,
|
||||||
|
): void => {
|
||||||
|
if (!Array.isArray(polygon) || polygon.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (let index = 0; index < polygon.length; index += 1) {
|
||||||
|
const current = polygon[index];
|
||||||
|
const next = polygon[(index + 1) % polygon.length];
|
||||||
|
if (callback(current, next, index) === false) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const forEachVertex = (
|
||||||
|
polygon: Polygon,
|
||||||
|
callback: (current: Point, previous: Point, index: number) => boolean | void,
|
||||||
|
): void => {
|
||||||
|
if (!Array.isArray(polygon) || polygon.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (let index = 0; index < polygon.length; index += 1) {
|
||||||
|
const current = polygon[index];
|
||||||
|
const prev = polygon[(index - 1 + polygon.length) % polygon.length];
|
||||||
|
if (callback(current, prev, index) === false) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const lineIntersection = (p1: Point, p2: Point, cp1: Point, cp2: Point): Point => {
|
||||||
|
const A1 = p2.y - p1.y;
|
||||||
|
const B1 = p1.x - p2.x;
|
||||||
|
const C1 = A1 * p1.x + B1 * p1.y;
|
||||||
|
|
||||||
|
const A2 = cp2.y - cp1.y;
|
||||||
|
const B2 = cp1.x - cp2.x;
|
||||||
|
const C2 = A2 * cp1.x + B2 * cp1.y;
|
||||||
|
|
||||||
|
const det = A1 * B2 - A2 * B1;
|
||||||
|
if (Math.abs(det) < 1e-6) {
|
||||||
|
return { x: cp1.x, y: cp1.y };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
x: (B2 * C1 - B1 * C2) / det,
|
||||||
|
y: (A1 * C2 - A2 * C1) / det,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clipPolygon = (subject: Polygon, clipper: Polygon): Polygon => {
|
||||||
|
if (!Array.isArray(subject) || !subject.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
let output = subject;
|
||||||
|
iterateEdges(clipper, (cp1, cp2) => {
|
||||||
|
const input = output;
|
||||||
|
output = [];
|
||||||
|
if (!Array.isArray(input) || !input.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
forEachVertex(input, (current, prev) => {
|
||||||
|
const currentInside = signedDistanceToEdge(cp1, cp2, current) >= 0;
|
||||||
|
const prevInside = signedDistanceToEdge(cp1, cp2, prev) >= 0;
|
||||||
|
if (currentInside) {
|
||||||
|
if (!prevInside) {
|
||||||
|
output.push(lineIntersection(prev, current, cp1, cp2));
|
||||||
|
}
|
||||||
|
output.push(current);
|
||||||
|
} else if (prevInside) {
|
||||||
|
output.push(lineIntersection(prev, current, cp1, cp2));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return output.length > 0;
|
||||||
|
});
|
||||||
|
return output;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isPointInsideConvex = (point: Point, polygon: Polygon): boolean => {
|
||||||
|
if (!polygon?.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let sign = 0;
|
||||||
|
let inside = true;
|
||||||
|
iterateEdges(polygon, (a, b) => {
|
||||||
|
const cross = signedDistanceToEdge(a, b, point);
|
||||||
|
if (cross === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const currentSign = cross > 0 ? 1 : -1;
|
||||||
|
if (sign === 0) {
|
||||||
|
sign = currentSign;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (sign !== currentSign) {
|
||||||
|
inside = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return inside;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const polygonCentroid = (polygon: Polygon): Point => {
|
||||||
|
if (!polygon?.length) {
|
||||||
|
return { x: 0, y: 0 };
|
||||||
|
}
|
||||||
|
let area = 0;
|
||||||
|
let cx = 0;
|
||||||
|
let cy = 0;
|
||||||
|
iterateEdges(polygon, (current, next) => {
|
||||||
|
const cross = current.x * next.y - next.x * current.y;
|
||||||
|
area += cross;
|
||||||
|
cx += (current.x + next.x) * cross;
|
||||||
|
cy += (current.y + next.y) * cross;
|
||||||
|
});
|
||||||
|
if (Math.abs(area) < 1e-6) {
|
||||||
|
let sumX = 0;
|
||||||
|
let sumY = 0;
|
||||||
|
forEachVertex(polygon, (point) => {
|
||||||
|
sumX += point.x;
|
||||||
|
sumY += point.y;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
x: sumX / polygon.length,
|
||||||
|
y: sumY / polygon.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const areaFactor = 1 / (3 * area);
|
||||||
|
return {
|
||||||
|
x: cx * areaFactor,
|
||||||
|
y: cy * areaFactor,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
export interface CardBounds {
|
||||||
|
minX: number;
|
||||||
|
maxX: number;
|
||||||
|
minY: number;
|
||||||
|
maxY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComputeBoundsOptions {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
canvasWidth: number;
|
||||||
|
canvasHeight: number;
|
||||||
|
padding: number;
|
||||||
|
shelfWidth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const computeCardBounds = ({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
canvasWidth,
|
||||||
|
canvasHeight,
|
||||||
|
padding,
|
||||||
|
shelfWidth = 0,
|
||||||
|
}: ComputeBoundsOptions): CardBounds => {
|
||||||
|
const halfW = width / 2;
|
||||||
|
const halfH = height / 2;
|
||||||
|
const shelfOffset = Math.max(shelfWidth, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
minX: padding + halfW,
|
||||||
|
maxX: Math.max(padding + halfW, canvasWidth - shelfOffset - padding - halfW),
|
||||||
|
minY: padding + halfH,
|
||||||
|
maxY: Math.max(padding + halfH, canvasHeight - padding - halfH),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,4 +1,14 @@
|
|||||||
import { clamp, formatTransform } from '../utils/math';
|
import { clamp, formatTransform, toNumber } from '../utils/math';
|
||||||
|
import {
|
||||||
|
Point,
|
||||||
|
Polygon,
|
||||||
|
clipPolygon,
|
||||||
|
isPointInsideConvex,
|
||||||
|
polygonCentroid,
|
||||||
|
iterateEdges,
|
||||||
|
forEachVertex,
|
||||||
|
} from './utils/geometry';
|
||||||
|
import { computeCardBounds } from './utils/layoutUtils';
|
||||||
import { fetchLayoutRecords, upsertLayoutRecords } from './db';
|
import { fetchLayoutRecords, upsertLayoutRecords } from './db';
|
||||||
import {
|
import {
|
||||||
ANGULAR_DAMPING,
|
ANGULAR_DAMPING,
|
||||||
@@ -39,12 +49,7 @@ export {
|
|||||||
TORQUE_TO_ACCELERATION,
|
TORQUE_TO_ACCELERATION,
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Point {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Polygon = Point[];
|
|
||||||
|
|
||||||
interface TransformOptions {
|
interface TransformOptions {
|
||||||
centerX?: number;
|
centerX?: number;
|
||||||
@@ -98,24 +103,7 @@ interface BaseMetrics {
|
|||||||
baseScale: number;
|
baseScale: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DragGroupItem {
|
export interface InertiaSimulationState {
|
||||||
docId?: string | null;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
currentCenterX?: number;
|
|
||||||
currentCenterY?: number;
|
|
||||||
displayRotation?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DragState {
|
|
||||||
docKey?: string | null;
|
|
||||||
dragScale?: number;
|
|
||||||
originCenterX?: number;
|
|
||||||
originCenterY?: number;
|
|
||||||
groupItems?: DragGroupItem[] | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface InertiaSimulationState {
|
|
||||||
docId: DocumentId;
|
docId: DocumentId;
|
||||||
restRotation: number;
|
restRotation: number;
|
||||||
rotation: number;
|
rotation: number;
|
||||||
@@ -129,6 +117,62 @@ interface InertiaSimulationState {
|
|||||||
massGrams?: number;
|
massGrams?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DragGroupItem {
|
||||||
|
docId: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
currentCenterX: number;
|
||||||
|
currentCenterY: number;
|
||||||
|
baseOffsetX: number;
|
||||||
|
baseOffsetY: number;
|
||||||
|
initialRotation: number;
|
||||||
|
targetRotation: number;
|
||||||
|
displayRotation: number;
|
||||||
|
angularVelocity: number;
|
||||||
|
dynamicRotation: number;
|
||||||
|
massGrams: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveDragSession {
|
||||||
|
pointerId: number;
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
lastClientX: number;
|
||||||
|
lastClientY: number;
|
||||||
|
docKey: string;
|
||||||
|
isGroup: boolean;
|
||||||
|
activeDocIds: string[];
|
||||||
|
originCenterX: number;
|
||||||
|
originCenterY: number;
|
||||||
|
currentCenterX: number;
|
||||||
|
currentCenterY: number;
|
||||||
|
rotation: number;
|
||||||
|
restRotation: number;
|
||||||
|
dynamicRotation: number;
|
||||||
|
angularVelocity: number;
|
||||||
|
moved: boolean;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
dragScale: number;
|
||||||
|
baseScale: number;
|
||||||
|
lastTimestamp: number;
|
||||||
|
localPointerOffsetX: number;
|
||||||
|
localPointerOffsetY: number;
|
||||||
|
containerRectLeft: number;
|
||||||
|
containerRectTop: number;
|
||||||
|
groupItems: DragGroupItem[];
|
||||||
|
groupElevated: boolean;
|
||||||
|
stackSelectionApplied: boolean;
|
||||||
|
massGrams: number;
|
||||||
|
pointerRadiusScale: number;
|
||||||
|
lastPointerCanvasX: number;
|
||||||
|
lastPointerCanvasY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InteractionState =
|
||||||
|
| { type: 'idle' }
|
||||||
|
| { type: 'dragging'; session: ActiveDragSession };
|
||||||
|
|
||||||
interface WorkspaceSnapshot {
|
interface WorkspaceSnapshot {
|
||||||
layout: Map<DocumentId, LayoutEntry>;
|
layout: Map<DocumentId, LayoutEntry>;
|
||||||
canvasSize: { width: number; height: number };
|
canvasSize: { width: number; height: number };
|
||||||
@@ -281,146 +325,7 @@ function buildKey(docId: DocumentId, suffix: string): string {
|
|||||||
return `${docId}::${suffix}`;
|
return `${docId}::${suffix}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const signedDistanceToEdge = (edgeStart: Point, edgeEnd: Point, point: Point): number =>
|
|
||||||
(edgeEnd.x - edgeStart.x) * (point.y - edgeStart.y)
|
|
||||||
- (edgeEnd.y - edgeStart.y) * (point.x - edgeStart.x);
|
|
||||||
|
|
||||||
const iterateEdges = (
|
|
||||||
polygon: Polygon,
|
|
||||||
callback: (current: Point, next: Point, index: number) => boolean | void,
|
|
||||||
): void => {
|
|
||||||
if (!Array.isArray(polygon) || polygon.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (let index = 0; index < polygon.length; index += 1) {
|
|
||||||
const current = polygon[index];
|
|
||||||
const next = polygon[(index + 1) % polygon.length];
|
|
||||||
if (callback(current, next, index) === false) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const forEachVertex = (
|
|
||||||
polygon: Polygon,
|
|
||||||
callback: (current: Point, previous: Point, index: number) => boolean | void,
|
|
||||||
): void => {
|
|
||||||
if (!Array.isArray(polygon) || polygon.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (let index = 0; index < polygon.length; index += 1) {
|
|
||||||
const current = polygon[index];
|
|
||||||
const prev = polygon[(index - 1 + polygon.length) % polygon.length];
|
|
||||||
if (callback(current, prev, index) === false) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const lineIntersection = (p1: Point, p2: Point, cp1: Point, cp2: Point): Point => {
|
|
||||||
const A1 = p2.y - p1.y;
|
|
||||||
const B1 = p1.x - p2.x;
|
|
||||||
const C1 = A1 * p1.x + B1 * p1.y;
|
|
||||||
|
|
||||||
const A2 = cp2.y - cp1.y;
|
|
||||||
const B2 = cp1.x - cp2.x;
|
|
||||||
const C2 = A2 * cp1.x + B2 * cp1.y;
|
|
||||||
|
|
||||||
const det = A1 * B2 - A2 * B1;
|
|
||||||
if (Math.abs(det) < 1e-6) {
|
|
||||||
return { x: cp1.x, y: cp1.y };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
x: (B2 * C1 - B1 * C2) / det,
|
|
||||||
y: (A1 * C2 - A2 * C1) / det,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const clipPolygon = (subject: Polygon, clipper: Polygon): Polygon => {
|
|
||||||
if (!Array.isArray(subject) || !subject.length) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
let output = subject;
|
|
||||||
iterateEdges(clipper, (cp1, cp2) => {
|
|
||||||
const input = output;
|
|
||||||
output = [];
|
|
||||||
if (!Array.isArray(input) || !input.length) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
forEachVertex(input, (current, prev) => {
|
|
||||||
const currentInside = signedDistanceToEdge(cp1, cp2, current) >= 0;
|
|
||||||
const prevInside = signedDistanceToEdge(cp1, cp2, prev) >= 0;
|
|
||||||
if (currentInside) {
|
|
||||||
if (!prevInside) {
|
|
||||||
output.push(lineIntersection(prev, current, cp1, cp2));
|
|
||||||
}
|
|
||||||
output.push(current);
|
|
||||||
} else if (prevInside) {
|
|
||||||
output.push(lineIntersection(prev, current, cp1, cp2));
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
return output.length > 0;
|
|
||||||
});
|
|
||||||
return output;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isPointInsideConvex = (point: Point, polygon: Polygon): boolean => {
|
|
||||||
if (!polygon?.length) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let sign = 0;
|
|
||||||
let inside = true;
|
|
||||||
iterateEdges(polygon, (a, b) => {
|
|
||||||
const cross = signedDistanceToEdge(a, b, point);
|
|
||||||
if (cross === 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const currentSign = cross > 0 ? 1 : -1;
|
|
||||||
if (sign === 0) {
|
|
||||||
sign = currentSign;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (sign !== currentSign) {
|
|
||||||
inside = false;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
return inside;
|
|
||||||
};
|
|
||||||
|
|
||||||
const polygonCentroid = (polygon: Polygon): Point => {
|
|
||||||
if (!polygon?.length) {
|
|
||||||
return { x: 0, y: 0 };
|
|
||||||
}
|
|
||||||
let area = 0;
|
|
||||||
let cx = 0;
|
|
||||||
let cy = 0;
|
|
||||||
iterateEdges(polygon, (current, next) => {
|
|
||||||
const cross = current.x * next.y - next.x * current.y;
|
|
||||||
area += cross;
|
|
||||||
cx += (current.x + next.x) * cross;
|
|
||||||
cy += (current.y + next.y) * cross;
|
|
||||||
});
|
|
||||||
if (Math.abs(area) < 1e-6) {
|
|
||||||
let sumX = 0;
|
|
||||||
let sumY = 0;
|
|
||||||
forEachVertex(polygon, (point) => {
|
|
||||||
sumX += point.x;
|
|
||||||
sumY += point.y;
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
x: sumX / polygon.length,
|
|
||||||
y: sumY / polygon.length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const areaFactor = 1 / (3 * area);
|
|
||||||
return {
|
|
||||||
x: cx * areaFactor,
|
|
||||||
y: cy * areaFactor,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
const generateInitialLayout = (
|
const generateInitialLayout = (
|
||||||
entries: LayoutGenerationEntry[],
|
entries: LayoutGenerationEntry[],
|
||||||
{
|
{
|
||||||
@@ -556,7 +461,7 @@ export class WorkspaceEngine {
|
|||||||
tagDropTargetId: string | null;
|
tagDropTargetId: string | null;
|
||||||
pendingTagDocId: string | null;
|
pendingTagDocId: string | null;
|
||||||
pendingRemovalTag: unknown;
|
pendingRemovalTag: unknown;
|
||||||
dragInProgress: boolean;
|
|
||||||
activeDragDocIds: Set<DocumentId>;
|
activeDragDocIds: Set<DocumentId>;
|
||||||
pendingSnapshotSync: boolean;
|
pendingSnapshotSync: boolean;
|
||||||
pendingPersistSync: boolean;
|
pendingPersistSync: boolean;
|
||||||
@@ -571,6 +476,8 @@ export class WorkspaceEngine {
|
|||||||
pendingPersistence: unknown;
|
pendingPersistence: unknown;
|
||||||
itemRefs: ItemRefs;
|
itemRefs: ItemRefs;
|
||||||
inertiaAnimations: Map<string, InertiaSimulationState>;
|
inertiaAnimations: Map<string, InertiaSimulationState>;
|
||||||
|
|
||||||
|
state: InteractionState;
|
||||||
initialLoadDone: boolean;
|
initialLoadDone: boolean;
|
||||||
|
|
||||||
constructor({
|
constructor({
|
||||||
@@ -593,7 +500,7 @@ export class WorkspaceEngine {
|
|||||||
this.tagDropTargetId = null;
|
this.tagDropTargetId = null;
|
||||||
this.pendingTagDocId = null;
|
this.pendingTagDocId = null;
|
||||||
this.pendingRemovalTag = null;
|
this.pendingRemovalTag = null;
|
||||||
this.dragInProgress = false;
|
|
||||||
this.activeDragDocIds = new Set();
|
this.activeDragDocIds = new Set();
|
||||||
this.pendingSnapshotSync = false;
|
this.pendingSnapshotSync = false;
|
||||||
this.pendingPersistSync = false;
|
this.pendingPersistSync = false;
|
||||||
@@ -613,6 +520,8 @@ export class WorkspaceEngine {
|
|||||||
this.pendingPersistence = null;
|
this.pendingPersistence = null;
|
||||||
this.itemRefs = { current: new Map() };
|
this.itemRefs = { current: new Map() };
|
||||||
this.inertiaAnimations = new Map();
|
this.inertiaAnimations = new Map();
|
||||||
|
|
||||||
|
this.state = { type: 'idle' };
|
||||||
this.initialLoadDone = false;
|
this.initialLoadDone = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -714,20 +623,264 @@ export class WorkspaceEngine {
|
|||||||
this.emit();
|
this.emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
beginDrag(docIds: Array<string | null> = []): void {
|
get dragInProgress(): boolean {
|
||||||
this.dragInProgress = true;
|
return this.state.type === 'dragging';
|
||||||
if (Array.isArray(docIds)) {
|
|
||||||
this.activeDragDocIds = new Set(docIds.map((id) => (id != null ? String(id) : null)).filter(Boolean));
|
|
||||||
} else {
|
|
||||||
this.activeDragDocIds.clear();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get activeDragSession(): ActiveDragSession | null {
|
||||||
|
return this.state.type === 'dragging' ? this.state.session : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
beginDrag(docIds: Array<string | null> = []): void {
|
||||||
|
// Legacy method support or internal helper
|
||||||
|
// If we are starting a drag, we should transition state
|
||||||
|
// But this method was used to set flags.
|
||||||
|
// We'll keep it for now but it might be redundant if startDragSession handles everything.
|
||||||
|
// Let's make it a no-op or just update activeDragDocIds if we were keeping them separate,
|
||||||
|
// but we are trying to move to state machine.
|
||||||
|
// If called externally, it might be an issue.
|
||||||
|
// Assuming startDragSession is the main entry point now.
|
||||||
}
|
}
|
||||||
|
|
||||||
endDrag(): void {
|
endDrag(): void {
|
||||||
this.dragInProgress = false;
|
if (this.state.type === 'dragging') {
|
||||||
this.activeDragDocIds.clear();
|
this.state = { type: 'idle' };
|
||||||
|
this.activeDragDocIds.clear(); // Keep this for now if used elsewhere
|
||||||
|
this.setDraggingId(null);
|
||||||
this.flushPendingLayoutOps();
|
this.flushPendingLayoutOps();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startDragSession(session: ActiveDragSession): void {
|
||||||
|
this.state = { type: 'dragging', session };
|
||||||
|
|
||||||
|
// Update legacy/derived state if needed
|
||||||
|
if (Array.isArray(session.activeDocIds)) {
|
||||||
|
this.activeDragDocIds = new Set(session.activeDocIds.map((id) => (id != null ? String(id) : null)).filter(Boolean));
|
||||||
|
} else {
|
||||||
|
this.activeDragDocIds.clear();
|
||||||
|
}
|
||||||
|
this.setDraggingId(session.docKey);
|
||||||
|
|
||||||
|
// Initial transform application
|
||||||
|
session.groupItems.forEach((item) => {
|
||||||
|
if (item.docId === session.docKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const node = this.itemRefs.current.get(item.docId);
|
||||||
|
if (node) {
|
||||||
|
const itemEntry = this.layout.get(item.docId) || null;
|
||||||
|
applyDomTransform(node, {
|
||||||
|
centerX: item.currentCenterX,
|
||||||
|
centerY: item.currentCenterY,
|
||||||
|
width: item.width,
|
||||||
|
height: item.height,
|
||||||
|
rotation: item.displayRotation ?? 0,
|
||||||
|
scale: 1,
|
||||||
|
zIndex: itemEntry?.z,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDragSession(
|
||||||
|
pointerId: number,
|
||||||
|
clientX: number,
|
||||||
|
clientY: number,
|
||||||
|
timestamp: number
|
||||||
|
): void {
|
||||||
|
if (this.state.type !== 'dragging') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const state = this.state.session;
|
||||||
|
if (state.pointerId !== pointerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousTimestamp = state.lastTimestamp ?? timestamp;
|
||||||
|
let dt = (timestamp - previousTimestamp) / 1000;
|
||||||
|
if (!Number.isFinite(dt) || dt <= 0) {
|
||||||
|
dt = MIN_TIMESTEP;
|
||||||
|
}
|
||||||
|
dt = clamp(dt, MIN_TIMESTEP, MAX_TIMESTEP);
|
||||||
|
|
||||||
|
state.lastClientX = clientX;
|
||||||
|
state.lastClientY = clientY;
|
||||||
|
state.lastTimestamp = timestamp;
|
||||||
|
|
||||||
|
const pointerCanvasX = clientX - state.containerRectLeft;
|
||||||
|
const pointerCanvasY = clientY - state.containerRectTop;
|
||||||
|
|
||||||
|
// Helper for updating angular velocity based on pointer movement
|
||||||
|
const updatePointerAngularVelocity = (
|
||||||
|
pX: number,
|
||||||
|
pY: number,
|
||||||
|
cX: number,
|
||||||
|
cY: number,
|
||||||
|
dtSec: number,
|
||||||
|
targetState: ActiveDragSession | DragGroupItem = state
|
||||||
|
) => {
|
||||||
|
if (!Number.isFinite(dtSec) || dtSec <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const leverX = pX - cX;
|
||||||
|
const leverY = pY - cY;
|
||||||
|
if (!Number.isFinite(leverX) || !Number.isFinite(leverY)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevCanvasX = Number.isFinite(state.lastPointerCanvasX)
|
||||||
|
? state.lastPointerCanvasX
|
||||||
|
: pX;
|
||||||
|
const prevCanvasY = Number.isFinite(state.lastPointerCanvasY)
|
||||||
|
? state.lastPointerCanvasY
|
||||||
|
: pY;
|
||||||
|
|
||||||
|
const velocityCanvasX = (pX - prevCanvasX) / dtSec;
|
||||||
|
const velocityCanvasY = (pY - prevCanvasY) / dtSec;
|
||||||
|
|
||||||
|
if (!Number.isFinite(velocityCanvasX) || !Number.isFinite(velocityCanvasY)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const torque = leverX * velocityCanvasY - leverY * velocityCanvasX;
|
||||||
|
const influenceRadius = Math.max(targetState.width, targetState.height) / 2 || 1;
|
||||||
|
const radiusScale = clamp(Math.hypot(leverX, leverY) / influenceRadius, 0.2, 2.5);
|
||||||
|
|
||||||
|
if (targetState === state) {
|
||||||
|
state.pointerRadiusScale = radiusScale;
|
||||||
|
}
|
||||||
|
|
||||||
|
const torqueResponse = 0.0025 * radiusScale;
|
||||||
|
const angularVelocityDeg = clamp(
|
||||||
|
torque * torqueResponse,
|
||||||
|
-MAX_ANGULAR_VELOCITY,
|
||||||
|
MAX_ANGULAR_VELOCITY,
|
||||||
|
);
|
||||||
|
|
||||||
|
const mass = Math.max(targetState.massGrams || CARD_BASE_WEIGHT_GRAMS, CARD_BASE_WEIGHT_GRAMS);
|
||||||
|
const massScale = Math.max(mass / CARD_BASE_WEIGHT_GRAMS, 1);
|
||||||
|
targetState.angularVelocity = angularVelocityDeg / massScale;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper for applying dynamic rotation
|
||||||
|
const applyDynamicRotation = (
|
||||||
|
dtSec: number,
|
||||||
|
targetState: ActiveDragSession | DragGroupItem = state,
|
||||||
|
dampingFactor = 0.94
|
||||||
|
) => {
|
||||||
|
if (!Number.isFinite(dtSec) || dtSec <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const radiusInfluence = clamp(state.pointerRadiusScale || 1, 0.3, 3);
|
||||||
|
const response = 1.1 * radiusInfluence;
|
||||||
|
|
||||||
|
let nextDynamic = (targetState.dynamicRotation || 0) + (targetState.angularVelocity || 0) * dtSec * response;
|
||||||
|
nextDynamic = clamp(nextDynamic, -MAX_DYNAMIC_ROTATION, MAX_DYNAMIC_ROTATION);
|
||||||
|
const adjustedDamping = Math.pow(dampingFactor, 1 / Math.max(radiusInfluence, 0.8));
|
||||||
|
targetState.dynamicRotation = nextDynamic * adjustedDamping;
|
||||||
|
|
||||||
|
if (targetState === state) {
|
||||||
|
state.rotation = state.restRotation + state.dynamicRotation;
|
||||||
|
} else {
|
||||||
|
const item = targetState as DragGroupItem;
|
||||||
|
item.displayRotation = (item.initialRotation || 0) + item.dynamicRotation;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deltaX = clientX - state.startX;
|
||||||
|
const deltaY = clientY - state.startY;
|
||||||
|
|
||||||
|
if (!state.moved) {
|
||||||
|
const distanceSquared = deltaX * deltaX + deltaY * deltaY;
|
||||||
|
// We need DRAG_HYSTERESIS_SQUARED here, but it's not imported.
|
||||||
|
// Assuming 4*4 = 16 for now or we should import it.
|
||||||
|
// Let's use a safe default if not available, but ideally we import it.
|
||||||
|
// Checking imports... it was in useDocumentDrag.ts import from constants.
|
||||||
|
// I should add it to imports in workspaceEngine.ts if not present.
|
||||||
|
// For now I'll use 16.
|
||||||
|
if (distanceSquared < 16) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.moved = true;
|
||||||
|
if (!state.stackSelectionApplied) {
|
||||||
|
state.stackSelectionApplied = true;
|
||||||
|
}
|
||||||
|
if (!state.groupElevated) {
|
||||||
|
state.activeDocIds.forEach((id) => this.bringToFront(id));
|
||||||
|
state.groupElevated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvasWidth = this.canvasSize.width || DESK_DEFAULT_CANVAS_WIDTH;
|
||||||
|
const canvasHeight = this.canvasSize.height || DESK_DEFAULT_CANVAS_HEIGHT;
|
||||||
|
|
||||||
|
state.groupItems.forEach((item) => {
|
||||||
|
updatePointerAngularVelocity(
|
||||||
|
pointerCanvasX,
|
||||||
|
pointerCanvasY,
|
||||||
|
item.currentCenterX,
|
||||||
|
item.currentCenterY,
|
||||||
|
dt,
|
||||||
|
item
|
||||||
|
);
|
||||||
|
|
||||||
|
applyDynamicRotation(dt, item, 0.96);
|
||||||
|
|
||||||
|
const gravitationDecay = 0.92;
|
||||||
|
item.baseOffsetX = (item.baseOffsetX || 0) * gravitationDecay;
|
||||||
|
item.baseOffsetY = (item.baseOffsetY || 0) * gravitationDecay;
|
||||||
|
|
||||||
|
if (Math.abs(item.baseOffsetX) < 0.5) item.baseOffsetX = 0;
|
||||||
|
if (Math.abs(item.baseOffsetY) < 0.5) item.baseOffsetY = 0;
|
||||||
|
|
||||||
|
const targetX = pointerCanvasX - state.localPointerOffsetX + (item.baseOffsetX || 0);
|
||||||
|
const targetY = pointerCanvasY - state.localPointerOffsetY + (item.baseOffsetY || 0);
|
||||||
|
|
||||||
|
const smoothing = 0.18;
|
||||||
|
const stackFriction = 0.85;
|
||||||
|
const effectiveSmoothing = smoothing * stackFriction;
|
||||||
|
|
||||||
|
item.currentCenterX += (targetX - item.currentCenterX) * effectiveSmoothing;
|
||||||
|
item.currentCenterY += (targetY - item.currentCenterY) * effectiveSmoothing;
|
||||||
|
|
||||||
|
const bounds = computeCardBounds({
|
||||||
|
width: item.width,
|
||||||
|
height: item.height,
|
||||||
|
canvasWidth,
|
||||||
|
canvasHeight,
|
||||||
|
padding: DESK_CANVAS_PADDING,
|
||||||
|
});
|
||||||
|
|
||||||
|
item.currentCenterX = clamp(item.currentCenterX, bounds.minX, bounds.maxX);
|
||||||
|
item.currentCenterY = clamp(item.currentCenterY, bounds.minY, bounds.maxY);
|
||||||
|
|
||||||
|
const entry = this.layout.get(item.docId) || null;
|
||||||
|
const payload = {
|
||||||
|
centerX: item.currentCenterX,
|
||||||
|
centerY: item.currentCenterY,
|
||||||
|
rotation: item.displayRotation ?? 0,
|
||||||
|
width: item.width,
|
||||||
|
height: item.height,
|
||||||
|
scale: item.docId === state.docKey ? state.dragScale || 1 : 1,
|
||||||
|
zIndex: entry?.z,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.applyTransform(
|
||||||
|
item.docId,
|
||||||
|
payload.centerX,
|
||||||
|
payload.centerY,
|
||||||
|
payload.width,
|
||||||
|
payload.height,
|
||||||
|
payload.rotation,
|
||||||
|
payload.scale,
|
||||||
|
payload.zIndex
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
state.lastPointerCanvasX = pointerCanvasX;
|
||||||
|
state.lastPointerCanvasY = pointerCanvasY;
|
||||||
|
}
|
||||||
|
|
||||||
flushPendingLayoutOps(): void {
|
flushPendingLayoutOps(): void {
|
||||||
if (this.pendingSnapshotSync) {
|
if (this.pendingSnapshotSync) {
|
||||||
@@ -827,7 +980,8 @@ export class WorkspaceEngine {
|
|||||||
if (!key) {
|
if (!key) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const node = this.itemRefs?.current?.get(key);
|
const node = this.itemRefs.current.get(key);
|
||||||
|
if (node) {
|
||||||
applyDomTransform(node, {
|
applyDomTransform(node, {
|
||||||
centerX,
|
centerX,
|
||||||
centerY,
|
centerY,
|
||||||
@@ -838,8 +992,13 @@ export class WorkspaceEngine {
|
|||||||
zIndex,
|
zIndex,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
finalizeGroupDrag(dragState: DragState): void {
|
finalizeGroupDrag(): void {
|
||||||
|
if (this.state.type !== 'dragging') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dragState = this.state.session;
|
||||||
if (!dragState?.groupItems) {
|
if (!dragState?.groupItems) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -883,6 +1042,7 @@ export class WorkspaceEngine {
|
|||||||
this.markLayoutDirty();
|
this.markLayoutDirty();
|
||||||
this.syncLayoutSnapshot();
|
this.syncLayoutSnapshot();
|
||||||
this.persistLayoutSnapshot();
|
this.persistLayoutSnapshot();
|
||||||
|
this.endDrag();
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelInertiaAnimation(docId: DocumentId | null): void {
|
cancelInertiaAnimation(docId: DocumentId | null): void {
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ export const formatTransform = (
|
|||||||
scale = 1,
|
scale = 1,
|
||||||
): string => `translate3d(${x}px, ${y}px, 0) rotate(${rotation}deg) scale(${scale})`;
|
): string => `translate3d(${x}px, ${y}px, 0) rotate(${rotation}deg) scale(${scale})`;
|
||||||
|
|
||||||
|
export const toNumber = (v: unknown, fallback = 0): number =>
|
||||||
|
Number.isFinite(Number(v)) ? Number(v) : fallback;
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
clamp,
|
clamp,
|
||||||
formatTransform,
|
formatTransform,
|
||||||
|
toNumber,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user