feat: Implement Z-index-aware click behavior for selected documents and add group gravitation to document drag.
This commit is contained in:
@@ -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,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user