feat: Implement Z-index-aware click behavior for selected documents and add group gravitation to document drag.

This commit is contained in:
2025-11-25 21:55:52 +01:00
parent 45ff3fb9ac
commit c4603a0b3a
4 changed files with 100 additions and 30 deletions
+5 -1
View File
@@ -795,7 +795,10 @@ const DesktopWorkspace: React.FC<DocumentsViewProps> = ({
? descriptorOrDescriptors ? descriptorOrDescriptors
: [descriptorOrDescriptors]; : [descriptorOrDescriptors];
const keys = descriptors 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); .filter((k: any) => k);
if (keys.length > 0) { if (keys.length > 0) {
@@ -893,6 +896,7 @@ function DesktopWorkspaceView({
containerRef, containerRef,
onDocumentActivate: handleDeskDocumentActivate, onDocumentActivate: handleDeskDocumentActivate,
markLayoutDirty, markLayoutDirty,
onSelect,
}) 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>;
@@ -23,6 +23,7 @@ interface PointerIntentArgs {
pointerButton?: number; pointerButton?: number;
pointerType?: string; pointerType?: string;
stackHits?: string[] | null; stackHits?: string[] | null;
isTopMost?: boolean;
} }
export interface PointerIntent { export interface PointerIntent {
@@ -53,6 +54,7 @@ export const createPointerIntent = ({
pointerButton, pointerButton,
pointerType, pointerType,
stackHits, stackHits,
isTopMost = true,
}: PointerIntentArgs): PointerIntent => { }: PointerIntentArgs): PointerIntent => {
const alreadySelected = selectedDocumentIds.includes(doc.id); const alreadySelected = selectedDocumentIds.includes(doc.id);
const selectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0; const selectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0;
@@ -64,7 +66,15 @@ export const createPointerIntent = ({
clickAction = CLICK_ACTIONS.addStack; clickAction = CLICK_ACTIONS.addStack;
dragAction = DRAG_ACTIONS.dragSelection; dragAction = DRAG_ACTIONS.dragSelection;
} else if (alreadySelected) { } else if (alreadySelected) {
// Only open detail if it's already the top-most card
if (isTopMost) {
clickAction = CLICK_ACTIONS.openDetail; 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; dragAction = selectionCount > 1 ? DRAG_ACTIONS.dragSelection : DRAG_ACTIONS.dragSelectSingle;
} else { } else {
clickAction = CLICK_ACTIONS.selectSingle; clickAction = CLICK_ACTIONS.selectSingle;
@@ -232,6 +232,23 @@ export const useDeskPointer = ({
const entryDescriptor = buildEntryDescriptor(doc.id); const entryDescriptor = buildEntryDescriptor(doc.id);
const stackHits = metaKey ? resolveStackDocIds(event, doc.id) : null; 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({ const intent = createPointerIntent({
doc, doc,
entryDescriptor, entryDescriptor,
@@ -240,6 +257,7 @@ export const useDeskPointer = ({
pointerButton, pointerButton,
pointerType, pointerType,
stackHits, stackHits,
isTopMost,
}); });
if (intent.selectedAtDown) { if (intent.selectedAtDown) {
@@ -277,6 +295,7 @@ export const useDeskPointer = ({
resetLongPressState, resetLongPressState,
scheduleLongPress, scheduleLongPress,
selectedDocumentIds, selectedDocumentIds,
layoutRef,
], ],
); );
+61 -24
View File
@@ -111,6 +111,7 @@ 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]; type EngineInertiaState = Parameters<WorkspaceEngine['startInertiaAnimation']>[1];
@@ -203,6 +204,7 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
containerRef: providedContainerRef, containerRef: providedContainerRef,
onDocumentActivate, onDocumentActivate,
markLayoutDirty, markLayoutDirty,
onSelect,
} = options; } = options;
const fallbackContainerRef = useRef<HTMLElement | null>(null); const fallbackContainerRef = useRef<HTMLElement | null>(null);
@@ -331,7 +333,7 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
return; 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 layout = layoutRef.current;
const sortedSelectionIds = [...selectionIds] const sortedSelectionIds = [...selectionIds]
.sort((a, b) => { .sort((a, b) => {
@@ -341,15 +343,33 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
}); });
// 4. Determine Anchor // 4. Determine Anchor
// Anchor is the top-most valid card (last in sorted list) // Prefer the clicked card if it's in the selection and has a valid layout
// We iterate backwards to find the first one with a valid layout
let anchorId = sortedSelectionIds[sortedSelectionIds.length - 1]; let anchorId = sortedSelectionIds[sortedSelectionIds.length - 1];
// 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--) { 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];
break; 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 docKey = anchorId;
const doc = documentLookup.get(docKey); const doc = documentLookup.get(docKey);
@@ -359,10 +379,10 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
engine?.cancelInertiaAnimation?.(docKey); engine?.cancelInertiaAnimation?.(docKey);
const isGroupDrag = sortedSelectionIds.length > 1; const isGroupDrag = finalSelectionIds.length > 1;
if (isGroupDrag) { if (isGroupDrag) {
sortedSelectionIds.forEach((id) => { finalSelectionIds.forEach((id) => {
if (id !== docKey) { if (id !== docKey) {
engine?.cancelInertiaAnimation?.(id); engine?.cancelInertiaAnimation?.(id);
} }
@@ -382,10 +402,15 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
const centerX = Number.isFinite(entry?.centerX) ? entry.centerX : defaultCenterX; const centerX = Number.isFinite(entry?.centerX) ? entry.centerX : defaultCenterX;
const centerY = Number.isFinite(entry?.centerY) ? entry.centerY : defaultCenterY; const centerY = Number.isFinite(entry?.centerY) ? entry.centerY : defaultCenterY;
const initialCenter = {
x: centerX,
y: centerY,
};
const modifierPressed = Boolean(options?.modifierActive); const modifierPressed = Boolean(options?.modifierActive);
if (!modifierPressed) { if (!modifierPressed) {
if (isGroupDrag) { if (isGroupDrag) {
sortedSelectionIds.forEach((id) => { finalSelectionIds.forEach((id) => {
bringToFront(id); bringToFront(id);
}); });
} else { } else {
@@ -422,7 +447,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[] = sortedSelectionIds.map((id) => { const groupItems: DragGroupItemInternal[] = 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;
@@ -432,8 +457,11 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
Number.isFinite(itemEntry?.centerX) ? itemEntry.centerX : canvasPadding + itemWidth / 2; Number.isFinite(itemEntry?.centerX) ? itemEntry.centerX : canvasPadding + itemWidth / 2;
const itemCenterY = const itemCenterY =
Number.isFinite(itemEntry?.centerY) ? itemEntry.centerY : canvasPadding + itemHeight / 2; 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 initialRotation = itemEntry?.rotation ?? 0;
const itemMass = computeDocumentMassGrams(itemDoc); const itemMass = computeDocumentMassGrams(itemDoc);
@@ -447,6 +475,7 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
baseOffsetY, baseOffsetY,
offsetX: baseOffsetX, offsetX: baseOffsetX,
offsetY: baseOffsetY, offsetY: baseOffsetY,
initialRotation: initialRotation,
targetRotation: initialRotation, targetRotation: initialRotation,
displayRotation: initialRotation, displayRotation: initialRotation,
angularVelocity: 0, angularVelocity: 0,
@@ -464,16 +493,20 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
const massGrams = computeDocumentMassGrams(doc); const massGrams = computeDocumentMassGrams(doc);
dragStateRef.current = { const state: DragStateInternal = {
docId: docKey,
docKey,
pointerId: event.pointerId, pointerId: event.pointerId,
originCenterX: centerX,
originCenterY: centerY,
currentCenterX: centerX,
currentCenterY: centerY,
startX: event.clientX, startX: event.clientX,
startY: event.clientY, 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, rotation: entry?.rotation ?? 0,
restRotation: entry?.rotation ?? 0, restRotation: entry?.rotation ?? 0,
dynamicRotation: 0, dynamicRotation: 0,
@@ -485,15 +518,11 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
dragScale: 1, dragScale: 1,
baseScale: normalizedBaseScale, baseScale: normalizedBaseScale,
capturedTarget, capturedTarget,
lastClientX: event.clientX,
lastClientY: event.clientY,
lastTimestamp: eventTimestamp, lastTimestamp: eventTimestamp,
localPointerOffsetX, localPointerOffsetX,
localPointerOffsetY, localPointerOffsetY,
containerRectLeft: containerLeft, containerRectLeft: containerLeft,
containerRectTop: containerTop, containerRectTop: containerTop,
isGroup: isGroupDrag,
activeDocIds: sortedSelectionIds,
groupItems, groupItems,
groupElevated: !isGroupDrag, groupElevated: !isGroupDrag,
stackSelectionApplied: true, stackSelectionApplied: true,
@@ -503,10 +532,7 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
lastPointerCanvasY: pointerCanvasY, lastPointerCanvasY: pointerCanvasY,
} satisfies DragStateInternal; } satisfies DragStateInternal;
const state = dragStateRef.current; dragStateRef.current = state;
if (!state) {
return;
}
clearDragTransforms(); clearDragTransforms();
state.groupItems.forEach((item) => { state.groupItems.forEach((item) => {
@@ -562,6 +588,7 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
itemRefs, itemRefs,
clearDragTransforms, clearDragTransforms,
setDragTransform, setDragTransform,
onSelect,
]); ]);
const handlePointerMove = useCallback( const handlePointerMove = useCallback(
@@ -723,6 +750,16 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
applyDynamicRotation(dt, item, 0.96); applyDynamicRotation(dt, item, 0.96);
// 2. Calculate Target Position // 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 targetX = pointerCanvasX - state.localPointerOffsetX + (item.baseOffsetX || 0);
const targetY = pointerCanvasY - state.localPointerOffsetY + (item.baseOffsetY || 0); const targetY = pointerCanvasY - state.localPointerOffsetY + (item.baseOffsetY || 0);