diff --git a/frontend/src/desktop/DesktopWorkspace.tsx b/frontend/src/desktop/DesktopWorkspace.tsx index 1d5f0c5..4c2e506 100644 --- a/frontend/src/desktop/DesktopWorkspace.tsx +++ b/frontend/src/desktop/DesktopWorkspace.tsx @@ -795,7 +795,10 @@ const DesktopWorkspace: React.FC = ({ ? descriptorOrDescriptors : [descriptorOrDescriptors]; const keys = descriptors - .map((d: any) => getDocEntryKey(d.id)) + .map((d: any) => { + const id = typeof d === 'string' ? d : d?.id; + return getDocEntryKey(id); + }) .filter((k: any) => k); if (keys.length > 0) { @@ -893,6 +896,7 @@ function DesktopWorkspaceView({ containerRef, onDocumentActivate: handleDeskDocumentActivate, markLayoutDirty, + onSelect, }) as { handlePointerDown: (event: React.PointerEvent, docId: Identifier | null, options: PointerDownOptions) => void; handlePointerMove: React.PointerEventHandler; diff --git a/frontend/src/desktop/pointer/pointerUtils.ts b/frontend/src/desktop/pointer/pointerUtils.ts index 36b81b2..6a05983 100644 --- a/frontend/src/desktop/pointer/pointerUtils.ts +++ b/frontend/src/desktop/pointer/pointerUtils.ts @@ -23,6 +23,7 @@ interface PointerIntentArgs { pointerButton?: number; pointerType?: string; stackHits?: string[] | null; + isTopMost?: boolean; } export interface PointerIntent { @@ -53,6 +54,7 @@ export const createPointerIntent = ({ pointerButton, pointerType, stackHits, + isTopMost = true, }: PointerIntentArgs): PointerIntent => { const alreadySelected = selectedDocumentIds.includes(doc.id); const selectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0; @@ -64,7 +66,15 @@ export const createPointerIntent = ({ clickAction = CLICK_ACTIONS.addStack; dragAction = DRAG_ACTIONS.dragSelection; } else if (alreadySelected) { - clickAction = CLICK_ACTIONS.openDetail; + // 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; diff --git a/frontend/src/desktop/pointer/useDeskPointer.js b/frontend/src/desktop/pointer/useDeskPointer.js index c2d79d8..6b07af4 100644 --- a/frontend/src/desktop/pointer/useDeskPointer.js +++ b/frontend/src/desktop/pointer/useDeskPointer.js @@ -232,6 +232,23 @@ export const useDeskPointer = ({ 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, @@ -240,6 +257,7 @@ export const useDeskPointer = ({ pointerButton, pointerType, stackHits, + isTopMost, }); if (intent.selectedAtDown) { @@ -277,6 +295,7 @@ export const useDeskPointer = ({ resetLongPressState, scheduleLongPress, selectedDocumentIds, + layoutRef, ], ); diff --git a/frontend/src/desktop/useDocumentDrag.ts b/frontend/src/desktop/useDocumentDrag.ts index a08fa6a..05bfa0e 100644 --- a/frontend/src/desktop/useDocumentDrag.ts +++ b/frontend/src/desktop/useDocumentDrag.ts @@ -111,6 +111,7 @@ interface UseDocumentDragOptions { containerRef?: RefObject; onDocumentActivate?: (docId: Identifier | null, event?: PointerEvent | ReactPointerEvent) => void; markLayoutDirty?: () => void; + onSelect?: (docIds: string[]) => void; } type EngineInertiaState = Parameters[1]; @@ -203,6 +204,7 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => { containerRef: providedContainerRef, onDocumentActivate, markLayoutDirty, + onSelect, } = options; const fallbackContainerRef = useRef(null); @@ -331,7 +333,7 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => { return; } - // 3. Sort by Z-index (ascending) so the top-most card is last + // 3. Sort by Z-index (ascending) const layout = layoutRef.current; const sortedSelectionIds = [...selectionIds] .sort((a, b) => { @@ -341,16 +343,34 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => { }); // 4. Determine Anchor - // Anchor is the top-most valid card (last in sorted list) - // We iterate backwards to find the first one with a valid layout + // Prefer the clicked card if it's in the selection and has a valid layout let anchorId = sortedSelectionIds[sortedSelectionIds.length - 1]; - for (let i = sortedSelectionIds.length - 1; i >= 0; i--) { - if (layout.has(sortedSelectionIds[i])) { - anchorId = sortedSelectionIds[i]; - break; + + // Use docIdInput directly as it's the argument passed to the function + if (docIdInput && sortedSelectionIds.includes(docIdInput) && layout.has(docIdInput)) { + anchorId = docIdInput; + } 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--) { + if (layout.has(sortedSelectionIds[i])) { + anchorId = sortedSelectionIds[i]; + break; + } } } + // 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); + finalSelectionIds.push(anchorId); + + // Sync global selection order with the new visual stack order + if (onSelect) { + onSelect(finalSelectionIds); + } + const docKey = anchorId; const doc = documentLookup.get(docKey); if (!doc) { @@ -359,10 +379,10 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => { engine?.cancelInertiaAnimation?.(docKey); - const isGroupDrag = sortedSelectionIds.length > 1; + const isGroupDrag = finalSelectionIds.length > 1; if (isGroupDrag) { - sortedSelectionIds.forEach((id) => { + finalSelectionIds.forEach((id) => { if (id !== docKey) { engine?.cancelInertiaAnimation?.(id); } @@ -382,10 +402,15 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => { const centerX = Number.isFinite(entry?.centerX) ? entry.centerX : defaultCenterX; const centerY = Number.isFinite(entry?.centerY) ? entry.centerY : defaultCenterY; + const initialCenter = { + x: centerX, + y: centerY, + }; + const modifierPressed = Boolean(options?.modifierActive); if (!modifierPressed) { if (isGroupDrag) { - sortedSelectionIds.forEach((id) => { + finalSelectionIds.forEach((id) => { bringToFront(id); }); } else { @@ -422,7 +447,7 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => { const localPointerOffsetX = pointerOffsetX * cosInitial - pointerOffsetY * sinInitial; const localPointerOffsetY = pointerOffsetX * sinInitial + pointerOffsetY * cosInitial; - const groupItems: DragGroupItemInternal[] = sortedSelectionIds.map((id) => { + const groupItems: DragGroupItemInternal[] = finalSelectionIds.map((id) => { const itemDoc = documentLookup.get(id); const itemSize = ensureDocumentSize(itemDoc) || sizeInfo; const itemWidth = itemSize.width || docWidth; @@ -432,8 +457,11 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => { Number.isFinite(itemEntry?.centerX) ? itemEntry.centerX : canvasPadding + itemWidth / 2; const itemCenterY = Number.isFinite(itemEntry?.centerY) ? itemEntry.centerY : canvasPadding + itemHeight / 2; - const baseOffsetX = itemCenterX - centerX; - const baseOffsetY = itemCenterY - centerY; + + // Base offset is relative to the ANCHOR's center + const baseOffsetX = itemCenterX - initialCenter.x; + const baseOffsetY = itemCenterY - initialCenter.y; + const initialRotation = itemEntry?.rotation ?? 0; const itemMass = computeDocumentMassGrams(itemDoc); @@ -447,6 +475,7 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => { baseOffsetY, offsetX: baseOffsetX, offsetY: baseOffsetY, + initialRotation: initialRotation, targetRotation: initialRotation, displayRotation: initialRotation, angularVelocity: 0, @@ -464,16 +493,20 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => { const massGrams = computeDocumentMassGrams(doc); - dragStateRef.current = { - docId: docKey, - docKey, + const state: DragStateInternal = { pointerId: event.pointerId, - originCenterX: centerX, - originCenterY: centerY, - currentCenterX: centerX, - currentCenterY: centerY, startX: event.clientX, startY: event.clientY, + lastClientX: event.clientX, + lastClientY: event.clientY, + docKey, + isGroup: true, + activeDocIds: finalSelectionIds, + docId: docKey, + originCenterX: initialCenter.x, + originCenterY: initialCenter.y, + currentCenterX: initialCenter.x, + currentCenterY: initialCenter.y, rotation: entry?.rotation ?? 0, restRotation: entry?.rotation ?? 0, dynamicRotation: 0, @@ -485,15 +518,11 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => { dragScale: 1, baseScale: normalizedBaseScale, capturedTarget, - lastClientX: event.clientX, - lastClientY: event.clientY, lastTimestamp: eventTimestamp, localPointerOffsetX, localPointerOffsetY, containerRectLeft: containerLeft, containerRectTop: containerTop, - isGroup: isGroupDrag, - activeDocIds: sortedSelectionIds, groupItems, groupElevated: !isGroupDrag, stackSelectionApplied: true, @@ -503,10 +532,7 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => { lastPointerCanvasY: pointerCanvasY, } satisfies DragStateInternal; - const state = dragStateRef.current; - if (!state) { - return; - } + dragStateRef.current = state; clearDragTransforms(); state.groupItems.forEach((item) => { @@ -562,6 +588,7 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => { itemRefs, clearDragTransforms, setDragTransform, + onSelect, ]); const handlePointerMove = useCallback( @@ -723,6 +750,16 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => { 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);