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];
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<HTMLElement>, docId: Identifier | null, options: PointerDownOptions) => void;
handlePointerMove: React.PointerEventHandler<HTMLElement>;
+11 -1
View File
@@ -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;
@@ -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,
],
);
+65 -28
View File
@@ -111,6 +111,7 @@ interface UseDocumentDragOptions {
containerRef?: RefObject<HTMLElement>;
onDocumentActivate?: (docId: Identifier | null, event?: PointerEvent | ReactPointerEvent) => void;
markLayoutDirty?: () => void;
onSelect?: (docIds: string[]) => void;
}
type EngineInertiaState = Parameters<WorkspaceEngine['startInertiaAnimation']>[1];
@@ -203,6 +204,7 @@ const useDocumentDrag = (options: UseDocumentDragOptions) => {
containerRef: providedContainerRef,
onDocumentActivate,
markLayoutDirty,
onSelect,
} = options;
const fallbackContainerRef = useRef<HTMLElement | null>(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);