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];
|
||||
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>;
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user