From 3c24b890a48428551d04935312e893e2e40a309b Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Sun, 2 Nov 2025 21:33:34 +0100 Subject: [PATCH 01/46] fix --- frontend/src/DesktopWorkspace.jsx | 159 ++++++++---------------- frontend/src/desktop/useDocumentDrag.js | 28 ++--- 2 files changed, 64 insertions(+), 123 deletions(-) diff --git a/frontend/src/DesktopWorkspace.jsx b/frontend/src/DesktopWorkspace.jsx index 0ad4b2f..abaa12a 100644 --- a/frontend/src/DesktopWorkspace.jsx +++ b/frontend/src/DesktopWorkspace.jsx @@ -35,9 +35,6 @@ const CARD_MIN = 240; const CARD_MAX = 340; const TAG_REMOVE_DISTANCE = 160; const STACK_HIT_EPSILON = 4; -const STACK_CENTER_TOLERANCE = 0.35; -const STACK_CENTER_MIN = 32; -const STACK_ROTATION_TOLERANCE = 15; const DEBUG_DRAG = false; const DEBUG_FOCUS = true; @@ -1953,7 +1950,6 @@ const DesktopWorkspaceView = () => { onClearSelection, detailPanelOpen, onCloseDetailPanel, - documentLookup, } = useDesktopContext(); const { handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel } = @@ -1974,7 +1970,8 @@ const DesktopWorkspaceView = () => { return []; } - const hits = []; + const candidates = []; + items.forEach((doc) => { if (!doc?.id) { return; @@ -1983,6 +1980,7 @@ const DesktopWorkspaceView = () => { if (!layout) { return; } + const sizeInfo = ensureDocumentSize(doc); if (!sizeInfo) { return; @@ -2018,122 +2016,66 @@ const DesktopWorkspaceView = () => { const halfWidth = width / 2; const halfHeight = height / 2; - if ( + const containsPointer = Math.abs(localX) <= halfWidth + STACK_HIT_EPSILON - && Math.abs(localY) <= halfHeight + STACK_HIT_EPSILON - ) { - const docKey = String(doc.id); - if (!hits.some((entry) => entry.id === docKey)) { - hits.push({ - id: docKey, - z: Number.isFinite(layout.z) ? layout.z : 0, - }); - } - } + && Math.abs(localY) <= halfHeight + STACK_HIT_EPSILON; + + candidates.push({ + id: String(doc.id), + z: Number.isFinite(layout.z) ? layout.z : 0, + centerX, + centerY, + rotationDeg, + width, + height, + halfWidth, + halfHeight, + localX, + localY, + marginX: halfWidth - Math.abs(localX), + marginY: halfHeight - Math.abs(localY), + containsPointer, + }); }); - if (!hits.length) { + const pointerCandidates = candidates.filter((candidate) => candidate.containsPointer); + if (!pointerCandidates.length) { return []; } - hits.sort((a, b) => (b.z ?? 0) - (a.z ?? 0)); + const sortedByZ = [...pointerCandidates].sort((a, b) => (b.z ?? 0) - (a.z ?? 0)); + const targetKey = targetDocId != null ? String(targetDocId) : sortedByZ[0].id; - const targetKey = targetDocId != null ? String(targetDocId) : hits[0].id; - const orderedIds = hits.map((entry) => entry.id); + const primary = sortedByZ.find((candidate) => candidate.id === targetKey) || sortedByZ[0]; + if (!primary) { + return []; + } + + const radius = Math.max(Math.min(primary.halfWidth, primary.halfHeight) * 1.2, 6); + const radiusSquared = radius * radius; + + const selected = candidates + .filter((candidate) => { + if (!candidate?.id) { + return false; + } + const dx = candidate.centerX - primary.centerX; + const dy = candidate.centerY - primary.centerY; + return dx * dx + dy * dy <= radiusSquared + 1e-4; + }) + .sort((a, b) => (b.z ?? 0) - (a.z ?? 0)); if (targetKey) { - const targetIndex = orderedIds.indexOf(targetKey); + const targetIndex = selected.findIndex((entry) => entry.id === targetKey); if (targetIndex > 0) { - const [targetEntry] = orderedIds.splice(targetIndex, 1); - orderedIds.unshift(targetEntry); + const [targetEntry] = selected.splice(targetIndex, 1); + selected.unshift(targetEntry); } } - const primaryKey = orderedIds[0]; - if (!primaryKey) { - return orderedIds; - } - - const primaryDoc = documentLookup.get(primaryKey) || null; - const primaryLayout = primaryDoc - ? layoutSnapshot.get(primaryDoc.id) ?? layoutRef.current.get(primaryKey) - : null; - const primarySize = primaryDoc ? ensureDocumentSize(primaryDoc) : null; - - if (!primaryLayout || !primarySize) { - return orderedIds; - } - - const primaryCenterX = Number(primaryLayout.centerX); - const primaryCenterY = Number(primaryLayout.centerY); - const primaryRotation = Number(primaryLayout.rotation) || 0; - if (!Number.isFinite(primaryCenterX) || !Number.isFinite(primaryCenterY)) { - return orderedIds; - } - - const centerTolX = Math.max(primarySize.width * STACK_CENTER_TOLERANCE, STACK_CENTER_MIN); - const centerTolY = Math.max(primarySize.height * STACK_CENTER_TOLERANCE, STACK_CENTER_MIN); - - const filteredIds = []; - - orderedIds.forEach((docKey, index) => { - if (!docKey) { - return; - } - if (index === 0 || docKey === targetKey) { - filteredIds.push(docKey); - return; - } - - const candidateDoc = documentLookup.get(docKey) || null; - if (!candidateDoc) { - return; - } - - const candidateLayout = layoutSnapshot.get(candidateDoc.id) ?? layoutRef.current.get(docKey); - if (!candidateLayout) { - return; - } - - const candidateSize = ensureDocumentSize(candidateDoc); - if (!candidateSize) { - return; - } - - const candidateCenterX = Number(candidateLayout.centerX); - const candidateCenterY = Number(candidateLayout.centerY); - if (!Number.isFinite(candidateCenterX) || !Number.isFinite(candidateCenterY)) { - return; - } - - const dx = Math.abs(candidateCenterX - primaryCenterX); - const dy = Math.abs(candidateCenterY - primaryCenterY); - if (dx > centerTolX || dy > centerTolY) { - return; - } - - const candidateRotation = Number(candidateLayout.rotation) || 0; - const rotationDiffRaw = Math.abs(candidateRotation - primaryRotation) % 360; - const rotationDiff = rotationDiffRaw > 180 ? 360 - rotationDiffRaw : rotationDiffRaw; - if (rotationDiff > STACK_ROTATION_TOLERANCE) { - return; - } - - const sizeRatio = candidateSize.width && primarySize.width - ? Math.min(candidateSize.width, primarySize.width) / Math.max(candidateSize.width, primarySize.width) - : 1; - const heightRatio = candidateSize.height && primarySize.height - ? Math.min(candidateSize.height, primarySize.height) / Math.max(candidateSize.height, primarySize.height) - : 1; - - if (sizeRatio < 0.55 || heightRatio < 0.55) { - return; - } - - filteredIds.push(docKey); - }); - - return filteredIds; + return selected + .map((candidate) => candidate.id) + .filter((id, index, array) => array.indexOf(id) === index); }, [ activeTagSet, @@ -2142,7 +2084,6 @@ const DesktopWorkspaceView = () => { layoutRef, layoutSnapshot, containerRef, - documentLookup, ], ); diff --git a/frontend/src/desktop/useDocumentDrag.js b/frontend/src/desktop/useDocumentDrag.js index 2c2b18f..9aaa656 100644 --- a/frontend/src/desktop/useDocumentDrag.js +++ b/frontend/src/desktop/useDocumentDrag.js @@ -380,8 +380,7 @@ const useDocumentDrag = () => { const localPointerOffsetX = pointerOffsetX * cosInitial - pointerOffsetY * sinInitial; const localPointerOffsetY = pointerOffsetX * sinInitial + pointerOffsetY * cosInitial; - const stackRandom = () => Math.random(); - const groupItems = groupDocIds.map((id, index) => { + const groupItems = groupDocIds.map((id) => { const itemDoc = documentLookup.get(id); const itemSize = ensureDocumentSize(itemDoc) || sizeInfo; const itemWidth = itemSize.width || docWidth; @@ -391,10 +390,8 @@ const useDocumentDrag = () => { typeof itemEntry?.centerX === 'number' ? itemEntry.centerX : canvasPadding + itemWidth / 2; const itemCenterY = typeof itemEntry?.centerY === 'number' ? itemEntry.centerY : canvasPadding + itemHeight / 2; - const radius = index === 0 ? 0 : 24 + index * 8; - const offsetAngle = (index * 1.618 + stackRandom() * 0.5) * Math.PI; - const offsetX = Math.cos(offsetAngle) * radius; - const offsetY = Math.sin(offsetAngle) * radius; + const baseOffsetX = itemCenterX - centerX; + const baseOffsetY = itemCenterY - centerY; const initialRotation = itemEntry?.rotation ?? 0; const targetRotation = initialRotation; return { @@ -403,8 +400,10 @@ const useDocumentDrag = () => { height: itemHeight, currentCenterX: itemCenterX, currentCenterY: itemCenterY, - offsetX, - offsetY, + baseOffsetX, + baseOffsetY, + offsetX: baseOffsetX, + offsetY: baseOffsetY, initialRotation, displayRotation: initialRotation, targetRotation, @@ -599,14 +598,15 @@ const useDocumentDrag = () => { if (isPrimary) { item.currentCenterX = centerX; item.currentCenterY = centerY; - item.offsetX *= 0.92; - item.offsetY *= 0.92; + item.offsetX = item.baseOffsetX ?? 0; + item.offsetY = item.baseOffsetY ?? 0; item.displayRotation = state.rotation ?? item.displayRotation ?? 0; } else { - item.offsetX *= 0.92; - item.offsetY *= 0.92; - if (Math.abs(item.offsetX) < 1) item.offsetX = 0; - if (Math.abs(item.offsetY) < 1) item.offsetY = 0; + const decay = 0.82; + const currentOffsetX = (item.offsetX ?? item.baseOffsetX ?? 0) * decay; + const currentOffsetY = (item.offsetY ?? item.baseOffsetY ?? 0) * decay; + item.offsetX = Math.abs(currentOffsetX) < 0.5 ? 0 : currentOffsetX; + item.offsetY = Math.abs(currentOffsetY) < 0.5 ? 0 : currentOffsetY; const targetX = centerX + item.offsetX; const targetY = centerY + item.offsetY; From 74aacbc1eb6edd7bab04ed1895b5ebf1333ea339 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Sun, 2 Nov 2025 22:08:15 +0100 Subject: [PATCH 02/46] fix --- frontend/src/DesktopWorkspace.jsx | 31 ++++++++++++++----------- frontend/src/app/AppLayout.jsx | 19 +++++++++++++++ frontend/src/desktop/useDocumentDrag.js | 8 ++++++- 3 files changed, 44 insertions(+), 14 deletions(-) diff --git a/frontend/src/DesktopWorkspace.jsx b/frontend/src/DesktopWorkspace.jsx index abaa12a..4afc6fa 100644 --- a/frontend/src/DesktopWorkspace.jsx +++ b/frontend/src/DesktopWorkspace.jsx @@ -583,6 +583,7 @@ const DesktopWorkspace = ({ helpOpen = false, onHelpClose = null, tenantId = null, + viewId = 'default', }) => { const items = useMemo(() => (searchResults ? searchResults : documents), [documents, searchResults]); @@ -628,11 +629,12 @@ const DesktopWorkspace = ({ }, [documentLookup]); const storageKey = useMemo(() => { - if (!tenantId) { + if (!tenantId || !viewId) { return null; } - return `papercrate.desk-layout.${tenantId}`; - }, [tenantId]); + const normalizedViewId = encodeURIComponent(String(viewId)); + return `papercrate.desk-layout.${tenantId}.${normalizedViewId}`; + }, [tenantId, viewId]); const initialPersistedLayout = useMemo(() => { if (!storageKey || typeof window === 'undefined') { @@ -1173,7 +1175,7 @@ const recalcVisibleDocIds = useCallback(() => { return; } layoutDirtyRef.current = false; - const payload = {}; + const merged = new Map(persistedLayoutRef.current); snapshot.forEach((entry, docId) => { if (!docId || !entry) { return; @@ -1183,18 +1185,21 @@ const recalcVisibleDocIds = useCallback(() => { if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) { return; } - payload[docId] = { - centerX, - centerY, - rotation: Number.isFinite(Number(entry.rotation)) ? Number(entry.rotation) : 0, - z: Number.isFinite(Number(entry.z)) ? Number(entry.z) : undefined, - }; + const rotation = Number.isFinite(Number(entry.rotation)) ? Number(entry.rotation) : 0; + const z = Number.isFinite(Number(entry.z)) ? Number(entry.z) : undefined; + merged.set(docId, { centerX, centerY, rotation, z }); + }); + + const payload = {}; + merged.forEach((entry, docId) => { + if (!docId || !entry) { + return; + } + payload[docId] = entry; }); try { window.localStorage.setItem(storageKey, JSON.stringify(payload)); - persistedLayoutRef.current = new Map( - Object.entries(payload).map(([id, value]) => [id, value]), - ); + persistedLayoutRef.current = merged; } catch (error) { console.warn('[desk] Failed to persist desk layout', error); } diff --git a/frontend/src/app/AppLayout.jsx b/frontend/src/app/AppLayout.jsx index a571aa2..e29488e 100644 --- a/frontend/src/app/AppLayout.jsx +++ b/frontend/src/app/AppLayout.jsx @@ -4865,6 +4865,23 @@ const AppLayout = () => { setDeskHelpOpen(false); }, []); + const deskViewId = useMemo(() => { + if (showingSearchResults) { + const trimmedQuery = searchQuery.trim(); + const tagsKey = [...activeTagFilters].sort().join(','); + const correspondentsKey = [...activeCorrespondentFilters].sort().join(','); + return `search:${trimmedQuery}|tags:${tagsKey}|corr:${correspondentsKey}`; + } + const folderKey = selectedFolder && selectedFolder !== '' ? selectedFolder : 'root'; + return `folder:${folderKey}`; + }, [ + showingSearchResults, + searchQuery, + activeTagFilters, + activeCorrespondentFilters, + selectedFolder, + ]); + const deskWorkspaceProps = useMemo( () => ({ documents, @@ -4883,6 +4900,7 @@ const AppLayout = () => { helpOpen: deskHelpOpen, onHelpClose: handleDeskHelpClose, tenantId: currentTenantId, + viewId: deskViewId, selectedDocumentIds, onClearSelection: clearDocumentSelection, detailPanelOpen, @@ -4910,6 +4928,7 @@ const AppLayout = () => { handleDeskHelpOpen, handleDeskHelpClose, currentTenantId, + deskViewId, deskHelpOpen, selectedDocumentIds, clearDocumentSelection, diff --git a/frontend/src/desktop/useDocumentDrag.js b/frontend/src/desktop/useDocumentDrag.js index 9aaa656..964b975 100644 --- a/frontend/src/desktop/useDocumentDrag.js +++ b/frontend/src/desktop/useDocumentDrag.js @@ -171,6 +171,7 @@ const useDocumentDrag = () => { const rotation = simulationState.rotation; layoutRef.current.set(docId, { ...entry, rotation }); + markLayoutDirty?.(); const node = itemRefs.current.get(docId); if (node) { @@ -185,7 +186,7 @@ const useDocumentDrag = () => { const isSettled = Math.abs(angularVelocity) < SETTLE_ANGULAR_VELOCITY; return isSettled; }, - [itemRefs, layoutRef], + [itemRefs, layoutRef, markLayoutDirty], ); const startInertiaAnimation = useCallback( @@ -627,6 +628,8 @@ const useDocumentDrag = () => { item.displayRotation += (item.targetRotation - item.displayRotation) * rotationBlend; } + markLayoutDirty?.(); + const entryItem = layoutRef.current.get(item.docId) || {}; layoutRef.current.set(item.docId, { ...entryItem, @@ -780,6 +783,8 @@ const useDocumentDrag = () => { const pointerInsideCard = Math.abs(pointerLocalX) <= halfWidth && Math.abs(pointerLocalY) <= halfHeight; + markLayoutDirty?.(); + const currentTimestamp = typeof event.timeStamp === 'number' && Number.isFinite(event.timeStamp) ? event.timeStamp @@ -829,6 +834,7 @@ const useDocumentDrag = () => { recalcVisibleDocIds, debugDrag, onDocumentStackSelect, + markLayoutDirty, ], ); From 626b6c16e0183ed24298d9e702e9330fc2acbae0 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Sun, 2 Nov 2025 22:25:00 +0100 Subject: [PATCH 03/46] fix --- frontend/src/desk/db.js | 143 ++++++++++++++++++++++++++ frontend/src/documents/tagTransfer.js | 95 +++++++++++++++++ frontend/src/ui/usePointerTap.js | 73 +++++++++++++ 3 files changed, 311 insertions(+) create mode 100644 frontend/src/desk/db.js create mode 100644 frontend/src/documents/tagTransfer.js create mode 100644 frontend/src/ui/usePointerTap.js diff --git a/frontend/src/desk/db.js b/frontend/src/desk/db.js new file mode 100644 index 0000000..b49f91e --- /dev/null +++ b/frontend/src/desk/db.js @@ -0,0 +1,143 @@ +const DB_NAME = 'papercrate_desk'; +const DB_VERSION = 1; +const LAYOUT_STORE = 'layouts'; + +const currentDbPromise = { value: null }; + +const openDatabase = () => { + if (currentDbPromise.value) { + return currentDbPromise.value; + } + + currentDbPromise.value = new Promise((resolve, reject) => { + if (typeof indexedDB === 'undefined') { + reject(new Error('IndexedDB not available')); + return; + } + + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(LAYOUT_STORE)) { + const store = db.createObjectStore(LAYOUT_STORE, { + keyPath: ['tenantId', 'viewId', 'documentId'], + }); + store.createIndex('tenantViewIdx', ['tenantId', 'viewId'], { unique: false }); + store.createIndex('tenantIdx', 'tenantId', { unique: false }); + store.createIndex('updatedIdx', 'updatedAt', { unique: false }); + } + }; + + request.onsuccess = () => { + resolve(request.result); + }; + + request.onerror = () => { + reject(request.error || new Error('Failed to open IndexedDB')); + }; + }); + + return currentDbPromise.value; +}; + +export const fetchLayoutRecords = async ({ tenantId, viewId }) => { + if (!tenantId || !viewId) { + return []; + } + + try { + const db = await openDatabase(); + const transaction = db.transaction(LAYOUT_STORE, 'readonly'); + const store = transaction.objectStore(LAYOUT_STORE); + const index = store.index('tenantViewIdx'); + const request = index.getAll([tenantId, viewId]); + + return await new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result || []); + request.onerror = () => reject(request.error || new Error('Failed to fetch layout records')); + }); + } catch (error) { + console.warn('[desk] Failed to read layout records', error); + return []; + } +}; + +export const upsertLayoutRecords = async ({ tenantId, viewId, entries }) => { + if (!tenantId || !viewId || !Array.isArray(entries) || !entries.length) { + return; + } + + try { + const db = await openDatabase(); + const transaction = db.transaction(LAYOUT_STORE, 'readwrite'); + const store = transaction.objectStore(LAYOUT_STORE); + const timestamp = Date.now(); + + entries.forEach((entry) => { + if (!entry || !entry.documentId) { + return; + } + store.put({ + tenantId, + viewId, + documentId: entry.documentId, + centerX: Number(entry.centerX) || 0, + centerY: Number(entry.centerY) || 0, + rotation: Number(entry.rotation) || 0, + zIndex: Number(entry.zIndex) || 0, + updatedAt: entry.updatedAt || timestamp, + }); + }); + + await new Promise((resolve, reject) => { + transaction.oncomplete = resolve; + transaction.onerror = () => reject(transaction.error || new Error('Failed to persist layout records')); + transaction.onabort = () => reject(transaction.error || new Error('Layout transaction aborted')); + }); + } catch (error) { + console.warn('[desk] Failed to upsert layout records', error); + } +}; + +export const deleteTenantLayouts = async (tenantId) => { + if (!tenantId) { + return; + } + try { + const db = await openDatabase(); + const transaction = db.transaction(LAYOUT_STORE, 'readwrite'); + const store = transaction.objectStore(LAYOUT_STORE); + const index = store.index('tenantIdx'); + const request = index.openCursor(tenantId); + + await new Promise((resolve, reject) => { + request.onsuccess = (event) => { + const cursor = event.target.result; + if (cursor) { + cursor.delete(); + cursor.continue(); + } else { + resolve(); + } + }; + request.onerror = () => reject(request.error || new Error('Failed to delete tenant layouts')); + }); + } catch (error) { + console.warn('[desk] Failed to clean tenant layouts', error); + } +}; + +export const closeDeskDatabase = () => { + if (!currentDbPromise.value) { + return; + } + currentDbPromise.value = currentDbPromise.value.then((db) => { + try { + db.close(); + } catch (error) { + console.warn('[desk] Failed to close IndexedDB', error); + } + return null; + }); +}; diff --git a/frontend/src/documents/tagTransfer.js b/frontend/src/documents/tagTransfer.js new file mode 100644 index 0000000..5679c89 --- /dev/null +++ b/frontend/src/documents/tagTransfer.js @@ -0,0 +1,95 @@ +const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag']; +const TAG_TEXT_MIME_TYPE = 'text/plain'; + +const serializePayload = (payload) => { + try { + return JSON.stringify(payload); + } catch (error) { + console.warn('[tagTransfer] Failed to serialize payload', error); + return null; + } +}; + +export const createTagTransferPayload = (tag, sourceDocId = null) => { + if (!tag || !tag.id) { + return null; + } + + return { + id: tag.id, + label: tag.label || '', + sourceDocId: sourceDocId ?? null, + }; +}; + +export const writeTagTransferData = (dataTransfer, tag, sourceDocId = null) => { + if (!dataTransfer) { + return; + } + + const payload = createTagTransferPayload(tag, sourceDocId); + if (!payload) { + return; + } + + const serialized = serializePayload(payload); + if (!serialized) { + return; + } + + try { + dataTransfer.setData(TAG_MIME_TYPES[0], serialized); + dataTransfer.setData(TAG_MIME_TYPES[1], serialized); + if (payload.label) { + dataTransfer.setData(TAG_TEXT_MIME_TYPE, payload.label); + } + } catch (error) { + console.warn('[tagTransfer] Failed to write drag data', error); + } +}; + +export const readTagTransferData = (dataTransfer) => { + if (!dataTransfer) { + return null; + } + + for (let index = 0; index < TAG_MIME_TYPES.length; index += 1) { + const type = TAG_MIME_TYPES[index]; + try { + const raw = dataTransfer.getData(type); + if (raw) { + return raw; + } + } catch (error) { + console.warn('[tagTransfer] Failed to read drag data for type', type, error); + } + } + return null; +}; + +export const parseTagTransferPayload = (input) => { + const dataTransfer = input && 'dataTransfer' in input ? input.dataTransfer : input; + const raw = readTagTransferData(dataTransfer); + if (!raw) { + return null; + } + + try { + return JSON.parse(raw); + } catch (error) { + console.warn('[tagTransfer] Failed to parse drag payload', error); + } + + return null; +}; + +export const isTagTransferEvent = (event) => { + const types = event?.dataTransfer?.types; + if (!types) { + return false; + } + const typeList = Array.isArray(types) ? types : Array.from(types); + return TAG_MIME_TYPES.some((type) => typeList.includes(type)); +}; + +export { TAG_MIME_TYPES }; diff --git a/frontend/src/ui/usePointerTap.js b/frontend/src/ui/usePointerTap.js new file mode 100644 index 0000000..f37b5d6 --- /dev/null +++ b/frontend/src/ui/usePointerTap.js @@ -0,0 +1,73 @@ +import { useCallback, useEffect, useRef } from 'react'; + +const defaultFilter = (event) => { + if (!event) { + return false; + } + const { type, button, pointerType, isPrimary } = event; + const isPointerUp = type === 'pointerup'; + const buttonValid = + button == null || button === 0 || (isPointerUp && (button === -1 || button === 0)); + if (!buttonValid) { + return false; + } + if (pointerType === 'touch' && isPrimary === false) { + return false; + } + return true; +}; + +const usePointerTap = ({ + onSingle, + onDouble, + delay = 240, + filter = defaultFilter, +} = {}) => { + const timerRef = useRef(null); + + useEffect(() => () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }, []); + + return useCallback( + (event, metadata = undefined) => { + if (!filter(event)) { + return; + } + + if (typeof event.persist === 'function') { + event.persist(); + } + + const context = { + clientX: event.clientX, + clientY: event.clientY, + pointerType: event.pointerType, + event, + data: metadata, + }; + + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + if (typeof onDouble === 'function') { + onDouble(context); + } + return; + } + + timerRef.current = setTimeout(() => { + timerRef.current = null; + if (typeof onSingle === 'function') { + onSingle(context); + } + }, delay); + }, + [delay, filter, onDouble, onSingle], + ); +}; + +export default usePointerTap; From 5c0f3502966fc9ade182d796b68149fc0077085a Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Sun, 2 Nov 2025 22:33:27 +0100 Subject: [PATCH 04/46] idbdatabase --- frontend/src/DesktopWorkspace.jsx | 126 ++++++++++++++---------------- 1 file changed, 59 insertions(+), 67 deletions(-) diff --git a/frontend/src/DesktopWorkspace.jsx b/frontend/src/DesktopWorkspace.jsx index 4afc6fa..6c50014 100644 --- a/frontend/src/DesktopWorkspace.jsx +++ b/frontend/src/DesktopWorkspace.jsx @@ -24,6 +24,7 @@ import { parseTagTransferPayload, writeTagTransferData, } from './documents/tagTransfer'; +import { fetchLayoutRecords, upsertLayoutRecords } from './desk/db'; import './DesktopWorkspace.css'; const CANVAS_PADDING = 24; @@ -628,58 +629,7 @@ const DesktopWorkspace = ({ documentLookupRef.current = documentLookup; }, [documentLookup]); - const storageKey = useMemo(() => { - if (!tenantId || !viewId) { - return null; - } - const normalizedViewId = encodeURIComponent(String(viewId)); - return `papercrate.desk-layout.${tenantId}.${normalizedViewId}`; - }, [tenantId, viewId]); - - const initialPersistedLayout = useMemo(() => { - if (!storageKey || typeof window === 'undefined') { - return new Map(); - } - try { - const raw = window.localStorage.getItem(storageKey); - if (!raw) { - return new Map(); - } - const parsed = JSON.parse(raw); - if (!parsed || typeof parsed !== 'object') { - return new Map(); - } - const map = new Map(); - Object.entries(parsed).forEach(([docId, value]) => { - if (!value || typeof value !== 'object') { - return; - } - const centerX = Number(value.centerX); - const centerY = Number(value.centerY); - if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) { - return; - } - const rotation = Number.isFinite(Number(value.rotation)) ? Number(value.rotation) : 0; - const z = Number.isFinite(Number(value.z)) ? Number(value.z) : undefined; - map.set(String(docId), { - centerX, - centerY, - rotation, - z, - }); - }); - return map; - } catch (error) { - console.warn('[desk] Failed to parse persisted layout', error); - return new Map(); - } - }, [storageKey]); - - const persistedLayoutRef = useRef(initialPersistedLayout); - - useEffect(() => { - persistedLayoutRef.current = initialPersistedLayout; - }, [initialPersistedLayout]); + const persistedLayoutRef = useRef(new Map()); const markLayoutDirty = useCallback(() => { layoutDirtyRef.current = true; @@ -1167,8 +1117,8 @@ const recalcVisibleDocIds = useCallback(() => { ]); const persistLayoutSnapshot = useCallback( - (snapshot, force = false) => { - if (!storageKey || typeof window === 'undefined') { + async (snapshot, force = false) => { + if (!tenantId || !viewId) { return; } if (!force && !layoutDirtyRef.current) { @@ -1190,21 +1140,25 @@ const recalcVisibleDocIds = useCallback(() => { merged.set(docId, { centerX, centerY, rotation, z }); }); - const payload = {}; + persistedLayoutRef.current = merged; + + const records = []; merged.forEach((entry, docId) => { if (!docId || !entry) { return; } - payload[docId] = entry; + records.push({ + documentId: docId, + centerX: entry.centerX, + centerY: entry.centerY, + rotation: entry.rotation ?? 0, + zIndex: entry.z ?? 0, + }); }); - try { - window.localStorage.setItem(storageKey, JSON.stringify(payload)); - persistedLayoutRef.current = merged; - } catch (error) { - console.warn('[desk] Failed to persist desk layout', error); - } + + await upsertLayoutRecords({ tenantId, viewId, entries: records }); }, - [storageKey], + [tenantId, viewId], ); const syncLayoutSnapshot = useCallback((force = false) => { @@ -1250,6 +1204,47 @@ const recalcVisibleDocIds = useCallback(() => { observer.observe(container); return () => observer.disconnect(); }, []); + useEffect(() => { + if (!tenantId || !viewId) { + return; + } + let cancelled = false; + + const loadLayouts = async () => { + const records = await fetchLayoutRecords({ tenantId, viewId }); + if (cancelled) { + return; + } + const map = new Map(); + records.forEach((record) => { + if (!record || !record.documentId) { + return; + } + map.set(String(record.documentId), { + centerX: Number(record.centerX) || 0, + centerY: Number(record.centerY) || 0, + rotation: Number(record.rotation) || 0, + z: Number(record.zIndex) || 0, + }); + }); + persistedLayoutRef.current = map; + layoutDirtyRef.current = false; + if (records.length) { + zCounterRef.current = Math.max( + zCounterRef.current, + ...records.map((r) => Number(r.zIndex) || 0), + ); + } + setDocSizeVersion((value) => value + 1); + }; + + loadLayouts(); + + return () => { + cancelled = true; + }; + }, [tenantId, viewId]); + useLayoutEffect(() => { if (!containerRef.current || !canvasSize.width || !canvasSize.height) { return; @@ -1291,9 +1286,7 @@ const recalcVisibleDocIds = useCallback(() => { const persisted = docKey ? persistedLayoutRef.current.get(docKey) : null; let existing = previous.get(doc.id) || null; if (persisted) { - existing = existing - ? { ...existing, ...persisted } - : { ...persisted }; + existing = existing ? { ...existing, ...persisted } : { ...persisted }; } if (existing) { const defaultCenterX = (minCenterX + maxCenterX) / 2; @@ -1349,7 +1342,6 @@ const recalcVisibleDocIds = useCallback(() => { syncLayoutSnapshot, recalcVisibleDocIds, ]); - useEffect(() => { recalcVisibleDocIds(); }, [recalcVisibleDocIds, items.length, canvasSize.width, canvasSize.height, docSizeVersion]); From c85cff720b72dc1d204ec50bbe3978163f5c9d45 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Sun, 2 Nov 2025 23:17:59 +0100 Subject: [PATCH 05/46] on delete --- .../down.sql | 13 +++++++++++++ .../up.sql | 13 +++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 backend/migrations/202511010002_set_assigned_by_on_delete_set_null/down.sql create mode 100644 backend/migrations/202511010002_set_assigned_by_on_delete_set_null/up.sql diff --git a/backend/migrations/202511010002_set_assigned_by_on_delete_set_null/down.sql b/backend/migrations/202511010002_set_assigned_by_on_delete_set_null/down.sql new file mode 100644 index 0000000..58f08d3 --- /dev/null +++ b/backend/migrations/202511010002_set_assigned_by_on_delete_set_null/down.sql @@ -0,0 +1,13 @@ +ALTER TABLE tenant.document_tags + DROP CONSTRAINT IF EXISTS document_tags_assigned_by_fkey, + ADD CONSTRAINT document_tags_assigned_by_fkey + FOREIGN KEY (assigned_by) + REFERENCES shared.users (id) + ON DELETE NO ACTION; + +ALTER TABLE tenant.document_correspondents + DROP CONSTRAINT IF EXISTS document_correspondents_assigned_by_fkey, + ADD CONSTRAINT document_correspondents_assigned_by_fkey + FOREIGN KEY (assigned_by) + REFERENCES shared.users (id) + ON DELETE NO ACTION; diff --git a/backend/migrations/202511010002_set_assigned_by_on_delete_set_null/up.sql b/backend/migrations/202511010002_set_assigned_by_on_delete_set_null/up.sql new file mode 100644 index 0000000..cbdf1db --- /dev/null +++ b/backend/migrations/202511010002_set_assigned_by_on_delete_set_null/up.sql @@ -0,0 +1,13 @@ +ALTER TABLE tenant.document_tags + DROP CONSTRAINT IF EXISTS document_tags_assigned_by_fkey, + ADD CONSTRAINT document_tags_assigned_by_fkey + FOREIGN KEY (assigned_by) + REFERENCES shared.users (id) + ON DELETE SET NULL; + +ALTER TABLE tenant.document_correspondents + DROP CONSTRAINT IF EXISTS document_correspondents_assigned_by_fkey, + ADD CONSTRAINT document_correspondents_assigned_by_fkey + FOREIGN KEY (assigned_by) + REFERENCES shared.users (id) + ON DELETE SET NULL; From 2ee69406b60490f81c0ef695b25fc297fab1eed3 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Mon, 3 Nov 2025 00:17:00 +0100 Subject: [PATCH 06/46] stuff --- frontend/src/DesktopWorkspace.css | 33 ++ frontend/src/DesktopWorkspace.jsx | 17 ++ frontend/src/app/AppLayout.jsx | 148 +-------- frontend/src/app/useDocumentSelection.js | 4 +- frontend/src/documents/DocumentsGrid.jsx | 215 ++++++++++++- frontend/src/documents/DocumentsList.jsx | 353 ++++++++++++++-------- frontend/src/documents/DocumentsPanel.jsx | 16 +- frontend/src/documents/useInlineRename.js | 133 ++++++++ frontend/src/styles.css | 76 ++++- 9 files changed, 720 insertions(+), 275 deletions(-) create mode 100644 frontend/src/documents/useInlineRename.js diff --git a/frontend/src/DesktopWorkspace.css b/frontend/src/DesktopWorkspace.css index 6489c37..4aecad2 100644 --- a/frontend/src/DesktopWorkspace.css +++ b/frontend/src/DesktopWorkspace.css @@ -111,6 +111,39 @@ transition: transform 0.28s ease; } +.desk-item__correspondents { + position: absolute; + bottom: 0; + left: 0; + display: flex; + flex-direction: column; + gap: 0.35rem; + align-items: flex-start; + transform-origin: bottom left; + transform: translate(0.5em, -0.5em); + pointer-events: none; +} + +.desk-correspondent-chip { + pointer-events: none; + font-size: 0.82rem; + padding: 0.18rem 0.55rem; + max-width: min(16rem, 80%); + display: inline-flex; + align-items: center; + overflow: hidden; + box-shadow: 2px 2px 4px var(--shadow-faint); + background: color-mix(in oklch, var(--surface-subtle) 88%, transparent); + color: var(--muted); +} + +.desk-correspondent-chip__label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .tag-chip--draggable { user-select: none; diff --git a/frontend/src/DesktopWorkspace.jsx b/frontend/src/DesktopWorkspace.jsx index 6c50014..3df97ea 100644 --- a/frontend/src/DesktopWorkspace.jsx +++ b/frontend/src/DesktopWorkspace.jsx @@ -12,6 +12,7 @@ import { useAssetNavigator } from './hooks/useAssetNavigator'; import { ArrowLeftIcon, ArrowRightIcon, CloseIcon } from './ui/icons'; import { createDocumentsTableHeaderActions } from './documents/DocumentsPanel'; import createWorkspaceSurfaceConfig from './documents/workspaceHeader'; +import { resolveCorrespondents } from './documents/correspondents'; import DetailPanel from './detail/DetailPanel'; import { clamp, formatTransform } from './desktop/math'; import { preventAll } from './desktop/events'; @@ -2204,6 +2205,7 @@ const DesktopWorkspaceView = () => { const shouldLoad = docKey ? visibleDocIds.has(docKey) : false; const dragging = draggingId === doc.id; const tags = Array.isArray(doc.tags) ? doc.tags : []; + const correspondents = resolveCorrespondents(doc); const docTagKeys = tags .map((tag) => (tag ? tag.id : null)) .filter(Boolean); @@ -2298,6 +2300,21 @@ const DesktopWorkspaceView = () => { onNavigatorSnapshot={handleNavigatorSnapshot} shouldLoad={shouldLoad} /> + {correspondents.length > 0 && ( + + )} {tags.length > 0 && (