From 5b88ecf2dd81f6c4b2a5f49566f936f0c2c0f169 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Thu, 6 Nov 2025 23:11:22 +0100 Subject: [PATCH] debounce --- frontend/src/desktop/DesktopWorkspace.jsx | 14 +--- frontend/src/desktop/useDocumentDrag.js | 57 +++++++++------- frontend/src/desktop/workspaceEngine.js | 83 +++++++++++++++++++---- 3 files changed, 102 insertions(+), 52 deletions(-) diff --git a/frontend/src/desktop/DesktopWorkspace.jsx b/frontend/src/desktop/DesktopWorkspace.jsx index 95b118e..06d65cf 100644 --- a/frontend/src/desktop/DesktopWorkspace.jsx +++ b/frontend/src/desktop/DesktopWorkspace.jsx @@ -242,10 +242,6 @@ const DesktopWorkspace = ({ engine.recalcVisibleDocIds(); }, [engine]); - const syncLayoutSnapshot = useCallback((force = false) => { - engine.syncLayoutSnapshot(force); - }, [engine]); - const setDraggingId = useCallback((value) => { engine.setDraggingId(value); }, [engine]); @@ -666,7 +662,6 @@ const DesktopWorkspace = ({ resolveBaseMetrics, bringToFront, setDraggingId, - syncLayoutSnapshot, canvasSize, openOverlayForDoc, recalcVisibleDocIds, @@ -725,7 +720,6 @@ const DesktopWorkspace = ({ markLayoutDirty, tagDropTargetId, visibleDocIds, - syncLayoutSnapshot, ], ); return ( @@ -778,10 +772,9 @@ const DesktopWorkspaceView = ({ onCloseDetailPanel, documentLookup, resolveBaseMetrics, - bringToFront, - setDraggingId, - syncLayoutSnapshot, - canvasSize, + bringToFront, + setDraggingId, + canvasSize, openOverlayForDoc, recalcVisibleDocIds, dragSettings, @@ -800,7 +793,6 @@ const DesktopWorkspaceView = ({ resolveBaseMetrics, bringToFront, setDraggingId, - syncLayoutSnapshot, canvasSize, openOverlayForDoc, recalcVisibleDocIds, diff --git a/frontend/src/desktop/useDocumentDrag.js b/frontend/src/desktop/useDocumentDrag.js index 664567f..3fc1a84 100644 --- a/frontend/src/desktop/useDocumentDrag.js +++ b/frontend/src/desktop/useDocumentDrag.js @@ -19,7 +19,6 @@ const useDocumentDrag = (options = {}) => { resolveBaseMetrics, bringToFront, setDraggingId, - syncLayoutSnapshot, canvasSize, openOverlayForDoc, recalcVisibleDocIds, @@ -114,7 +113,7 @@ const useDocumentDrag = (options = {}) => { }, [dragTransformsRef, layoutRef, markLayoutDirty]); const finishDrag = useCallback( - (pointerId, { shouldSync = false, clearTransforms = true } = {}) => { + (pointerId, { clearTransforms = true } = {}) => { const state = dragStateRef.current; if (state && state.pointerId === pointerId) { const capturedTarget = state.capturedTarget; @@ -134,11 +133,8 @@ const useDocumentDrag = (options = {}) => { if (clearTransforms) { clearDragTransforms(); } - if (shouldSync) { - syncLayoutSnapshot(true); - } }, - [clearDragTransforms, debugDrag, engine, setDraggingId, syncLayoutSnapshot], + [clearDragTransforms, debugDrag, engine, setDraggingId], ); const handlePointerDown = useCallback( @@ -176,7 +172,7 @@ const useDocumentDrag = (options = {}) => { : []; if (!stackDocIdsOption && !pointerModifierActive && !wasSelectedAtPointerDown) { - selectionIds = []; + selectionIds = [docKey]; } if (stackDocIdsOption && stackDocIdsOption.length) { @@ -193,20 +189,23 @@ const useDocumentDrag = (options = {}) => { if (!stackDocIdsOption && metaOrCtrl && !selectionIds.includes(docKey)) { selectionIds = [...selectionIds, docKey]; } - let groupDocIds = selectionIds + + selectionIds = selectionIds .map((id) => String(id)) .filter((id, index, array) => array.indexOf(id) === index && documentLookup.has(id)); - if (!groupDocIds.includes(docKey)) { - groupDocIds.unshift(docKey); + + if (!selectionIds.includes(docKey)) { + selectionIds.unshift(docKey); } - groupDocIds = groupDocIds.filter((id, index, array) => array.indexOf(id) === index); - if (!groupDocIds.length) { - groupDocIds = [docKey]; + + if (!selectionIds.length) { + selectionIds = [docKey]; } - const isGroupDrag = groupDocIds.length > 1; + + const isGroupDrag = selectionIds.length > 1; if (isGroupDrag) { - groupDocIds.forEach((id) => { + selectionIds.forEach((id) => { if (id !== docKey) { engine?.cancelInertiaAnimation?.(id); } @@ -230,7 +229,7 @@ const useDocumentDrag = (options = {}) => { if (!modifierPressed) { if (isGroupDrag) { const layout = layoutRef.current; - const ordered = [...groupDocIds] + const ordered = [...selectionIds] .filter((id, index, array) => array.indexOf(id) === index) .sort((a, b) => { const aZ = layout.get(a)?.z ?? 0; @@ -275,7 +274,7 @@ const useDocumentDrag = (options = {}) => { const localPointerOffsetX = pointerOffsetX * cosInitial - pointerOffsetY * sinInitial; const localPointerOffsetY = pointerOffsetX * sinInitial + pointerOffsetY * cosInitial; - const groupItems = groupDocIds.map((id) => { + const groupItems = selectionIds.map((id) => { const itemDoc = documentLookup.get(id); const itemSize = ensureDocumentSize(itemDoc) || sizeInfo; const itemWidth = itemSize.width || docWidth; @@ -343,7 +342,7 @@ const useDocumentDrag = (options = {}) => { containerRectLeft: containerLeft, containerRectTop: containerTop, isGroup: isGroupDrag, - groupDocIds, + activeDocIds: selectionIds, groupItems, groupElevated: !isGroupDrag, stackDocIds: hasStackSource ? stackDocIdsOption : null, @@ -368,7 +367,7 @@ const useDocumentDrag = (options = {}) => { }); }); - engine?.beginDrag?.(state.groupDocIds); + engine?.beginDrag?.(state.activeDocIds); setDraggingId(docKey); @@ -380,6 +379,7 @@ const useDocumentDrag = (options = {}) => { const node = itemRefs.current.get(item.docId); if (node) { item.displayRotation = item.initialRotation; + const itemEntry = layoutRef.current.get(item.docId) || null; applyDomTransform(node, { centerX: item.currentCenterX, centerY: item.currentCenterY, @@ -387,6 +387,7 @@ const useDocumentDrag = (options = {}) => { height: item.height, rotation: item.displayRotation ?? 0, scale: 1, + zIndex: itemEntry?.z, }); } }); @@ -447,7 +448,7 @@ const useDocumentDrag = (options = {}) => { } if (!state.groupElevated) { const layout = layoutRef.current; - const sortedGroup = state.groupDocIds + const sortedGroup = state.activeDocIds .filter((id) => id !== state.docKey) .sort((a, b) => { const aZ = layout.get(a)?.z ?? 0; @@ -515,6 +516,7 @@ const useDocumentDrag = (options = {}) => { item.displayRotation += (item.targetRotation - item.displayRotation) * rotationBlend; } + const entry = layoutRef.current.get(item.docId) || null; const payload = { centerX: item.currentCenterX, centerY: item.currentCenterY, @@ -522,6 +524,7 @@ const useDocumentDrag = (options = {}) => { width: item.width, height: item.height, scale: isPrimary ? state.dragScale || 1 : 1, + zIndex: entry?.z, }; setDragTransform(item.docId, payload); @@ -630,6 +633,7 @@ const useDocumentDrag = (options = {}) => { state.currentCenterX = currentCenterX; state.currentCenterY = currentCenterY; + const layoutEntry = layoutRef.current.get(state.docKey) || null; const transformPayload = { centerX: currentCenterX, centerY: currentCenterY, @@ -637,6 +641,7 @@ const useDocumentDrag = (options = {}) => { width: state.width, height: state.height, scale: state.dragScale || 1, + zIndex: layoutEntry?.z, }; setDragTransform(state.docKey, transformPayload); @@ -710,8 +715,8 @@ const useDocumentDrag = (options = {}) => { if (state.isGroup) { engine?.finalizeGroupDrag?.(state); - commitActiveDragTransforms(state.groupDocIds); - finishDrag(event.pointerId, { shouldSync: true }); + commitActiveDragTransforms(state.activeDocIds); + finishDrag(event.pointerId); recalcVisibleDocIds(); return; } @@ -728,7 +733,7 @@ const useDocumentDrag = (options = {}) => { dragScale: state.dragScale || 1, }; const docId = state.docKey; - finishDrag(event.pointerId, { shouldSync: true }); + finishDrag(event.pointerId); engine?.startInertiaAnimation?.(docId, inertiaState); return; } @@ -770,8 +775,8 @@ const useDocumentDrag = (options = {}) => { if (state && state.pointerId === event.pointerId && state.moved) { if (state.isGroup) { engine?.finalizeGroupDrag?.(state); - commitActiveDragTransforms(state.groupDocIds); - finishDrag(event.pointerId, { shouldSync: true }); + commitActiveDragTransforms(state.activeDocIds); + finishDrag(event.pointerId); recalcVisibleDocIds(); return; } @@ -787,7 +792,7 @@ const useDocumentDrag = (options = {}) => { dragScale: state.dragScale || 1, }; const docId = state.docKey; - finishDrag(event.pointerId, { shouldSync: true }); + finishDrag(event.pointerId); engine?.startInertiaAnimation?.(docId, inertiaState); return; } diff --git a/frontend/src/desktop/workspaceEngine.js b/frontend/src/desktop/workspaceEngine.js index 45d8898..3effcb7 100644 --- a/frontend/src/desktop/workspaceEngine.js +++ b/frontend/src/desktop/workspaceEngine.js @@ -26,6 +26,7 @@ export const applyDomTransform = ( height, rotation = 0, scale = 1, + zIndex, } = {}, ) => { if (!node) { @@ -38,6 +39,9 @@ export const applyDomTransform = ( const originX = cx - w / 2; const originY = cy - h / 2; node.style.transform = formatTransform(originX, originY, rotation || 0, scale || 1); + if (zIndex != null && node.style.zIndex !== String(zIndex)) { + node.style.zIndex = String(zIndex); + } }; export const clampCardDimensions = (width, height) => { @@ -399,6 +403,11 @@ export class WorkspaceEngine { this.pendingRemovalTag = null; this.dragInProgress = false; this.activeDragDocIds = new Set(); + this.pendingSnapshotSync = false; + this.pendingPersistSync = false; + this.persistDebounceId = null; + this.pendingSnapshotSync = false; + this.pendingPersistSync = false; this.items = []; this.documentLookup = new Map(); @@ -529,6 +538,16 @@ export class WorkspaceEngine { endDrag() { this.dragInProgress = false; this.activeDragDocIds.clear(); + this.flushPendingLayoutOps(); + } + + flushPendingLayoutOps() { + if (this.pendingSnapshotSync) { + this.syncLayoutSnapshot(); + } + if (this.pendingPersistSync) { + this.persistLayoutSnapshot(); + } } setTagDropTargetId(docId) { @@ -583,6 +602,7 @@ export class WorkspaceEngine { } this.markLayoutDirty(); this.syncLayoutSnapshot(); + this.persistLayoutSnapshot(); } bringToFront(docId) { @@ -598,10 +618,11 @@ export class WorkspaceEngine { this.layout.set(key, { ...entry, z: this.zCounter }); this.markLayoutDirty(); this.syncLayoutSnapshot(); + this.persistLayoutSnapshot(); this.recalcVisibleDocIds(); } - applyTransform(docId, centerX, centerY, width, height, rotation, scale = 1) { + applyTransform(docId, centerX, centerY, width, height, rotation, scale = 1, zIndex = null) { const key = docId != null ? String(docId) : null; if (!key) { return; @@ -614,6 +635,7 @@ export class WorkspaceEngine { height, rotation, scale, + zIndex, }); } @@ -635,12 +657,14 @@ export class WorkspaceEngine { const centerY = item.currentCenterY ?? entry.centerY ?? dragState.originCenterY; const rotation = item.displayRotation ?? entry.rotation ?? 0; - this.layout.set(key, { + const nextEntry = { ...entry, centerX, centerY, rotation, - }); + }; + + this.layout.set(key, nextEntry); this.applyTransform( key, @@ -650,10 +674,13 @@ export class WorkspaceEngine { item.height, rotation, key === dragState.docKey ? dragState.dragScale || 1 : 1, + nextEntry.z, ); }); this.markLayoutDirty(); + this.syncLayoutSnapshot(); + this.persistLayoutSnapshot(); } cancelInertiaAnimation(docId) { @@ -721,7 +748,8 @@ export class WorkspaceEngine { simulationState.rotation = simulationState.restRotation + dynamicRotation; const rotation = simulationState.rotation; - this.layout.set(key, { ...entry, rotation }); + const nextEntry = { ...entry, rotation }; + this.layout.set(key, nextEntry); this.markLayoutDirty(); this.applyTransform( @@ -732,6 +760,7 @@ export class WorkspaceEngine { simulationState.height, rotation, simulationState.dragScale || 1, + nextEntry.z, ); const isSettled = Math.abs(angularVelocity) < SETTLE_ANGULAR_VELOCITY; @@ -775,6 +804,7 @@ export class WorkspaceEngine { if (settled) { this.inertiaAnimations.delete(key); this.syncLayoutSnapshot(); + this.persistLayoutSnapshot(); return; } simulationState.frameId = window.requestAnimationFrame(step); @@ -784,27 +814,38 @@ export class WorkspaceEngine { this.inertiaAnimations.set(key, simulationState); } - syncLayoutSnapshot(force = false) { - if (this.dragInProgress && !force) { + syncLayoutSnapshot() { + if (this.dragInProgress) { + this.pendingSnapshotSync = true; return; } + this.pendingSnapshotSync = false; this.layoutSnapshot = new Map(this.layout); this.emit(); - this.persistLayoutSnapshot(force); } - async persistLayoutSnapshot(force = false) { - if (this.dragInProgress && !force) { + async persistLayoutSnapshot() { + if (this.dragInProgress) { + this.pendingPersistSync = true; return; } if (!this.allowLayoutPersistence || !this.tenantId || !this.viewId) { + this.pendingPersistSync = false; return; } - if (!force && !this.layoutDirty) { + if (!this.layoutDirty && !this.pendingPersistSync) { return; } + this.pendingPersistSync = false; this.layoutDirty = false; - const snapshot = new Map(this.layoutSnapshot); + if (this.persistDebounceId) { + clearTimeout(this.persistDebounceId); + this.persistDebounceId = null; + } + const snapshotSource = this.layoutSnapshot && this.layoutSnapshot.size + ? this.layoutSnapshot + : this.layout; + const snapshot = new Map(snapshotSource); const merged = new Map(this.persistedLayout); snapshot.forEach((entry, docId) => { if (!docId || !entry) { @@ -836,10 +877,21 @@ export class WorkspaceEngine { }); }); - try { - await upsertLayoutRecords({ tenantId: this.tenantId, viewId: this.viewId, entries: records }); - } catch (error) { - console.warn('[desk] Failed to persist layout snapshot', error); + const persistTask = async () => { + try { + await upsertLayoutRecords({ tenantId: this.tenantId, viewId: this.viewId, entries: records }); + } catch (error) { + console.warn('[desk] Failed to persist layout snapshot', error); + } + }; + + if (typeof window !== 'undefined' && typeof window.setTimeout === 'function') { + this.persistDebounceId = window.setTimeout(() => { + this.persistDebounceId = null; + void persistTask(); + }, 100); + } else { + await persistTask(); } } @@ -940,6 +992,7 @@ export class WorkspaceEngine { this.layout = next; this.zCounter = Math.max(this.zCounter, maxZ); this.syncLayoutSnapshot(); + this.persistLayoutSnapshot(); this.recalcVisibleDocIds(); }