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; diff --git a/backend/src/schema.rs b/backend/src/schema.rs index 963a61e..eee62d9 100644 --- a/backend/src/schema.rs +++ b/backend/src/schema.rs @@ -12,6 +12,8 @@ pub mod sql_types { #[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)] #[diesel(postgres_type(name = "api_capability"))] pub struct ApiCapability; + #[diesel(postgres_type(name = "api_capability"))] + pub struct ApiCapability; } diesel::table! { @@ -205,6 +207,7 @@ diesel::table! { created_at -> Timestamptz, updated_at -> Timestamptz, capability_set_id -> Nullable, + capability_set_id -> Nullable, } } @@ -310,24 +313,31 @@ diesel::joinable!(documents -> folders (folder_id)); diesel::joinable!(documents -> tenants (tenant_id)); diesel::joinable!(folders -> tenants (tenant_id)); diesel::joinable!(jobs -> tenants (tenant_id)); +diesel::joinable!(magic_tokens -> users (user_id)); diesel::joinable!(user_sessions -> tenants (tenant_id)); diesel::joinable!(user_sessions -> users (user_id)); diesel::joinable!(tags -> tenants (tenant_id)); diesel::joinable!(user_memberships -> capability_sets (capability_set_id)); +diesel::joinable!(user_memberships -> capability_sets (capability_set_id)); diesel::joinable!(user_memberships -> tenants (tenant_id)); diesel::joinable!(user_memberships -> users (user_id)); diesel::joinable!(user_passkeys -> users (user_id)); diesel::joinable!(webauthn_challenges -> users (user_id)); +diesel::joinable!(api_tokens -> capability_sets (capability_set_id)); diesel::joinable!(api_tokens -> tenants (tenant_id)); diesel::joinable!(api_tokens -> capability_sets (capability_set_id)); diesel::joinable!(api_tokens -> users (user_id)); diesel::allow_tables_to_appear_in_same_query!( + api_tokens, + capability_set_capabilities, + capability_sets, correspondents, capability_set_capabilities, capability_sets, document_asset_objects, document_assets, + document_assets_v2, document_correspondents, document_tags, document_versions, @@ -335,12 +345,11 @@ diesel::allow_tables_to_appear_in_same_query!( folders, jobs, magic_tokens, - user_sessions, tags, tenants, user_memberships, user_passkeys, + user_sessions, users, webauthn_challenges, - api_tokens, ); diff --git a/docs/desktopworkspace.md b/docs/desktopworkspace.md new file mode 100644 index 0000000..ec49111 --- /dev/null +++ b/docs/desktopworkspace.md @@ -0,0 +1,15 @@ +# Desktop Workspace Interaction Spec + +The desktop workspace should apply the following selection and drag behaviours: + +- **Click on a non-selected card**: clear any existing selection, then select the clicked card only. +- **Click on a selected card**: keep the selection and open the detail panel for that card (no selection change). +- **Drag on a non-selected card**: clear the selection, select the dragged card, then drag that single card. +- **Drag on a selected card**: drag the entire current selection without altering which cards are selected. +- **Cmd/Ctrl + click on a non-selected card**: add that card to the existing selection. +- **Cmd/Ctrl + click on a selected card**: expand the selection by adding the stack of cards beneath the clicked card. +- **Cmd/Ctrl + drag on a non-selected card**: replace the current selection with the entire stack beneath the pointer, then drag that stack. +- **Cmd/Ctrl + drag on a selected card**: replace the current selection with the stack beneath the pointer, then drag that stack. +- **Touch long-press**: behaves like a stack-select gesture, expanding the selection to the stack under the pressed card without requiring modifier keys. + +These rules ensure the selection model remains predictable while supporting stack-aware gestures unique to the desktop workspace. diff --git a/frontend/Dockerfile b/frontend/Dockerfile index b05cba1..21efd0b 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,6 +1,9 @@ -# syntax=docker/dockerfile:1 +# syntax=docker/dockerfile:1.6 -FROM node:20-alpine AS build +ARG NODE_IMAGE=node:20-alpine +ARG NGINX_IMAGE=nginx:alpine + +FROM --platform=$BUILDPLATFORM ${NODE_IMAGE} AS build WORKDIR /app COPY package.json package-lock.json ./ @@ -9,7 +12,7 @@ RUN npm ci --no-audit --no-fund COPY . . RUN npm run build -FROM nginx:alpine +FROM ${NGINX_IMAGE} WORKDIR /usr/share/nginx/html COPY --from=build /app/dist ./ diff --git a/frontend/package.json b/frontend/package.json index d8a1450..a8b9ad3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "webpack serve --mode development --open", "build": "webpack --mode production", - "lint": "eslint src --ext .js,.jsx" + "lint": "eslint src --ext .js,.jsx", + "test:engine": "node --test tests/workspaceEngine.test.js" }, "dependencies": { "@fontsource/inter": "^5.2.8", diff --git a/frontend/src/DesktopWorkspace.jsx b/frontend/src/DesktopWorkspace.jsx deleted file mode 100644 index 0ad4b2f..0000000 --- a/frontend/src/DesktopWorkspace.jsx +++ /dev/null @@ -1,2573 +0,0 @@ -import React, { - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from 'react'; -import { createPortal } from 'react-dom'; -import { resolveDocumentAssetUrl, createAssetView } from './asset_manager'; -import { useAssetNavigator } from './hooks/useAssetNavigator'; -import { ArrowLeftIcon, ArrowRightIcon, CloseIcon } from './ui/icons'; -import { createDocumentsTableHeaderActions } from './documents/DocumentsPanel'; -import createWorkspaceSurfaceConfig from './documents/workspaceHeader'; -import DetailPanel from './detail/DetailPanel'; -import { clamp, formatTransform } from './desktop/math'; -import { preventAll } from './desktop/events'; -import useDocumentDrag from './desktop/useDocumentDrag'; -import { DesktopProvider, useDesktopContext } from './desktop/context'; -import PreviewZoomOverlay from './detail/PreviewZoomOverlay'; -import { getTagColorStyle } from './utils/colors'; -import { - isTagTransferEvent, - parseTagTransferPayload, - writeTagTransferData, -} from './documents/tagTransfer'; -import './DesktopWorkspace.css'; - -const CANVAS_PADDING = 24; -const ROTATION_RANGE = 7; -const DEFAULT_CANVAS_WIDTH = 1024; -const DEFAULT_CANVAS_HEIGHT = 680; - -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; -const DEBUG_DROP = true; - -const signedDistance = (ax, ay, bx, by, px, py) => (bx - ax) * (py - ay) - (by - ay) * (px - ax); - -const clipPolygonWithEdge = (subject, edgeStart, edgeEnd) => { - if (!subject.length) { - return []; - } - const result = []; - let prev = subject[subject.length - 1]; - let prevInside = signedDistance(edgeStart.x, edgeStart.y, edgeEnd.x, edgeEnd.y, prev.x, prev.y) >= 0; - subject.forEach((curr) => { - const currInside = signedDistance(edgeStart.x, edgeStart.y, edgeEnd.x, edgeEnd.y, curr.x, curr.y) >= 0; - if (currInside !== prevInside) { - const dx = curr.x - prev.x; - const dy = curr.y - prev.y; - const denom = (edgeEnd.x - edgeStart.x) * dy - (edgeEnd.y - edgeStart.y) * dx; - if (Math.abs(denom) > 1e-9) { - const t = ((edgeStart.x - prev.x) * dy - (edgeStart.y - prev.y) * dx) / denom; - result.push({ - x: edgeStart.x + t * (edgeEnd.x - edgeStart.x), - y: edgeStart.y + t * (edgeEnd.y - edgeStart.y), - }); - } - } - if (currInside) { - result.push(curr); - } - prev = curr; - prevInside = currInside; - }); - return result; -}; - -const clipPolygon = (subject, clipShape) => { - if (!subject.length) { - return []; - } - let output = subject; - let prev = clipShape[clipShape.length - 1]; - for (let index = 0; index < clipShape.length; index += 1) { - const curr = clipShape[index]; - output = clipPolygonWithEdge(output, prev, curr); - if (!output.length) { - return []; - } - prev = curr; - } - return output; -}; - -const isPointInsideConvex = (point, polygon) => { - if (!polygon.length) { - return false; - } - let prev = polygon[polygon.length - 1]; - for (let index = 0; index < polygon.length; index += 1) { - const curr = polygon[index]; - if (signedDistance(prev.x, prev.y, curr.x, curr.y, point.x, point.y) < -1e-6) { - return false; - } - prev = curr; - } - return true; -}; - -const polygonCentroid = (polygon) => { - let x = 0; - let y = 0; - polygon.forEach((point) => { - x += point.x; - y += point.y; - }); - const count = polygon.length || 1; - return { - x: x / count, - y: y / count, - }; -}; - -const DesktopPreviewCard = ({ - doc, - title, - ensureAssetUrl, - getDocumentAsset, - prefetch = 3, - onNavigatorSnapshot, - shouldLoad = true, -}) => { - const navigator = useAssetNavigator({ - document: doc, - assetType: 'preview', - ensureAssetUrl: shouldLoad ? ensureAssetUrl : null, - getAsset: getDocumentAsset, - prefetch, - }); - - const { currentUrl, cardinality, canGoPrev, canGoNext, currentMetadata, ordinal } = navigator; - const docId = doc?.id ?? null; - - const metadataWidth = Number(currentMetadata?.width); - const metadataHeight = Number(currentMetadata?.height); - - useEffect(() => { - if (!onNavigatorSnapshot || !docId) { - return undefined; - } - const snapshot = { - url: currentUrl || null, - alt: title, - canGoPrev, - canGoNext, - goPrev: navigator.goPrev, - goNext: navigator.goNext, - ordinal, - width: Number.isFinite(metadataWidth) && metadataWidth > 0 ? metadataWidth : null, - height: Number.isFinite(metadataHeight) && metadataHeight > 0 ? metadataHeight : null, - }; - onNavigatorSnapshot(docId, snapshot); - return () => onNavigatorSnapshot(docId, null); - }, [ - docId, - currentUrl, - title, - canGoPrev, - canGoNext, - ordinal, - metadataWidth, - metadataHeight, - navigator.goPrev, - navigator.goNext, - onNavigatorSnapshot, - ]); - const hasPreview = Boolean(currentUrl); - const cardClasses = ['desk-item__card']; - if (!hasPreview) cardClasses.push('desk-item__card--empty'); - const showNav = hasPreview && (cardinality > 1 || canGoPrev || canGoNext); - - return ( -
{ - if (event instanceof DragEvent) { - event.preventDefault(); - } - }} - > - {hasPreview ? ( - {title} event.preventDefault()} - /> - ) : ( -
-
DOC
-
- {title} -
-
- )} - {showNav ? ( -
- - -
- ) : null} -
- ); -}; - -const generateInitialLayout = ( - entries, - { - canvasWidth, - canvasHeight, - padding, - startZ = 0, - rotationRange = ROTATION_RANGE, - minSpacing = 48, - shelfWidth = 0, - }, -) => { - const layout = new Map(); - let currentZ = startZ; - let maxZ = startZ; - - if (!entries.length) { - return { layout, maxZ }; - } - - const shelfOffset = Math.max(shelfWidth, 0); - const spacingBuffer = Math.max(minSpacing, 0); - const placed = []; - - const resolveBounds = (width, height) => { - const halfWidth = width / 2; - const halfHeight = height / 2; - return { - minCenterX: padding + halfWidth, - maxCenterX: Math.max( - padding + halfWidth, - canvasWidth - shelfOffset - padding - halfWidth, - ), - minCenterY: padding + halfHeight, - maxCenterY: Math.max(padding + halfHeight, canvasHeight - padding - halfHeight), - }; - }; - - const evaluateCandidateSpacing = (x, y, radius) => { - if (!placed.length) { - return Number.POSITIVE_INFINITY; - } - let best = Number.POSITIVE_INFINITY; - for (let i = 0; i < placed.length; i += 1) { - const item = placed[i]; - const dx = item.x - x; - const dy = item.y - y; - const distance = Math.sqrt(dx * dx + dy * dy) - item.radius - radius - spacingBuffer; - if (distance < best) { - best = distance; - } - } - return best; - }; - - entries.forEach((entry) => { - const width = Number(entry.width) || 0; - const height = Number(entry.height) || 0; - if (!entry.id || width <= 0 || height <= 0) { - return; - } - - const { minCenterX, maxCenterX, minCenterY, maxCenterY } = resolveBounds(width, height); - - const radius = Math.sqrt(width * width + height * height) / 2; - - let bestScore = -Infinity; - let bestX = (minCenterX + maxCenterX) / 2; - let bestY = (minCenterY + maxCenterY) / 2; - const samplesPerAxis = 14; - for (let gx = 0; gx < samplesPerAxis; gx += 1) { - const fracX = (gx + 0.5) / samplesPerAxis; - for (let gy = 0; gy < samplesPerAxis; gy += 1) { - const fracY = (gy + 0.5) / samplesPerAxis; - const candidateX = minCenterX + fracX * (maxCenterX - minCenterX); - const candidateY = minCenterY + fracY * (maxCenterY - minCenterY); - const edgeSpacing = Math.min( - candidateX - minCenterX, - maxCenterX - candidateX, - candidateY - minCenterY, - maxCenterY - candidateY, - ) - spacingBuffer * 0.5; - if (edgeSpacing <= 0) { - continue; - } - const neighborSpacing = evaluateCandidateSpacing(candidateX, candidateY, radius); - const score = Math.min(edgeSpacing, neighborSpacing); - if (score > bestScore) { - bestScore = score; - bestX = candidateX; - bestY = candidateY; - } - } - } - const centerX = clamp(bestX, minCenterX, maxCenterX); - const centerY = clamp(bestY, minCenterY, maxCenterY); - - const rotation = randomRangeFromSeed( - buildKey(entry.id, 'rotation'), - -rotationRange, - rotationRange, - ); - - currentZ += 1; - layout.set(entry.id, { - centerX, - centerY, - rotation, - z: currentZ, - width, - height, - }); - maxZ = Math.max(maxZ, currentZ); - - placed.push({ x: centerX, y: centerY, radius }); - }); - - return { layout, maxZ }; -}; - -const createDragPreview = (node, clientX, clientY) => { - if (!(node instanceof HTMLElement)) { - return null; - } - const rect = node.getBoundingClientRect(); - const safeClientX = Number.isFinite(clientX) ? clientX : rect.left + rect.width / 2; - const safeClientY = Number.isFinite(clientY) ? clientY : rect.top + rect.height / 2; - const offsetX = clamp(safeClientX - rect.left, 0, rect.width); - const offsetY = clamp(safeClientY - rect.top, 0, rect.height); - const clone = node.cloneNode(true); - clone.style.position = 'absolute'; - clone.style.top = '-9999px'; - clone.style.left = '-9999px'; - clone.style.pointerEvents = 'none'; - clone.style.opacity = '1'; - clone.style.transform = 'none'; - document.body.appendChild(clone); - return { clone, offsetX, offsetY }; -}; - -const cleanupPreview = (previewNode) => { - if (previewNode && previewNode.parentNode) { - previewNode.parentNode.removeChild(previewNode); - } -}; - -const usePreviewMetadata = (documents, getDocumentAsset, ensureAssetUrl) => { - const [metadataMap, setMetadataMap] = useState(() => new Map()); - - useEffect(() => { - let cancelled = false; - const docs = Array.isArray(documents) ? documents : []; - if (!docs.length) { - setMetadataMap(new Map()); - return () => { - cancelled = true; - }; - } - - const fetchMetadataForDoc = async (doc) => { - if (!doc?.id) { - return null; - } - - const docId = String(doc.id); - const resolveAsset = (type) => (typeof getDocumentAsset === 'function' ? getDocumentAsset(doc, type) : null); - - let asset = resolveAsset('preview') || resolveAsset('thumbnail'); - let view = createAssetView(asset); - let metadata = view.getPrimaryMetadata(); - - const hasDimensions = (meta) => - Number.isFinite(Number(meta?.width)) && Number.isFinite(Number(meta?.height)) && - Number(meta.width) > 0 && Number(meta.height) > 0; - - if (!hasDimensions(metadata) && ensureAssetUrl && docId && asset?.id) { - try { - const ensured = await ensureAssetUrl(doc.id, asset, { start: 1, limit: 1 }); - if (ensured) { - asset = ensured; - view = createAssetView(asset); - metadata = view.getPrimaryMetadata(); - } - } catch (error) { - console.warn('[desk] ensureDocumentSize metadata fetch failed', error); - } - } - - if (!hasDimensions(metadata)) { - return null; - } - - const width = Number(metadata.width); - const height = Number(metadata.height); - if (!Number.isFinite(width) || !Number.isFinite(height)) { - return null; - } - - return [docId, { width, height }]; - }; - - Promise.all(docs.map((doc) => fetchMetadataForDoc(doc))) - .then((entries) => { - if (cancelled) return; - const next = new Map(); - entries.forEach((entry) => { - if (entry) { - next.set(entry[0], entry[1]); - } - }); - setMetadataMap(next); - }) - .catch(() => { - if (!cancelled) { - setMetadataMap(new Map()); - } - }); - - return () => { - cancelled = true; - }; - }, [documents, getDocumentAsset, ensureAssetUrl]); - - return metadataMap; -}; - -function seededRandom(input) { - const text = String(input); - let hash = 2166136261; - for (let index = 0; index < text.length; index += 1) { - hash ^= text.charCodeAt(index); - hash = Math.imul(hash, 16777619); - } - return (hash >>> 0) / 4294967295; -} - -function randomRangeFromSeed(seedKey, min, max) { - const span = max - min; - if (span <= 0) return min; - const seed = seededRandom(seedKey); - return min + seed * span; -} - -function buildKey(docId, suffix) { - return `${docId}::${suffix}`; -} - -const clampCardDimensions = (width, height) => { - const w = Number(width); - const h = Number(height); - - if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) { - return null; - } - - const low = Math.max(CARD_MIN / w, CARD_MIN / h); - const high = Math.min(CARD_MAX / w, CARD_MAX / h); - - const candidates = []; - const addCandidate = (scale) => { - if (Number.isFinite(scale) && scale > 0) { - candidates.push(scale); - } - }; - - addCandidate(1); - addCandidate(low); - addCandidate(high); - - const best = candidates.reduce((acc, scale) => { - const scaledWidth = w * scale; - const scaledHeight = h * scale; - const violation = Math.max( - Math.max(CARD_MIN - scaledWidth, 0), - Math.max(scaledWidth - CARD_MAX, 0), - Math.max(CARD_MIN - scaledHeight, 0), - Math.max(scaledHeight - CARD_MAX, 0), - ); - const deviation = Math.abs(scale - 1); - if (!acc || violation < acc.violation || (violation === acc.violation && deviation < acc.deviation)) { - return { scale, violation, deviation }; - } - return acc; - }, null); - - const scale = best ? best.scale : 1; - return { - width: Math.round(w * scale), - height: Math.round(h * scale), - }; -}; - -const computeFallbackCardSize = (docId) => { - const baseSeed = seededRandom(`${docId}:fallback-size`); - const aspectSeed = seededRandom(`${docId}:fallback-aspect`); - - const width = CARD_MIN + baseSeed * (CARD_MAX - CARD_MIN); - const isPortrait = aspectSeed < 0.5; - const normalizedSeed = isPortrait ? aspectSeed / 0.5 : (aspectSeed - 0.5) / 0.5; - const aspectRange = 0.75; // keeps generated ratio pleasant but varied - const aspect = isPortrait - ? 1 + normalizedSeed * aspectRange - : 1 / (1 + normalizedSeed * aspectRange); - const height = width * aspect; - - return clampCardDimensions(width, height); -}; - -const DesktopWorkspace = ({ - documents = [], - searchResults = null, - onDocumentOpen, - onInspectDocument = null, - onDocumentPointerSelect = null, - onDocumentStackSelect = null, - onAssignTagToDocument = null, - onRemoveTagFromDocument = null, - ensureAssetUrl = null, - getDocumentAsset = () => null, - activeTagIds = [], - selectedDocumentIds = [], - onClearSelection = null, - detailPanelOpen = false, - onCloseDetailPanel = null, - helpOpen = false, - onHelpClose = null, - tenantId = null, -}) => { - const items = useMemo(() => (searchResults ? searchResults : documents), [documents, searchResults]); - - - const containerRef = useRef(null); - const layoutRef = useRef(new Map()); - const itemRefs = useRef(new Map()); - const zCounterRef = useRef(10); - const layoutDirtyRef = useRef(false); - - const [layoutSnapshot, setLayoutSnapshot] = useState(() => new Map()); - - const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 }); - const [visibleDocIds, setVisibleDocIds] = useState(() => new Set()); - const [draggingId, setDraggingId] = useState(null); - const [overlayDocId, setOverlayDocId] = useState(null); - const [overlayOriginRect, setOverlayOriginRect] = useState(null); - const [overlayOriginTransform, setOverlayOriginTransform] = useState(null); - const [previewSnapshots, setPreviewSnapshots] = useState(() => new Map()); - const [docSizeVersion, setDocSizeVersion] = useState(0); - const [tagDropTargetId, setTagDropTargetId] = useState(null); - const [pendingTagDocId, setPendingTagDocId] = useState(null); - const [pendingRemovalTag, setPendingRemovalTag] = useState(null); - const draggingTagRef = useRef(null); - const pendingDocTagDragRef = useRef(null); - const docSizeMapRef = useRef(new Map()); - const documentLookupRef = useRef(new Map()); - const removalCursorActiveRef = useRef(false); - const previewMetadata = usePreviewMetadata(items, getDocumentAsset, ensureAssetUrl); - const documentLookup = useMemo(() => { - const map = new Map(); - items.forEach((doc) => { - const key = doc?.id != null ? String(doc.id) : null; - if (key) { - map.set(key, doc); - } - }); - return map; - }, [items]); - - useEffect(() => { - documentLookupRef.current = documentLookup; - }, [documentLookup]); - - const storageKey = useMemo(() => { - if (!tenantId) { - return null; - } - return `papercrate.desk-layout.${tenantId}`; - }, [tenantId]); - - 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 markLayoutDirty = useCallback(() => { - layoutDirtyRef.current = true; - }, []); - - const applySnapshotDimensions = useCallback((docKey, snapshot) => { - const width = Number(snapshot?.width); - const height = Number(snapshot?.height); - if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { - return; - } - const normalized = clampCardDimensions(width, height); - if (!normalized) { - return; - } - const existing = docSizeMapRef.current.get(docKey); - if (existing && existing.width === normalized.width && existing.height === normalized.height) { - return; - } - const next = new Map(docSizeMapRef.current); - next.set(docKey, { ...normalized, source: 'snapshot' }); - docSizeMapRef.current = next; - setDocSizeVersion((value) => value + 1); - }, []); - const handleNavigatorSnapshot = useCallback( - (docId, snapshot) => { - const docKey = docId != null ? String(docId) : null; - if (!docKey) { - return; - } - - setPreviewSnapshots((previous) => { - const prevSnapshot = previous.get(docKey); - if (!snapshot) { - if (!previous.has(docKey)) { - return previous; - } - const next = new Map(previous); - next.delete(docKey); - return next; - } - - const next = new Map(previous); - const sameSnapshot = - prevSnapshot && - prevSnapshot.url === snapshot.url && - prevSnapshot.alt === snapshot.alt && - prevSnapshot.canGoPrev === snapshot.canGoPrev && - prevSnapshot.canGoNext === snapshot.canGoNext && - prevSnapshot.goPrev === snapshot.goPrev && - prevSnapshot.goNext === snapshot.goNext && - prevSnapshot.ordinal === snapshot.ordinal && - prevSnapshot.width === snapshot.width && - prevSnapshot.height === snapshot.height; - if (sameSnapshot) { - return previous; - } - next.set(docKey, snapshot); - return next; - }); - - if (snapshot) { - applySnapshotDimensions(docKey, snapshot); - } - }, - [applySnapshotDimensions], - ); - const activeTagSet = useMemo(() => { - if (!Array.isArray(activeTagIds) || activeTagIds.length === 0) { - return new Set(); - } - const set = new Set(); - activeTagIds.forEach((id) => { - if (id != null) { - set.add(String(id)); - } - }); - return set; - }, [activeTagIds]); - - const resolvePreviewDimensions = useCallback( - (doc) => { - if (!doc?.id) { - return null; - } - return previewMetadata.get(String(doc.id)) || null; - }, - [previewMetadata], - ); - - useEffect(() => { - if (!ensureAssetUrl) { - return; - } - - visibleDocIds.forEach((docId) => { - const doc = documentLookup.get(docId); - if (!doc) { - return; - } - resolveDocumentAssetUrl(doc, 'preview', { - ensureAssetUrl, - getAsset: getDocumentAsset, - }); - }); - }, [visibleDocIds, ensureAssetUrl, getDocumentAsset, documentLookup]); - - const requestCanvasFocus = useCallback(() => { - const canvas = containerRef.current; - if (!canvas || typeof canvas.focus !== 'function') { - return; - } - - const focusTarget = () => { - try { - if (DEBUG_FOCUS) { - console.log('[desk] focusCanvas -> attempting focus', canvas); - } - canvas.focus({ preventScroll: true }); - if (DEBUG_FOCUS) { - console.log('[desk] focusCanvas: applied focus. activeElement:', document?.activeElement); - } - } catch (error) { - if (DEBUG_FOCUS) { - console.warn('[desk] focusTarget failed to focus canvas', error); - } - } - }; - - if (typeof window === 'undefined') { - focusTarget(); - return; - } - - if (DEBUG_FOCUS) { - console.log('[desk] requestCanvasFocus -> scheduling deferred focus'); - } - - if (typeof window.requestAnimationFrame === 'function') { - window.requestAnimationFrame(() => { - if (DEBUG_FOCUS) { - console.log('[desk] requestCanvasFocus -> executing deferred focus (rAF)'); - } - focusTarget(); - }); - } else { - setTimeout(() => { - if (DEBUG_FOCUS) { - console.log('[desk] requestCanvasFocus -> executing deferred focus (timeout)'); - } - focusTarget(); - }, 0); - } - }, []); - - const updateRemovalCursor = useCallback((active) => { - if (typeof document === 'undefined') { - return; - } - if (removalCursorActiveRef.current === active) { - return; - } - const body = document.body; - if (!body) { - return; - } - removalCursorActiveRef.current = active; - if (active) { - body.classList.add('desk-cursor-remove'); - } else { - body.classList.remove('desk-cursor-remove'); - } - }, []); - - useEffect( - () => () => { - updateRemovalCursor(false); - }, - [updateRemovalCursor], - ); - - const isTagTransfer = useCallback((event) => isTagTransferEvent(event), []); - - const handleTagDragEnd = useCallback(() => { - updateRemovalCursor(false); - setTagDropTargetId(null); - }, [updateRemovalCursor]); - - const finalizeTagDrag = useCallback( - (dropEffect = 'none') => { - const state = draggingTagRef.current; - if (!state) { - updateRemovalCursor(false); - return; - } - - draggingTagRef.current = null; - - const node = state.element; - const showNode = () => { - if (node instanceof HTMLElement) { - node.classList.remove('is-drag-hidden'); - } - }; - const scheduleShowNode = () => { - if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') { - window.requestAnimationFrame(showNode); - } else { - setTimeout(showNode, 0); - } - }; - - cleanupPreview(state.previewClone); - - const shouldRemove = - !state.dropHandled && - dropEffect === 'none' && - state.sourceDocId && - typeof onRemoveTagFromDocument === 'function' && - (state.distance || 0) >= TAG_REMOVE_DISTANCE; - - if (!shouldRemove) { - scheduleShowNode(); - updateRemovalCursor(false); - return; - } - - updateRemovalCursor(false); - setPendingRemovalTag({ docId: state.sourceDocId, tagId: state.tagId }); - void (async () => { - try { - await onRemoveTagFromDocument(state.sourceDocId, state.tagId); - if (DEBUG_DROP) { - console.log('[desk] finalizeTagDrag -> removed tag due to fling'); - } - } catch (error) { - console.error('Failed to remove tag after drag', error); - scheduleShowNode(); - } finally { - setPendingRemovalTag(null); - } - })(); - }, - [onRemoveTagFromDocument, updateRemovalCursor, setPendingRemovalTag], - ); - - const ensureDocumentSize = useCallback((doc) => { - if (!doc?.id) { - return null; - } - return docSizeMapRef.current.get(String(doc.id)) || null; - }, []); - - useEffect(() => { - const current = docSizeMapRef.current; - const next = new Map(current); - const itemKeys = new Set(items.filter((doc) => doc?.id != null).map((doc) => String(doc.id))); - let changed = false; - - items.forEach((doc) => { - if (!doc?.id) { - return; - } - const key = String(doc.id); - const existing = next.get(key) || null; - const meta = previewMetadata.get(key); - if (meta) { - const normalized = clampCardDimensions(meta.width, meta.height); - if (normalized) { - if (existing?.source === 'snapshot') { - return; - } - if (!existing || existing.width !== normalized.width || existing.height !== normalized.height || existing.source !== 'metadata') { - next.set(key, { ...normalized, source: 'metadata' }); - changed = true; - } - return; - } - } - - if (!existing) { - const fallback = computeFallbackCardSize(key); - if (fallback) { - next.set(key, { ...fallback, source: 'fallback' }); - changed = true; - } - } - }); - - current.forEach((_, key) => { - if (!itemKeys.has(key)) { - next.delete(key); - changed = true; - } - }); - - if (changed) { - docSizeMapRef.current = next; - setDocSizeVersion((value) => value + 1); - } - }, [items, previewMetadata]); - - const overlayDisplay = useMemo(() => { - if (!overlayDocId) { - return null; - } - const snapshot = previewSnapshots.get(overlayDocId); - if (!snapshot || !snapshot.url) { - return null; - } - const doc = documentLookup.get(overlayDocId); - const alt = snapshot.alt || doc?.title; - return { - url: snapshot.url, - alt, - canGoPrev: snapshot.canGoPrev, - canGoNext: snapshot.canGoNext, - goPrev: snapshot.goPrev, - goNext: snapshot.goNext, - }; - }, [overlayDocId, previewSnapshots, documentLookup]); - - const closeOverlay = useCallback(() => { - setOverlayDocId(null); - setOverlayOriginRect(null); - setOverlayOriginTransform(null); - }, []); - - useEffect(() => { - if (overlayDocId && !documentLookup.has(overlayDocId)) { - setOverlayDocId(null); - setOverlayOriginRect(null); - setOverlayOriginTransform(null); - } - }, [overlayDocId, documentLookup]); - - const resolveBaseMetrics = useCallback( - (doc, cardWidth, cardHeight) => { - const previewDims = doc ? resolvePreviewDimensions(doc) : null; - if (previewDims?.width && previewDims?.height) { - const baseWidth = Math.max(previewDims.width, cardWidth); - const baseHeight = Math.max(previewDims.height, cardHeight); - const scaleX = cardWidth / baseWidth; - const scaleY = cardHeight / baseHeight; - const baseScale = Math.min(scaleX, scaleY, 1); - return { - baseWidth, - baseHeight, - baseScale: Number.isFinite(baseScale) && baseScale > 0 ? baseScale : 1, - }; - } - return { - baseWidth: cardWidth, - baseHeight: cardHeight, - baseScale: 1, - }; - }, - [resolvePreviewDimensions], - ); -const recalcVisibleDocIds = useCallback(() => { - const layoutMap = layoutRef.current; - const canvasWidth = canvasSize.width || DEFAULT_CANVAS_WIDTH; - const canvasHeight = canvasSize.height || DEFAULT_CANVAS_HEIGHT; - - if (!layoutMap.size || canvasWidth <= 0 || canvasHeight <= 0) { - setVisibleDocIds((prev) => (prev.size ? new Set() : prev)); - return; - } - - const viewport = [ - { x: 0, y: 0 }, - { x: canvasWidth, y: 0 }, - { x: canvasWidth, y: canvasHeight }, - { x: 0, y: canvasHeight }, - ]; - - const entries = []; - layoutMap.forEach((entry, rawId) => { - const docKey = rawId != null ? String(rawId) : null; - if (!docKey) { - return; - } - const doc = documentLookup.get(docKey); - if (!doc) { - return; - } - const sizeInfo = ensureDocumentSize(doc); - if (!sizeInfo) { - return; - } - const { width: cardWidth, height: cardHeight } = sizeInfo; - const rotationDeg = Number(entry?.rotation) || 0; - const rotationRad = (rotationDeg * Math.PI) / 180; - const cosRot = Math.cos(rotationRad); - const sinRot = Math.sin(rotationRad); - const halfWidth = cardWidth / 2; - const halfHeight = cardHeight / 2; - const localCorners = [ - { x: -halfWidth, y: -halfHeight }, - { x: halfWidth, y: -halfHeight }, - { x: halfWidth, y: halfHeight }, - { x: -halfWidth, y: halfHeight }, - ]; - const centerX = entry?.centerX ?? CANVAS_PADDING + cardWidth / 2; - const centerY = entry?.centerY ?? CANVAS_PADDING + cardHeight / 2; - const corners = localCorners.map(({ x, y }) => ({ - x: centerX + x * cosRot - y * sinRot, - y: centerY + x * sinRot + y * cosRot, - })); - const clipped = clipPolygon(corners, viewport); - if (!clipped.length) { - return; - } - entries.push({ - key: docKey, - z: entry?.z ?? 0, - polygon: clipped, - }); - }); - - if (!entries.length) { - setVisibleDocIds((prev) => (prev.size ? new Set() : prev)); - return; - } - - entries.sort((a, b) => (b.z || 0) - (a.z || 0)); - - const visiblePolygons = []; - const result = new Set(); - - entries.forEach(({ key, polygon }) => { - if (polygon.length < 3) { - return; - } - - let fullyCovered = true; - for (let i = 0; i < polygon.length; i += 1) { - const point = polygon[i]; - const inside = visiblePolygons.some((poly) => isPointInsideConvex(point, poly)); - if (!inside) { - fullyCovered = false; - break; - } - } - - if (fullyCovered) { - const centroid = polygonCentroid(polygon); - if (!visiblePolygons.some((poly) => isPointInsideConvex(centroid, poly))) { - fullyCovered = false; - } - } - - if (!fullyCovered) { - result.add(key); - visiblePolygons.push(polygon); - } - }); - - setVisibleDocIds((prev) => { - if (prev.size === result.size) { - let same = true; - prev.forEach((id) => { - if (!result.has(id)) { - same = false; - } - }); - if (same) { - result.forEach((id) => { - if (!prev.has(id)) { - same = false; - } - }); - } - if (same) { - return prev; - } - } - return result; - }); - }, [ - canvasSize.width, - canvasSize.height, - ensureDocumentSize, - documentLookup, - ]); - - const persistLayoutSnapshot = useCallback( - (snapshot, force = false) => { - if (!storageKey || typeof window === 'undefined') { - return; - } - if (!force && !layoutDirtyRef.current) { - return; - } - layoutDirtyRef.current = false; - const payload = {}; - snapshot.forEach((entry, docId) => { - if (!docId || !entry) { - return; - } - const centerX = Number(entry.centerX); - const centerY = Number(entry.centerY); - 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, - }; - }); - try { - window.localStorage.setItem(storageKey, JSON.stringify(payload)); - persistedLayoutRef.current = new Map( - Object.entries(payload).map(([id, value]) => [id, value]), - ); - } catch (error) { - console.warn('[desk] Failed to persist desk layout', error); - } - }, - [storageKey], - ); - - const syncLayoutSnapshot = useCallback((force = false) => { - const snapshot = new Map(layoutRef.current); - setLayoutSnapshot(snapshot); - persistLayoutSnapshot(snapshot, force); - }, [persistLayoutSnapshot]); - - useLayoutEffect(() => { - const container = containerRef.current; - if (!container) return () => {}; - - const nodeEnv = typeof globalThis !== 'undefined' ? globalThis.process?.env?.NODE_ENV : undefined; - if (nodeEnv !== 'production') { - console.log('[desk] canvas element', container); - } - - const commitSize = () => { - const rect = container.getBoundingClientRect(); - const width = Math.floor(rect.width) || 0; - const height = Math.floor(rect.height) || 0; - setCanvasSize((prev) => { - if (prev.width === width && prev.height === height) { - return prev; - } - return { width, height }; - }); - }; - - commitSize(); - - if (typeof ResizeObserver === 'undefined') { - window.addEventListener('resize', commitSize); - return () => { - window.removeEventListener('resize', commitSize); - }; - } - - const observer = new ResizeObserver(() => { - commitSize(); - }); - - observer.observe(container); - return () => observer.disconnect(); - }, []); - useLayoutEffect(() => { - if (!containerRef.current || !canvasSize.width || !canvasSize.height) { - return; - } - - if (!items.length) { - layoutRef.current = new Map(); - syncLayoutSnapshot(); - return; - } - - const missingSizes = items.some((doc) => !ensureDocumentSize(doc)); - if (missingSizes) { - return; - } - - const previous = layoutRef.current; - const next = new Map(); - let maxZ = zCounterRef.current; - const canvasWidth = canvasSize.width || DEFAULT_CANVAS_WIDTH; - const canvasHeight = canvasSize.height || DEFAULT_CANVAS_HEIGHT; - const docsNeedingLayout = []; - - items.forEach((doc) => { - const sizeInfo = ensureDocumentSize(doc); - if (!sizeInfo) { - return; - } - const { width: docWidth, height: docHeight } = sizeInfo; - const halfWidth = docWidth / 2; - const halfHeight = docHeight / 2; - - const minCenterX = CANVAS_PADDING + halfWidth; - const maxCenterX = Math.max(minCenterX, canvasWidth - CANVAS_PADDING - halfWidth); - const minCenterY = CANVAS_PADDING + halfHeight; - const maxCenterY = Math.max(minCenterY, canvasHeight - CANVAS_PADDING - halfHeight); - - const docKey = doc?.id != null ? String(doc.id) : null; - const persisted = docKey ? persistedLayoutRef.current.get(docKey) : null; - let existing = previous.get(doc.id) || null; - if (persisted) { - existing = existing - ? { ...existing, ...persisted } - : { ...persisted }; - } - if (existing) { - const defaultCenterX = (minCenterX + maxCenterX) / 2; - const defaultCenterY = (minCenterY + maxCenterY) / 2; - const prevCenterX = typeof existing.centerX === 'number' ? existing.centerX : defaultCenterX; - const prevCenterY = typeof existing.centerY === 'number' ? existing.centerY : defaultCenterY; - const centerX = clamp(prevCenterX, minCenterX, maxCenterX); - const centerY = clamp(prevCenterY, minCenterY, maxCenterY); - const rotation = existing.rotation ?? 0; - const z = existing.z ?? maxZ; - maxZ = Math.max(maxZ, z); - next.set(doc.id, { centerX, centerY, rotation, z, width: docWidth, height: docHeight }); - return; - } - - docsNeedingLayout.push({ - id: doc.id, - width: docWidth, - height: docHeight, - seedKey: doc.id, - }); - }); - - if (docsNeedingLayout.length) { - const { layout: generatedLayout, maxZ: updatedMaxZ } = generateInitialLayout( - docsNeedingLayout, - { - canvasWidth, - canvasHeight, - padding: CANVAS_PADDING, - startZ: maxZ, - rotationRange: ROTATION_RANGE, - minSpacing: 48, - shelfWidth: 0, - }, - ); - generatedLayout.forEach((entry, docId) => { - next.set(docId, entry); - }); - maxZ = Math.max(maxZ, updatedMaxZ); - } - - layoutRef.current = next; - zCounterRef.current = Math.max(zCounterRef.current, maxZ); - syncLayoutSnapshot(); - recalcVisibleDocIds(); - }, [ - items, - canvasSize.width, - canvasSize.height, - docSizeVersion, - ensureDocumentSize, - syncLayoutSnapshot, - recalcVisibleDocIds, - ]); - - useEffect(() => { - recalcVisibleDocIds(); - }, [recalcVisibleDocIds, items.length, canvasSize.width, canvasSize.height, docSizeVersion]); - - useEffect(() => { - if (draggingId && !items.some((doc) => doc.id === draggingId)) { - setDraggingId(null); - } - }, [draggingId, items]); - - const bringToFront = useCallback( - (docId) => { - zCounterRef.current += 1; - const entry = layoutRef.current.get(docId); - if (!entry) return; - const updated = { ...entry, z: zCounterRef.current }; - layoutRef.current.set(docId, updated); - markLayoutDirty(); - syncLayoutSnapshot(); - recalcVisibleDocIds(); - }, - [syncLayoutSnapshot, recalcVisibleDocIds, markLayoutDirty], - ); - - const openOverlayForDoc = useCallback( - (docId, originInfo = null) => { - if (!docId) { - return; - } - const docKey = String(docId); - const snapshot = previewSnapshots.get(docKey); - if (!snapshot || !snapshot.url) { - return; - } - const container = itemRefs.current.get(docId); - const imageNode = container?.querySelector?.('.desk-item__card img'); - if (!container || !imageNode) { - return; - } - const rect = imageNode.getBoundingClientRect(); - let originTransform = null; - if (originInfo) { - const { rotation = 0, scale = 1, width: originWidth, height: originHeight } = originInfo; - originTransform = { - rotation, - scaleX: scale, - scaleY: scale, - baseWidth: originWidth, - baseHeight: originHeight, - }; - } - if (!originTransform) { - const entry = layoutRef.current.get(docId) || null; - const doc = documentLookup.get(docKey) || null; - const sizeInfo = ensureDocumentSize(doc); - if (!sizeInfo) { - return; - } - const { width: cardWidth, height: cardHeight } = sizeInfo; - const { baseWidth, baseHeight, baseScale } = resolveBaseMetrics(doc, cardWidth, cardHeight); - const effectiveWidth = baseWidth * baseScale; - const effectiveHeight = baseHeight * baseScale; - originTransform = { - rotation: entry?.rotation ?? 0, - scaleX: baseScale, - scaleY: baseScale, - baseWidth: Number.isFinite(effectiveWidth) && effectiveWidth > 0 ? effectiveWidth : cardWidth, - baseHeight: Number.isFinite(effectiveHeight) && effectiveHeight > 0 ? effectiveHeight : cardHeight, - }; - } - bringToFront(docId); - setOverlayOriginRect(rect); - setOverlayOriginTransform(originTransform); - setOverlayDocId(docKey); - }, - [ - bringToFront, - previewSnapshots, - itemRefs, - setOverlayOriginTransform, - ensureDocumentSize, - resolveBaseMetrics, - documentLookup, - layoutRef, - ], - ); - const handleTagDragEnterDoc = useCallback( - (event, docId) => { - if (!isTagTransfer(event)) { - return; - } - preventAll(event); - updateRemovalCursor(false); - if (tagDropTargetId !== docId) { - setTagDropTargetId(docId); - } - }, - [isTagTransfer, tagDropTargetId, updateRemovalCursor], - ); - - const handleTagDragOverDoc = useCallback( - (event, docId) => { - if (!isTagTransfer(event)) { - return; - } - preventAll(event); - updateRemovalCursor(false); - const activeDrag = draggingTagRef.current; - event.dataTransfer.dropEffect = activeDrag?.sourceDocId ? 'move' : 'copy'; - if (tagDropTargetId !== docId) { - setTagDropTargetId(docId); - } - }, - [isTagTransfer, tagDropTargetId, updateRemovalCursor], - ); - - const handleTagDragLeaveDoc = useCallback((event, docId) => { - if (!isTagTransfer(event)) { - return; - } - preventAll(event); - if ( - event.currentTarget instanceof HTMLElement && - event.relatedTarget instanceof Node && - event.currentTarget.contains(event.relatedTarget) - ) { - return; - } - setTagDropTargetId((current) => (current === docId ? null : current)); - updateRemovalCursor(false); - }, [isTagTransfer, updateRemovalCursor]); - - const markActiveTagDropHandled = useCallback((tagId, sourceDocId = null) => { - const state = draggingTagRef.current; - if (!state) { - return; - } - if (state.tagId !== tagId) { - return; - } - if (sourceDocId && state.sourceDocId !== sourceDocId) { - return; - } - state.dropHandled = true; - }, []); - - const handleTagDropOnDoc = useCallback( - async (event, doc) => { - if (!doc || !isTagTransfer(event) || typeof onAssignTagToDocument !== 'function') { - if (DEBUG_DROP) { - console.log('[desk] handleTagDropOnDoc: drop ignored', { doc, hasTransfer: isTagTransfer(event) }); - } - return; - } - preventAll(event); - if (DEBUG_DROP) { - console.log('[desk] handleTagDropOnDoc: drop accepted for doc', doc.id, 'event', event); - } - setTagDropTargetId(null); - - const payload = parseTagTransferPayload(event); - if (!payload && DEBUG_DROP) { - console.warn('[desk] handleTagDropOnDoc: failed to parse payload'); - } - - if (!payload?.id) { - if (DEBUG_DROP) { - console.log('[desk] handleTagDropOnDoc: missing tag id payload', payload); - } - return; - } - - const tagId = payload.id; - const sourceDocId = payload.sourceDocId || null; - if (DEBUG_DROP) { - console.log('[desk] handleTagDropOnDoc: parsed payload', { tagId, sourceDocId }); - } - - if (sourceDocId && sourceDocId === doc.id) { - markActiveTagDropHandled(tagId, sourceDocId); - if (DEBUG_DROP) { - console.log('[desk] handleTagDropOnDoc: drop from same doc ignored', tagId); - } - return; - } - - const alreadyAssigned = Array.isArray(doc.tags) - ? doc.tags.some((tag) => tag.id === tagId) - : false; - if (alreadyAssigned) { - markActiveTagDropHandled(tagId, sourceDocId); - if (DEBUG_DROP) { - console.log('[desk] handleTagDropOnDoc: tag already assigned', tagId); - } - return; - } - - const movingBetweenDocuments = Boolean(sourceDocId && sourceDocId !== doc.id); - if (DEBUG_DROP) { - console.log('[desk] handleTagDropOnDoc: movingBetweenDocuments', movingBetweenDocuments); - } - - setPendingTagDocId(doc.id); - try { - await onAssignTagToDocument({ documentId: doc.id, tagId, tag: payload }); - markActiveTagDropHandled(tagId, sourceDocId); - if (DEBUG_DROP) { - console.log('[desk] handleTagDropOnDoc: assigned tag', tagId, 'to doc', doc.id); - } - if (movingBetweenDocuments && typeof onRemoveTagFromDocument === 'function') { - setPendingRemovalTag({ docId: sourceDocId, tagId }); - try { - await onRemoveTagFromDocument(sourceDocId, tagId); - if (DEBUG_DROP) { - console.log('[desk] handleTagDropOnDoc: removed tag from source doc', sourceDocId); - } - } finally { - setPendingRemovalTag(null); - } - } - } finally { - setPendingTagDocId(null); - if (DEBUG_DROP) { - console.log('[desk] handleTagDropOnDoc: finalizing drop for tag', tagId); - } - handleTagDragEnd(); - finalizeTagDrag(payload?.sourceDocId ? 'move' : 'copy'); - } - }, - [ - isTagTransfer, - markActiveTagDropHandled, - onAssignTagToDocument, - onRemoveTagFromDocument, - handleTagDragEnd, - finalizeTagDrag, - ], - ); - - const handleCanvasDragOver = useCallback( - (event) => { - if (!isTagTransfer(event)) { - return; - } - preventAll(event); - event.dataTransfer.dropEffect = 'move'; - updateRemovalCursor(true); - setTagDropTargetId(null); - }, - [isTagTransfer, updateRemovalCursor], - ); - - const handleCanvasDragLeave = useCallback( - (event) => { - if (!isTagTransfer(event)) { - return; - } - preventAll(event); - if ( - event.currentTarget instanceof HTMLElement && - event.relatedTarget instanceof Node && - event.currentTarget.contains(event.relatedTarget) - ) { - return; - } - updateRemovalCursor(false); - }, - [isTagTransfer, updateRemovalCursor], - ); - - const handleCanvasDrop = useCallback( - async (event) => { - if (!isTagTransfer(event)) { - return; - } - preventAll(event); - updateRemovalCursor(false); - requestCanvasFocus(); - setTagDropTargetId(null); - - const payload = parseTagTransferPayload(event); - if (!payload?.id) { - return; - } - - const tagId = payload.id; - const sourceDocId = payload.sourceDocId || null; - markActiveTagDropHandled(tagId, sourceDocId); - - if (!sourceDocId || typeof onRemoveTagFromDocument !== 'function') { - return; - } - - setPendingRemovalTag({ docId: sourceDocId, tagId }); - try { - await onRemoveTagFromDocument(sourceDocId, tagId); - } catch (error) { - console.error('Failed to remove tag via canvas drop', error); - } finally { - setPendingRemovalTag(null); - } - }, - [ - isTagTransfer, - markActiveTagDropHandled, - onRemoveTagFromDocument, - requestCanvasFocus, - updateRemovalCursor, - ], - ); - - const handleDocTagPointerDown = useCallback((event, doc, tag) => { - event.stopPropagation(); - if (!doc || !tag) { - pendingDocTagDragRef.current = null; - return; - } - const startX = Number.isFinite(event.clientX) - ? event.clientX - : Number.isFinite(event.pageX) - ? event.pageX - : 0; - const startY = Number.isFinite(event.clientY) - ? event.clientY - : Number.isFinite(event.pageY) - ? event.pageY - : 0; - pendingDocTagDragRef.current = { - docId: doc.id, - tagId: tag.id, - startX, - startY, - }; - updateRemovalCursor(false); - }, [updateRemovalCursor]); - - const handleDocTagDragStart = useCallback( - (event, doc, tag) => { - if (!doc || !tag) { - return; - } - event.stopPropagation(); - try { - if (event.dataTransfer) { - event.dataTransfer.effectAllowed = 'copyMove'; - } - } catch (error) { - console.warn('[desk] Failed to set drag effect', error); - } - - writeTagTransferData(event.dataTransfer, tag, doc.id); - - const pending = pendingDocTagDragRef.current; - const node = event.currentTarget; - const startX = Number.isFinite(event.clientX) - ? event.clientX - : Number.isFinite(event.pageX) - ? event.pageX - : 0; - const startY = Number.isFinite(event.clientY) - ? event.clientY - : Number.isFinite(event.pageY) - ? event.pageY - : 0; - const initialX = - pending && pending.docId === doc.id && pending.tagId === tag.id && Number.isFinite(pending.startX) - ? pending.startX - : startX; - const initialY = - pending && pending.docId === doc.id && pending.tagId === tag.id && Number.isFinite(pending.startY) - ? pending.startY - : startY; - pendingDocTagDragRef.current = null; - let preview = null; - if (node instanceof HTMLElement) { - preview = createDragPreview(node, event.clientX, event.clientY); - if (preview && event.dataTransfer) { - try { - event.dataTransfer.setDragImage(preview.clone, preview.offsetX, preview.offsetY); - } catch (error) { - console.warn('[desk] Failed to set drag image', error); - } - } - } - draggingTagRef.current = { - sourceDocId: doc.id, - tagId: tag.id, - tagLabel: tag.label, - startX: initialX, - startY: initialY, - distance: 0, - element: node instanceof HTMLElement ? node : null, - dropHandled: false, - hasPosition: Number.isFinite(initialX) && Number.isFinite(initialY), - previewClone: preview?.clone || null, - }; - - const hideNode = () => { - if (draggingTagRef.current?.element === node) { - node.classList.add('is-drag-hidden'); - } - }; - if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') { - window.requestAnimationFrame(hideNode); - } else { - setTimeout(hideNode, 0); - } - }, - [], - ); - - const handleDocTagDrag = useCallback( - (event) => { - const state = draggingTagRef.current; - if (!state) { - updateRemovalCursor(false); - return; - } - if (!state.hasPosition && Number.isFinite(event.clientX) && Number.isFinite(event.clientY)) { - state.startX = event.clientX; - state.startY = event.clientY; - state.hasPosition = true; - } - const clientX = Number.isFinite(event.clientX) ? event.clientX : state.startX; - const clientY = Number.isFinite(event.clientY) ? event.clientY : state.startY; - const deltaX = clientX - state.startX; - const deltaY = clientY - state.startY; - const distance = Math.hypot(deltaX, deltaY); - if (Number.isFinite(distance)) { - state.distance = distance; - } - const removalActive = - Boolean(state.sourceDocId) && - !tagDropTargetId && - Number.isFinite(state.distance) && - state.distance >= TAG_REMOVE_DISTANCE; - updateRemovalCursor(removalActive); - }, - [tagDropTargetId, updateRemovalCursor], - ); - - const handleDocTagDragEnd = useCallback( - (event) => { - handleTagDragEnd(); - finalizeTagDrag(event?.dataTransfer?.dropEffect || 'none'); - }, - [handleTagDragEnd, finalizeTagDrag], - ); - - const contextValue = useMemo( - () => ({ - layoutRef, - itemRefs, - documentLookup, - ensureDocumentSize, - resolveBaseMetrics, - bringToFront, - setDraggingId, - syncLayoutSnapshot, - canvasSize, - openOverlayForDoc, - recalcVisibleDocIds, - settings: { - canvasPadding: CANVAS_PADDING, - defaultCanvasWidth: DEFAULT_CANVAS_WIDTH, - defaultCanvasHeight: DEFAULT_CANVAS_HEIGHT, - debugDrag: DEBUG_DRAG, - }, - items, - containerRef, - handleCanvasDragOver, - handleCanvasDragLeave, - handleCanvasDrop, - layoutSnapshot, - docSizeVersion, - visibleDocIds, - draggingId, - tagDropTargetId, - pendingTagDocId, - pendingRemovalTag, - onDocumentOpen, - onInspectDocument, - onDocumentPointerSelect, - onDocumentStackSelect, - ensureAssetUrl, - getDocumentAsset, - handleNavigatorSnapshot, - activeTagSet, - handleTagDragEnterDoc, - handleTagDragOverDoc, - handleTagDragLeaveDoc, - handleTagDropOnDoc, - handleDocTagPointerDown, - handleDocTagDragStart, - handleDocTagDrag, - handleDocTagDragEnd, - overlayDisplay, - closeOverlay, - overlayOriginRect, - overlayOriginTransform, - markLayoutDirty, - selectedDocumentIds, - onClearSelection, - detailPanelOpen, - onCloseDetailPanel, - }), - [ - activeTagSet, - bringToFront, - canvasSize, - closeOverlay, - containerRef, - draggingId, - ensureAssetUrl, - ensureDocumentSize, - getDocumentAsset, - handleCanvasDragLeave, - handleCanvasDragOver, - handleCanvasDrop, - handleDocTagDrag, - handleDocTagDragEnd, - handleDocTagDragStart, - handleDocTagPointerDown, - handleNavigatorSnapshot, - handleTagDragEnterDoc, - handleTagDragLeaveDoc, - handleTagDragOverDoc, - handleTagDropOnDoc, - itemRefs, - items, - layoutRef, - layoutSnapshot, - docSizeVersion, - onDocumentOpen, - onInspectDocument, - onDocumentPointerSelect, - openOverlayForDoc, - overlayDisplay, - overlayOriginRect, - overlayOriginTransform, - pendingRemovalTag, - pendingTagDocId, - recalcVisibleDocIds, - resolveBaseMetrics, - setDraggingId, - syncLayoutSnapshot, - tagDropTargetId, - visibleDocIds, - documentLookup, - selectedDocumentIds, - onClearSelection, - onDocumentStackSelect, - markLayoutDirty, - detailPanelOpen, - onCloseDetailPanel, - ], - ); - - return ( - - - - - ); -}; - -const DesktopWorkspaceView = () => { - const { - items, - containerRef, - handleCanvasDragOver, - handleCanvasDragLeave, - handleCanvasDrop, - ensureDocumentSize, - layoutSnapshot, - layoutRef, - itemRefs, - visibleDocIds, - draggingId, - tagDropTargetId, - pendingTagDocId, - pendingRemovalTag, - onDocumentOpen, - ensureAssetUrl, - getDocumentAsset, - handleNavigatorSnapshot, - activeTagSet, - handleTagDragEnterDoc, - handleTagDragOverDoc, - handleTagDragLeaveDoc, - handleTagDropOnDoc, - handleDocTagPointerDown, - handleDocTagDragStart, - handleDocTagDrag, - handleDocTagDragEnd, - overlayDisplay, - closeOverlay, - overlayOriginRect, - overlayOriginTransform, - onDocumentPointerSelect, - onDocumentStackSelect, - selectedDocumentIds, - onClearSelection, - detailPanelOpen, - onCloseDetailPanel, - documentLookup, - } = useDesktopContext(); - - const { handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel } = - useDocumentDrag(); - - const resolveStackDocIds = useCallback( - (event, targetDocId = null) => { - const container = containerRef.current; - if (!container || !event) { - return []; - } - - const rect = container.getBoundingClientRect(); - const pointerCanvasX = event.clientX - rect.left; - const pointerCanvasY = event.clientY - rect.top; - - if (!Number.isFinite(pointerCanvasX) || !Number.isFinite(pointerCanvasY)) { - return []; - } - - const hits = []; - items.forEach((doc) => { - if (!doc?.id) { - return; - } - const layout = layoutSnapshot.get(doc.id) ?? layoutRef.current.get(doc.id); - if (!layout) { - return; - } - const sizeInfo = ensureDocumentSize(doc); - if (!sizeInfo) { - return; - } - const { width, height } = sizeInfo; - if (!width || !height) { - return; - } - - if (activeTagSet.size) { - const docTagKeys = Array.isArray(doc.tags) - ? doc.tags.map((tag) => (tag ? tag.id : null)).filter(Boolean) - : []; - if (!docTagKeys.some((key) => activeTagSet.has(key))) { - return; - } - } - - const centerX = Number(layout.centerX); - const centerY = Number(layout.centerY); - if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) { - return; - } - - const rotationDeg = Number(layout.rotation) || 0; - const rotationRad = (rotationDeg * Math.PI) / 180; - const dx = pointerCanvasX - centerX; - const dy = pointerCanvasY - centerY; - const cosRotation = Math.cos(-rotationRad); - const sinRotation = Math.sin(-rotationRad); - const localX = dx * cosRotation - dy * sinRotation; - const localY = dx * sinRotation + dy * cosRotation; - const halfWidth = width / 2; - const halfHeight = height / 2; - - if ( - 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, - }); - } - } - }); - - if (!hits.length) { - return []; - } - - hits.sort((a, b) => (b.z ?? 0) - (a.z ?? 0)); - - const targetKey = targetDocId != null ? String(targetDocId) : hits[0].id; - const orderedIds = hits.map((entry) => entry.id); - - if (targetKey) { - const targetIndex = orderedIds.indexOf(targetKey); - if (targetIndex > 0) { - const [targetEntry] = orderedIds.splice(targetIndex, 1); - orderedIds.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; - }, - [ - activeTagSet, - ensureDocumentSize, - items, - layoutRef, - layoutSnapshot, - containerRef, - documentLookup, - ], - ); - - const allSizesReady = items.every((doc) => ensureDocumentSize(doc)); - - useEffect(() => { - if (typeof window === 'undefined' || typeof onClearSelection !== 'function') { - return undefined; - } - - const handleKeyDown = (event) => { - if (!event || event.defaultPrevented) { - return; - } - - const key = event.key; - const spacePressed = key === ' ' || key === 'Space' || key === 'Spacebar'; - if (!spacePressed) { - return; - } - - const target = event.target; - if (target instanceof HTMLElement) { - const tag = target.tagName ? target.tagName.toLowerCase() : ''; - if ( - target.isContentEditable - || tag === 'input' - || tag === 'textarea' - || tag === 'select' - || tag === 'button' - ) { - return; - } - } - - const hasSelection = Array.isArray(selectedDocumentIds) && selectedDocumentIds.length > 0; - if (hasSelection) { - event.preventDefault(); - onClearSelection(); - return; - } - - if (detailPanelOpen && typeof onCloseDetailPanel === 'function') { - event.preventDefault(); - onCloseDetailPanel(); - } - }; - - window.addEventListener('keydown', handleKeyDown, true); - return () => window.removeEventListener('keydown', handleKeyDown, true); - }, [ - onClearSelection, - selectedDocumentIds, - detailPanelOpen, - onCloseDetailPanel, - ]); - - return ( - <> -
{ - if (event.target === event.currentTarget && typeof onClearSelection === 'function') { - onClearSelection(); - } - }} - > -
{ - if (event.target === event.currentTarget && typeof onClearSelection === 'function') { - onClearSelection(); - } - }} - > - {!allSizesReady ? ( -
-

Loading previews…

-
- ) : items.length === 0 ? ( -
-

No documents to show here yet. Drop files to make this space come alive.

-
- ) : ( - items.map((doc) => { - const sizeInfo = ensureDocumentSize(doc); - if (!sizeInfo) { - return null; - } - const { width: cardWidth, height: cardHeight } = sizeInfo; - const layout = layoutSnapshot.get(doc.id) ?? layoutRef.current.get(doc.id); - if ( - !layout || - !Number.isFinite(layout.centerX) || - !Number.isFinite(layout.centerY) - ) { - return null; - } - const layoutCenterX = layout.centerX; - const layoutCenterY = layout.centerY; - const rotation = layout.rotation || 0; - const originX = layoutCenterX - cardWidth / 2; - const originY = layoutCenterY - cardHeight / 2; - const transform = formatTransform( - Math.round(originX), - Math.round(originY), - rotation, - 1, - ); - const style = { - transform, - zIndex: layout.z ?? 1, - width: Math.round(cardWidth), - height: Math.round(cardHeight), - }; - const docKey = doc?.id != null ? String(doc.id) : null; - const shouldLoad = docKey ? visibleDocIds.has(docKey) : false; - const dragging = draggingId === doc.id; - const tags = Array.isArray(doc.tags) ? doc.tags : []; - const docTagKeys = tags - .map((tag) => (tag ? tag.id : null)) - .filter(Boolean); - const matchesFilter = - activeTagSet.size === 0 || docTagKeys.some((key) => activeTagSet.has(key)); - const dropActive = tagDropTargetId === doc.id; - const dropPending = pendingTagDocId === doc.id; - const itemClasses = ['desk-item']; - if (dragging) itemClasses.push('is-dragging'); - if (dropActive) itemClasses.push('is-tag-target'); - if (dropPending) itemClasses.push('is-tag-pending'); - if (!matchesFilter) itemClasses.push('is-filtered-out'); - const isSelected = selectedDocumentIds.includes(doc.id); - if (isSelected) itemClasses.push('is-selected'); - const docTagTokens = docTagKeys.join(' '); - return ( -
{ - if (node) { - itemRefs.current.set(doc.id, node); - } else { - itemRefs.current.delete(doc.id); - } - }} - onPointerDown={(event) => { - const alreadySelected = selectedDocumentIds.includes(doc.id); - const metaOrCtrlOnly = - (event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey; - - let stackDocIds = null; - let appliedStackSelection = false; - - if (metaOrCtrlOnly) { - const hits = resolveStackDocIds(event, doc.id); - if (Array.isArray(hits) && hits.length > 0) { - stackDocIds = hits; - const hasStack = hits.length > 1; - if (hasStack && alreadySelected && typeof onDocumentStackSelect === 'function') { - onDocumentStackSelect(hits, event); - appliedStackSelection = true; - } - } - } - - const shouldInvokePointerSelect = - typeof onDocumentPointerSelect === 'function' - && ( - !metaOrCtrlOnly - || !alreadySelected - || event.shiftKey - || event.altKey - || !stackDocIds - || stackDocIds.length <= 1 - ); - - if (shouldInvokePointerSelect) { - onDocumentPointerSelect(doc.id, event); - } - - handlePointerDown(event, doc.id, { - stackDocIds, - stackSelectionApplied: appliedStackSelection, - }); - }} - onPointerMove={handlePointerMove} - onPointerUp={handlePointerUp} - onPointerCancel={handlePointerCancel} - onDragEnter={(event) => handleTagDragEnterDoc(event, doc.id)} - onDragOver={(event) => handleTagDragOverDoc(event, doc.id)} - onDragLeave={(event) => handleTagDragLeaveDoc(event, doc.id)} - onDrop={(event) => handleTagDropOnDoc(event, doc)} - onKeyDown={(event) => { - if (event.key === 'Enter' || event.key === ' ') { - preventAll(event); - onDocumentOpen?.(doc.id); - } - }} - > -
- - {tags.length > 0 && ( - - )} -
-
- ); - }) - )} -
-
- - - ); -}; - -export default DesktopWorkspace; - -const DesktopHelpOverlay = ({ open = false, onClose = null }) => { - const portalTarget = typeof document !== 'undefined' ? document.body : null; - const closeButtonRef = useRef(null); - const previousFocusRef = useRef(null); - - const handleClose = useCallback(() => { - if (typeof onClose === 'function') { - onClose(); - } - }, [onClose]); - - useEffect(() => { - if (!open || typeof window === 'undefined') { - return undefined; - } - - const handleKeyDown = (event) => { - if (!event) { - return; - } - if (event.key === 'Escape') { - event.preventDefault(); - handleClose(); - } - }; - - window.addEventListener('keydown', handleKeyDown, true); - return () => window.removeEventListener('keydown', handleKeyDown, true); - }, [open, handleClose]); - - useEffect(() => { - if (!open) { - const previous = previousFocusRef.current; - if (previous && typeof previous.focus === 'function') { - previous.focus(); - } - previousFocusRef.current = null; - return; - } - - if (typeof document !== 'undefined') { - previousFocusRef.current = document.activeElement instanceof HTMLElement - ? document.activeElement - : null; - } - - if (closeButtonRef.current && typeof closeButtonRef.current.focus === 'function') { - closeButtonRef.current.focus(); - } - }, [open]); - - if (!open || !portalTarget) { - return null; - } - - return createPortal( -
-
-
-
-

Desk view tips

- -
-
-

Use the desk as a freeform workspace for triage and quick comparisons.

-
    -
  • Single-click a document to open it in the detail panel.
  • -
  • Double-click to open the zoomed preview.
  • -
  • - Drag selected cards to reposition them; build a selection with - {' '} - Cmd/Ctrl - {' '}+ click or Shift-click. -
  • -
  • - Cmd/Ctrl + click with an empty selection scoops up the stack under - {' '}the pointer. -
  • -
  • Space clears the current selection.
  • -
  • - Drag tags from the sidebar onto a card to assign them, or fling a - {' '}tag away to remove it. -
  • -
-
-
- -
-
-
, - portalTarget, - ); -}; - -export const createDesktopSurface = ({ - workspaceProps, - renderSidebarToggle, - parentBreadcrumb, - onNavigateParent, - detailProps = null, - detailOpen = false, -}) => { - if (!workspaceProps) { - return null; - } - - const { - currentFolderName, - searchResults, - onRefresh, - viewMode, - onViewModeChange, - } = workspaceProps; - const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName; - const subtitle = Array.isArray(searchResults) - ? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}` - : null; - - const actions = createDocumentsTableHeaderActions({ - viewMode: viewMode || 'desk', - onViewModeChange, - onRefresh, - onShowDeskHelp: viewMode === 'desk' ? workspaceProps?.onOpenHelp : null, - }); - - const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null; - const detail = detailOpen && detailProps ? : null; - const surfaceConfig = createWorkspaceSurfaceConfig({ - key: 'workspace', - variant: 'workspace', - title, - subtitle, - sidebarToggle, - parentBreadcrumb, - onNavigateParent, - actions, - breadcrumbs: workspaceProps?.breadcrumbs || null, - content: , - detail, - }); - - return { - ...surfaceConfig, - supportsDetail: Boolean(detailProps), - }; -}; diff --git a/frontend/src/app/AppLayout.jsx b/frontend/src/app/AppLayout.jsx index a571aa2..ff1d38f 100644 --- a/frontend/src/app/AppLayout.jsx +++ b/frontend/src/app/AppLayout.jsx @@ -1,5031 +1,37 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { - Navigate, - Outlet, - matchPath, - useLocation, - useMatch, - useNavigate, -} from 'react-router-dom'; -import AssetManager, { - getAssetFromVersion, - resolveDocumentAssetUrl, - createAssetView, -} from '../asset_manager'; -import useApiError from '../hooks/useApiError'; -import TagManager from '../tag_manager'; -import usePasskeys from '../settings/usePasskeys'; +import React from 'react'; +import { Navigate, Outlet } from 'react-router-dom'; import { AppShellContext } from '../appShellContext'; import DropOverlay from './DropOverlay'; -import { useManagementModals } from './useManagementModals'; -import { api, useAppDispatch, useAppState } from './appState'; -import { useDetailPanel } from './useDetailPanel'; -import { useDocumentSelection } from './useDocumentSelection'; -import { isTagTransferEvent } from '../documents/tagTransfer'; - -const ASSET_PRESIGN_TTL_MS = 240 * 1000; // backend issues 5 min tokens; refresh slightly early - -const DEFAULT_FOLDER_NAME = 'Documents'; - -const ROW_KEY_SEPARATOR = ':'; -const DOCUMENT_ROW_PREFIX = 'document'; -const FOLDER_ROW_PREFIX = 'folder'; - -const resolveApiPath = (path = '') => path; - -const makeRowKey = (type, id) => - id ? `${type}${ROW_KEY_SEPARATOR}${id}` : `${type}${ROW_KEY_SEPARATOR}`; - -const getRowType = (key) => (typeof key === 'string' ? key.split(ROW_KEY_SEPARATOR, 1)[0] : ''); - -const getRowId = (key) => { - if (typeof key !== 'string') return ''; - const separatorIndex = key.indexOf(ROW_KEY_SEPARATOR); - if (separatorIndex === -1) return key; - return key.slice(separatorIndex + 1); -}; - -const isDocumentRowKey = (key) => getRowType(key) === DOCUMENT_ROW_PREFIX; -const isFolderRowKey = (key) => getRowType(key) === FOLDER_ROW_PREFIX; - -const resolveDocumentRowKey = (documentId) => - documentId ? makeRowKey(DOCUMENT_ROW_PREFIX, documentId) : null; - -const resolveFolderRowKey = (folderId) => - folderId ? makeRowKey(FOLDER_ROW_PREFIX, folderId) : null; - -const hasFiles = (event) => - Array.from(event.dataTransfer?.types || []).includes('Files'); - -const isAssetEquivalent = (lhs, rhs) => { - if (!lhs || !rhs) return false; - const lhsView = createAssetView(lhs); - const rhsView = createAssetView(rhs); - const lhsPrimaryMetadata = lhsView.getPrimaryMetadata() || lhs?.metadata; - const rhsPrimaryMetadata = rhsView.getPrimaryMetadata() || rhs?.metadata; - const lhsCardinality = lhsView.getCardinality() || lhs?.cardinality || null; - const rhsCardinality = rhsView.getCardinality() || rhs?.cardinality || null; - const lhsObjects = lhsView.getObjects(); - const rhsObjects = rhsView.getObjects(); - const objectsComparable = - lhsObjects.length === rhsObjects.length - && lhsObjects.every((entry, index) => { - const other = rhsObjects[index]; - if (!other) return false; - if (entry.ordinal !== other.ordinal) return false; - if (entry.url && other.url && entry.url === other.url) { - return true; - } - if (!entry.url && !other.url) { - return JSON.stringify(entry.metadata || null) === JSON.stringify(other.metadata || null); - } - return entry.url === other.url; - }); - return ( - lhs.id === rhs.id - && lhs.url === rhs.url - && lhsPrimaryMetadata?.width === rhsPrimaryMetadata?.width - && lhsPrimaryMetadata?.height === rhsPrimaryMetadata?.height - && lhs.mime_type === rhs.mime_type - && lhs.asset_type === rhs.asset_type - && lhs.updated_at === rhs.updated_at - && lhsCardinality === rhsCardinality - && objectsComparable - ); -}; - -const mergeAssetIntoGroup = (group, assetData) => { - if (!assetData || !assetData.asset_type) { - if (Array.isArray(group)) { - return group; - } - return group || {}; - } - - if (Array.isArray(group) || !group) { - const list = Array.isArray(group) ? group : []; - const index = list.findIndex((item) => item?.id === assetData.id); - if (index >= 0) { - const existing = list[index]; - if (isAssetEquivalent(existing, assetData)) { - return list; - } - const next = list.slice(); - next[index] = { ...existing, ...assetData }; - return next; - } - return list.concat({ ...assetData }); - } - - const key = assetData.asset_type; - const previous = group?.[key]; - if (previous && isAssetEquivalent(previous, assetData)) { - return group; - } - - const next = { ...(group || {}) }; - next[key] = { ...(previous || {}), ...assetData }; - return next; -}; - -const mergeAssetIntoDocument = (doc, assetData) => { - if (!doc) return doc; - const existingGroup = doc.current_version?.assets || null; - const nextGroup = mergeAssetIntoGroup(existingGroup, assetData); - if (nextGroup === existingGroup) { - return doc; - } - const updatedCurrentVersion = doc.current_version - ? { ...doc.current_version, assets: nextGroup } - : { assets: nextGroup }; - return { ...doc, current_version: updatedCurrentVersion }; -}; - -const createRootNode = () => ({ - id: 'root', - name: DEFAULT_FOLDER_NAME, - parentId: null, - children: [], - expanded: true, - loaded: false, - hasChildren: false, -}); +import useDocumentsWorkspace from '../hooks/documents/useDocumentsWorkspace'; +import { useDocumentsPreferences } from './useDocumentsPreferences'; const AppLayout = () => { - const navigate = useNavigate(); - const location = useLocation(); - const appState = useAppState(); - const appDispatch = useAppDispatch(); - const folderMatch = matchPath('/documents/folder/:folderId', location.pathname); - const docMatch = matchPath('/documents/:documentId', location.pathname); - const routeFolderId = folderMatch?.params?.folderId || null; - const routeDocumentId = docMatch?.params?.documentId || null; - const previewDocumentId = routeDocumentId; - const { status: appStatus, token, tenant, tenants: tenantOptions = [] } = appState; - const tenantName = tenant?.name || tenant?.slug || null; - const currentTenantId = tenant?.id || null; - const [status, setStatus] = useState(null); - const setStatusMessage = useCallback((message, variant = 'info') => { - setStatus(message ? { message, variant } : null); - }, []); - const handleApiReport = useCallback( - ({ message, variant }) => setStatusMessage(message, variant), - [setStatusMessage], - ); - const reportApiError = useApiError({ - onReport: handleApiReport, - }); - const notifyApiError = useCallback( - (error, fallbackMessage, variant = 'error') => - reportApiError(error, { message: fallbackMessage, variant }), - [reportApiError], - ); - const [loading, setLoading] = useState(false); - const [creatingFolder, setCreatingFolder] = useState(false); - const [folderNodes, setFolderNodes] = useState(() => { - const rootNode = createRootNode(); - return new Map([[rootNode.id, rootNode]]); - }); - const [folderContents, setFolderContents] = useState(() => new Map()); - const [selectedFolder, setSelectedFolder] = useState(routeFolderId || 'root'); - const [currentFolder, setCurrentFolder] = useState(null); - const [currentSubfolders, setCurrentSubfolders] = useState([]); - const [documents, setDocuments] = useState([]); - const [documentsViewMode, setDocumentsViewMode] = useState(() => { - if (typeof window === 'undefined') { - return 'list'; - } - try { - const stored = window.sessionStorage.getItem('papercrate_view_mode'); - return stored === 'grid' || stored === 'desk' ? stored : 'list'; - } catch (error) { - console.warn('[view-mode] failed to read stored mode', error); - return 'list'; - } - }); - const [deskHelpOpen, setDeskHelpOpen] = useState(false); - const lastNonDeskViewRef = useRef(documentsViewMode === 'desk' ? 'list' : documentsViewMode); - - useEffect(() => { - if (documentsViewMode !== 'desk') { - lastNonDeskViewRef.current = documentsViewMode; - } - }, [documentsViewMode]); - - useEffect(() => { - if (documentsViewMode !== 'desk' && deskHelpOpen) { - setDeskHelpOpen(false); - } - }, [documentsViewMode, deskHelpOpen]); - const initialRowSelection = []; - const tokenRef = useRef(token); - const refreshPromiseRef = useRef(null); - const breadcrumbFetchRef = useRef(new Set()); - const tagRemovalCursorActiveRef = useRef(false); - const tenantIdRef = useRef(currentTenantId); - const detailPanelControlRef = useRef({ open: () => {}, close: () => {} }); - const setTagRemovalCursor = useCallback((active) => { - if (typeof document === 'undefined') { - return; - } - if (tagRemovalCursorActiveRef.current === active) { - return; - } - const body = document.body; - if (!body) { - return; - } - tagRemovalCursorActiveRef.current = active; - if (active) { - body.classList.add('desk-cursor-remove'); - } else { - body.classList.remove('desk-cursor-remove'); - } - }, []); - const refreshAccessToken = useCallback(async () => { - console.log('[Auth] Attempting to refresh access token…'); - appDispatch({ type: 'TOKEN_REFRESH_START' }); - try { - const { data } = await api.post('/auth/refresh'); - if (data?.access_token) { - appDispatch({ - type: 'TOKEN_REFRESH_SUCCESS', - token: data.access_token, - tenant: data.tenant || null, - }); - console.log('[Auth] Access token refreshed at', new Date().toISOString()); - return data.access_token; - } - throw new Error('Missing access token in refresh response'); - } catch (error) { - console.warn('[Auth] Failed to refresh access token', error); - appDispatch({ type: 'TOKEN_REFRESH_FAILURE', error: error?.message || null }); - throw error; - } - }, [appDispatch]); - const [searchResults, setSearchResults] = useState(null); - const [previewEntries, setPreviewEntries] = useState(() => new Map()); - const previewInflightRef = useRef(new Map()); - const previewReturnPathRef = useRef(null); - const [tags, setTags] = useState([]); - const [correspondents, setCorrespondents] = useState([]); - const [searchQuery, setSearchQuery] = useState(''); - const [activeTagFilters, setActiveTagFilters] = useState([]); - const [activeCorrespondentFilters, setActiveCorrespondentFilters] = useState([]); - const [searchLoading, setSearchLoading] = useState(false); - const documentsRouteMatch = useMatch('/documents'); - const documentsFolderRouteMatch = useMatch('/documents/folder/:folderId'); - const documentsDetailRouteMatch = useMatch('/documents/:documentId'); - const isDocumentsRoute = Boolean( - documentsRouteMatch || documentsFolderRouteMatch || documentsDetailRouteMatch, - ); - const toggleTagFilter = useCallback((tagId) => { - if (!tagId) return; - setActiveTagFilters((previous) => - previous.includes(tagId) - ? previous.filter((id) => id !== tagId) - : previous.concat([tagId]), - ); - }, []); - - const toggleCorrespondentFilter = useCallback((correspondentId) => { - setActiveCorrespondentFilters((previous) => { - if (!correspondentId) { - return []; - } - return previous.includes(correspondentId) ? [] : [correspondentId]; - }); - }, []); - - const initialRefreshAttemptedRef = useRef(Boolean(token)); - - useEffect(() => { - if (!token && !initialRefreshAttemptedRef.current && appStatus === 'logged-out') { - initialRefreshAttemptedRef.current = true; - console.log('[Auth] Attempting refresh at startup'); - refreshAccessToken().catch(() => {}); - } - }, [token, appStatus, refreshAccessToken]); - - const clearFilters = useCallback(() => { - setSearchQuery(''); - setActiveTagFilters([]); - setActiveCorrespondentFilters([]); - setSearchLoading(false); - }, []); - - const handleSearchChange = useCallback((value) => { - setSearchQuery(value); - }, []); - - const handleSearchSubmit = useCallback(() => { - if (!navigate) return; - const targetFolder = selectedFolder && selectedFolder !== 'root' ? selectedFolder : 'root'; - const targetPath = targetFolder === 'root' ? '/documents' : `/documents/folder/${targetFolder}`; - if (!isDocumentsRoute || location.pathname !== targetPath) { - navigate(targetPath, { replace: false }); - } - }, [navigate, selectedFolder, isDocumentsRoute, location.pathname]); - const [draggedDocumentIds, setDraggedDocumentIds] = useState([]); - const [draggedFolderId, setDraggedFolderId] = useState(null); - const [dropOverlayState, setDropOverlayState] = useState({ - active: false, - folderName: DEFAULT_FOLDER_NAME, - }); - const [activePreviewId, setActivePreviewId] = useState(routeDocumentId || null); - const shellRef = useRef(null); - const assetManagerRef = useRef(null); - if (!assetManagerRef.current) { - assetManagerRef.current = new AssetManager({ api, assetPresignTtlMs: ASSET_PRESIGN_TTL_MS }); - } - const assetManager = assetManagerRef.current; - - const extractDocumentFromResponse = useCallback( - (payload) => { - if (!payload) { - return null; - } - const hydratedDetail = assetManager.hydrateDetail(payload); - return hydratedDetail?.document || payload.document || payload; - }, - [assetManager], - ); - - const tagManagerRef = useRef(null); - if (!tagManagerRef.current) { - tagManagerRef.current = new TagManager(); - } - const tagManager = tagManagerRef.current; - + const documentsPreferences = useDocumentsPreferences(); const { - selectedEntries, - setSelectedEntries, - selectionOrder, - setSelectionOrder, - selectionOrderRef, - selectionAnchorRef, - selectionInitializedRef, - focusedDocumentId, - setFocusedDocumentId, - focusedRowKey, - setFocusedRowKey, - applySelection, - clearSelection: clearSelectionInternal, - handleRowSelection: handleRowSelectionInternal, - promoteSelectionOrder: promoteSelectionOrderInternal, - configureSelectionEnvironment, - } = useDocumentSelection({ - resolveDocumentRowKey, - resolveFolderRowKey, - isDocumentRowKey, - isFolderRowKey, - getRowId, - initialEntries: initialRowSelection, - }); - - const getDocumentAsset = useCallback((doc, type) => { - if (!doc || !type) return null; - return getAssetFromVersion(doc.current_version || null, type); - }, []); - - const bootstrapInitializedRef = useRef(false); - const dragCounterRef = useRef(0); - const detailFolderFetchRef = useRef(new Set()); - - const selectedDocumentIds = useMemo( - () => - selectedEntries - .filter(isDocumentRowKey) - .map((key) => getRowId(key)) - .filter(Boolean), - [selectedEntries], - ); - - const selectedFolderIds = useMemo( - () => - selectedEntries - .filter(isFolderRowKey) - .map((key) => getRowId(key)) - .filter(Boolean), - [selectedEntries], - ); - - - const resetWorkspaceState = useCallback(() => { - const rootNode = createRootNode(); - setFolderNodes(new Map([[rootNode.id, rootNode]])); - setFolderContents(new Map()); - setSelectedFolder('root'); - setCurrentFolder(null); - setCurrentSubfolders([]); - setDocuments([]); - setSelectedEntries([]); - setSelectionOrder([]); - selectionOrderRef.current = []; - setFocusedDocumentId(null); - selectionAnchorRef.current = null; - setDraggedDocumentIds([]); - setDraggedFolderId(null); - setSearchResults(null); - setTags([]); - setCorrespondents([]); - setSearchQuery(''); - setActiveTagFilters([]); - setActiveCorrespondentFilters([]); - setDropOverlayState({ active: false, folderName: DEFAULT_FOLDER_NAME }); - setActivePreviewId(null); - detailPanelControlRef.current.close(); - assetManager.reset(); - setPreviewEntries(() => new Map()); - previewInflightRef.current = new Map(); - dragCounterRef.current = 0; - breadcrumbFetchRef.current = new Set(); - detailFolderFetchRef.current = new Set(); - bootstrapInitializedRef.current = false; - selectionInitializedRef.current = false; - tenantIdRef.current = null; - }, [ - assetManager, - selectionAnchorRef, - selectionInitializedRef, - selectionOrderRef, - setFocusedDocumentId, - setSelectedEntries, - setSelectionOrder, - ]); - - const tagLookupById = useMemo(() => { - const map = new Map(); - tags.forEach((tag) => { - if (tag?.id) { - map.set(tag.id, tag); - } - }); - return map; - }, [tags]); - - const correspondentLookupByName = useMemo(() => { - const map = new Map(); - correspondents.forEach((correspondent) => { - if (correspondent?.name) { - map.set(correspondent.name.toLowerCase(), correspondent); - } - }); - return map; - }, [correspondents]); - useEffect(() => { - if (appStatus === 'logged-out' || appStatus === 'selecting-tenant') { - resetWorkspaceState(); - } - }, [appStatus, resetWorkspaceState]); - - useEffect(() => { - tokenRef.current = token; - }, [token]); - - useEffect(() => { - tenantIdRef.current = currentTenantId; - }, [currentTenantId]); - - useEffect(() => { - const requestInterceptor = api.interceptors.request.use((config) => { - const currentToken = tokenRef.current; - if (currentToken) { - config.headers = config.headers || {}; - if (!config.headers.Authorization) { - config.headers.Authorization = `Bearer ${currentToken}`; - } - } - return config; - }); - - const responseInterceptor = api.interceptors.response.use( - (response) => response, - async (error) => { - const { response, config } = error; - if (!response || !config) { - return Promise.reject(error); - } - - const status = response.status; - const url = typeof config.url === 'string' ? config.url : ''; - const isAuthRoute = url.includes('/auth/login') || url.includes('/auth/refresh'); - - if (status === 401 && !config._retry && !isAuthRoute) { - console.warn('[Auth] 401 received for', url, '- attempting token refresh'); - - if (!refreshPromiseRef.current) { - refreshPromiseRef.current = (async () => { - try { - return await refreshAccessToken(); - } finally { - refreshPromiseRef.current = null; - } - })(); - } - - try { - const newToken = await refreshPromiseRef.current; - if (!newToken) { - throw new Error('No token returned from refresh'); - } - config._retry = true; - config.headers = config.headers || {}; - config.headers.Authorization = `Bearer ${newToken}`; - console.log('[Auth] Retrying original request', url); - try { - return await api(config); - } catch (retryError) { - if (retryError?.response?.status === 401) { - notifyApiError(retryError, 'Session expired. Please log in again.'); - } - throw retryError; - } - } catch (refreshError) { - console.warn('[Auth] Refresh failed, clearing session'); - notifyApiError(refreshError, 'Session expired. Please log in again.'); - return Promise.reject(refreshError); - } - } - - return Promise.reject(error); - }, - ); - - return () => { - api.interceptors.request.eject(requestInterceptor); - api.interceptors.response.eject(responseInterceptor); - }; - }, [notifyApiError, refreshAccessToken]); - - useEffect(() => { - if (!selectedDocumentIds.length) { - return; - } - if (!selectedDocumentIds.includes(activePreviewId)) { - setActivePreviewId(selectedDocumentIds[selectedDocumentIds.length - 1]); - } - selectionInitializedRef.current = true; - }, [selectedDocumentIds, activePreviewId, selectionInitializedRef]); - - const currentFolderName = useMemo(() => { - if (selectedFolder === 'root' || !currentFolder) return DEFAULT_FOLDER_NAME; - return currentFolder.name; - }, [selectedFolder, currentFolder]); - - const isFilterActive = useMemo( - () => - searchQuery.trim().length > 0 || - activeTagFilters.length > 0 || - activeCorrespondentFilters.length > 0, - [searchQuery, activeTagFilters, activeCorrespondentFilters], - ); - - const applySelectedFolder = useCallback( - (folderId, contents) => { - const subfolders = contents?.subfolders ?? []; - const docs = assetManager.hydrateDocuments(contents?.documents ?? []); - const folderInfo = contents?.folder ?? null; - - setCurrentSubfolders(subfolders); - setDocuments(docs); - setCurrentFolder(folderInfo); - - const availableDocKeys = docs - .map((doc) => resolveDocumentRowKey(doc.id)) - .filter(Boolean); - const availableDocKeySet = new Set(availableDocKeys); - const availableFolderKeys = new Set( - subfolders - .map((folder) => resolveFolderRowKey(folder.id)) - .filter(Boolean), - ); - - let nextDocKeys = []; - let mergedSelection = []; - - setSelectedEntries((previous) => { - const previousFolderKeys = previous - .filter(isFolderRowKey) - .filter((key) => availableFolderKeys.has(key)); - const previousDocKeys = previous.filter(isDocumentRowKey); - - if (selectionInitializedRef.current) { - nextDocKeys = previousDocKeys.filter((key) => availableDocKeySet.has(key)); - } else { - nextDocKeys = previousDocKeys.filter((key) => availableDocKeySet.has(key)); - } - - mergedSelection = [...previousFolderKeys, ...nextDocKeys]; - return mergedSelection; - }); - - const nextFocus = (() => { - const currentFocusedKey = resolveDocumentRowKey(focusedDocumentId); - if (currentFocusedKey && availableDocKeySet.has(currentFocusedKey)) { - return focusedDocumentId; - } - if (nextDocKeys.length) { - const lastDocKey = nextDocKeys[nextDocKeys.length - 1]; - return getRowId(lastDocKey) || null; - } - return null; - })(); - - setFocusedDocumentId(nextFocus); - selectionAnchorRef.current = nextDocKeys.length - ? nextDocKeys[nextDocKeys.length - 1] - : null; - selectionOrderRef.current = mergedSelection; - setSelectionOrder(mergedSelection); - - return nextFocus; - }, - [ - assetManager, - focusedDocumentId, - selectionAnchorRef, - selectionInitializedRef, - selectionOrderRef, - setFocusedDocumentId, - setSelectedEntries, - setSelectionOrder, - ], - ); - - const showingSearchResults = searchResults !== null; - - const visibleDocuments = useMemo( - () => (showingSearchResults ? searchResults : documents), - [showingSearchResults, searchResults, documents], - ); - - const visibleDocumentIds = useMemo( - () => visibleDocuments.map((doc) => doc.id), - [visibleDocuments], - ); - - const visibleDocumentKeys = useMemo( - () => visibleDocumentIds.map((id) => resolveDocumentRowKey(id)).filter(Boolean), - [visibleDocumentIds], - ); - - const visibleFolderKeys = useMemo( - () => - showingSearchResults - ? [] - : currentSubfolders - .map((folder) => resolveFolderRowKey(folder.id)) - .filter(Boolean), - [showingSearchResults, currentSubfolders], - ); - - const visibleRowKeys = useMemo( - () => [...visibleFolderKeys, ...visibleDocumentKeys], - [visibleFolderKeys, visibleDocumentKeys], - ); - - const visibleRowKeySet = useMemo( - () => new Set(visibleRowKeys), - [visibleRowKeys], - ); - - const documentLookup = useMemo(() => { - const map = new Map(); - const push = (items) => { - (items || []).forEach((doc) => { - if (doc?.id) { - map.set(doc.id, doc); - } - }); - }; - - push(documents); - if (Array.isArray(searchResults)) { - push(searchResults); - } - return map; - }, [documents, searchResults]); - - const mapDocumentCaches = useCallback( - (mapper) => { - if (typeof mapper !== 'function') { - return; - } - - const applyToList = (list) => { - let changed = false; - const next = list.map((doc) => { - const updated = mapper(doc); - if (updated === undefined || updated === doc) { - return doc; - } - changed = true; - return updated; - }); - return changed ? next : list; - }; - - setDocuments((prev) => applyToList(prev)); - setSearchResults((prev) => { - if (!Array.isArray(prev)) { - return prev; - } - return applyToList(prev); - }); - setFolderContents((prev) => { - if (!prev.size) { - return prev; - } - let changed = false; - const next = new Map(); - prev.forEach((contents, key) => { - const docs = Array.isArray(contents?.documents) ? contents.documents : null; - if (!docs || docs.length === 0) { - next.set(key, contents); - return; - } - let docsChanged = false; - const updatedDocs = docs.map((doc) => { - const updated = mapper(doc); - if (updated === undefined || updated === doc) { - return doc; - } - docsChanged = true; - return updated; - }); - if (docsChanged) { - changed = true; - next.set(key, { ...contents, documents: updatedDocs }); - } else { - next.set(key, contents); - } - }); - return changed ? next : prev; - }); - }, - [setDocuments, setSearchResults, setFolderContents], - ); - - const updateDocumentCaches = useCallback( - (documentId, updater) => { - if (!documentId || typeof updater !== 'function') { - return; - } - - mapDocumentCaches((doc) => { - if (!doc || doc.id !== documentId) { - return doc; - } - const updated = updater(doc); - return updated === undefined ? doc : updated; - }); - }, - [mapDocumentCaches], - ); - - const removeDocumentFromCaches = useCallback( - (documentId) => { - if (!documentId) { - return; - } - - const removeFromList = (list) => { - const next = list.filter((doc) => doc.id !== documentId); - return next.length === list.length ? list : next; - }; - - setDocuments((prev) => removeFromList(prev)); - setSearchResults((prev) => (Array.isArray(prev) ? removeFromList(prev) : prev)); - setFolderContents((prev) => { - if (!prev.size) { - return prev; - } - let changed = false; - const next = new Map(); - prev.forEach((contents, key) => { - const docs = Array.isArray(contents?.documents) ? contents.documents : null; - if (!docs || docs.length === 0) { - next.set(key, contents); - return; - } - const filteredDocs = docs.filter((doc) => doc.id !== documentId); - if (filteredDocs.length !== docs.length) { - changed = true; - next.set(key, { ...contents, documents: filteredDocs }); - } else { - next.set(key, contents); - } - }); - return changed ? next : prev; - }); - }, - [setDocuments, setSearchResults, setFolderContents], - ); - - - const folderOptions = useMemo(() => { - const cache = new Map(); - const computePath = (id) => { - if (cache.has(id)) { - return cache.get(id); - } - if (!id || id === 'root') { - cache.set('root', DEFAULT_FOLDER_NAME); - return DEFAULT_FOLDER_NAME; - } - const node = folderNodes.get(id); - if (!node) { - return 'Folder'; - } - const parentId = node.parentId || 'root'; - const parentPath = computePath(parentId); - const name = node.name || 'Folder'; - const fullPath = parentId === 'root' ? name : `${parentPath}/${name}`; - cache.set(id, fullPath); - return fullPath; - }; - - const entries = []; - folderNodes.forEach((node, id) => { - if (!node) return; - entries.push({ id, label: computePath(id) }); - }); - - entries.sort((a, b) => { - if (a.id === 'root') return -1; - if (b.id === 'root') return 1; - return a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }); - }); - - return entries; - }, [folderNodes]); - - const folderLabelMap = useMemo(() => { - const map = new Map(); - folderOptions.forEach((option) => { - map.set(option.id, option.label); - }); - return map; - }, [folderOptions]); - - const navigableRows = useMemo(() => { - const entries = []; - if (!showingSearchResults) { - currentSubfolders.forEach((folder) => { - const key = resolveFolderRowKey(folder.id); - if (key) { - entries.push({ key, type: 'folder', id: folder.id }); - } - }); - } - visibleDocuments.forEach((doc) => { - const key = resolveDocumentRowKey(doc.id); - if (key) { - entries.push({ key, type: 'document', id: doc.id }); - } - }); - return entries; - }, [showingSearchResults, currentSubfolders, visibleDocuments]); - - const navigableRowKeys = useMemo( - () => navigableRows.map((entry) => entry.key), - [navigableRows], - ); - - useEffect(() => { - configureSelectionEnvironment({ - visibleRowKeySet, - navigableRowKeys, - }); - }, [configureSelectionEnvironment, visibleRowKeySet, navigableRowKeys]); - - const handleRowSelection = useCallback( - (rowKey, event) => { - handleRowSelectionInternal(rowKey, event); - }, - [handleRowSelectionInternal], - ); - - const promoteSelectionOrder = useCallback( - (docId) => { - if (!docId) return; - promoteSelectionOrderInternal(docId); - const rowKey = resolveDocumentRowKey(docId); - if (rowKey) { - selectionAnchorRef.current = rowKey; - } - setFocusedDocumentId(docId); - setActivePreviewId(docId); - }, - [ - promoteSelectionOrderInternal, - selectionAnchorRef, - setFocusedDocumentId, - setActivePreviewId, - ], - ); - - const clearDocumentSelection = useCallback(() => { - clearSelectionInternal(); - }, [clearSelectionInternal]); - - const prevFocusedDocIdRef = useRef(focusedDocumentId); - useEffect(() => { - const previous = prevFocusedDocIdRef.current; - if (previous === focusedDocumentId) { - return; - } - prevFocusedDocIdRef.current = focusedDocumentId; - if (focusedDocumentId) { - setFocusedRowKey(resolveDocumentRowKey(focusedDocumentId)); - } else { - setFocusedRowKey((current) => (isFolderRowKey(current) ? current : null)); - } - }, [focusedDocumentId, setFocusedRowKey]); - - useEffect(() => { - if (!navigableRowKeys.length) { - if (focusedRowKey) { - setFocusedRowKey(null); - } - return; - } - - if (focusedRowKey && navigableRowKeys.includes(focusedRowKey)) { - return; - } - - const docKey = focusedDocumentId ? resolveDocumentRowKey(focusedDocumentId) : null; - if (docKey && navigableRowKeys.includes(docKey)) { - setFocusedRowKey(docKey); - return; - } - - const selectedKey = selectedEntries.find((key) => navigableRowKeys.includes(key)); - if (selectedKey) { - setFocusedRowKey(selectedKey); - return; - } - - if (focusedRowKey) { - setFocusedRowKey(null); - } - }, [ - focusedRowKey, - focusedDocumentId, - navigableRowKeys, - selectedEntries, - setFocusedRowKey, - ]); - - - const ensureFolderData = useCallback( - async ( - folderId, - { force = false, includeDocuments = true, prefetchDepth = 0 } = {}, - ) => { - const requestTenantId = tenantIdRef.current; - const cached = folderContents.get(folderId); - if (!force && cached) { - const includesDocuments = Boolean(cached.__includesDocuments); - if (!includeDocuments || includesDocuments) { - if (prefetchDepth > 0) { - const subfolders = Array.isArray(cached.subfolders) ? cached.subfolders : []; - await Promise.allSettled( - subfolders.map((entry) => - ensureFolderData(entry.id, { - includeDocuments: false, - prefetchDepth: prefetchDepth - 1, - force: false, - }), - ), - ); - } - return cached; - } - } - - const path = folderId === 'root' ? 'root' : folderId; - const params = includeDocuments - ? undefined - : { include_documents: false }; - const { data } = await api.get(`/folders/${path}/contents`, { - params, - }); - const hydrated = assetManager.hydrateFolderContents(data); - const childFolders = Array.isArray(data.subfolders) ? data.subfolders : []; - const childIds = childFolders.map((child) => child.id); - - if (tenantIdRef.current !== requestTenantId) { - return { ...hydrated, __includesDocuments: includeDocuments }; - } - - setFolderNodes((prev) => { - const next = new Map(prev); - const existingNode = next.get(folderId) || { - id: folderId, - name: folderId === 'root' ? DEFAULT_FOLDER_NAME : data.folder?.name || 'Folder', - parentId: data.folder?.parent_id || 'root', - children: [], - expanded: folderId === 'root', - loaded: false, - hasChildren: false, - }; - - next.set(folderId, { - ...existingNode, - name: folderId === 'root' ? DEFAULT_FOLDER_NAME : data.folder?.name || existingNode.name, - parentId: data.folder?.parent_id ?? existingNode.parentId ?? 'root', - children: childIds, - expanded: folderId === 'root' ? true : existingNode.expanded, - loaded: true, - hasChildren: childIds.length > 0, - }); - - childFolders.forEach((child) => { - const childNode = next.get(child.id); - const previousChildren = Array.isArray(childNode?.children) ? childNode.children : []; - const childHasChildren = (() => { - if (childNode?.loaded) { - return previousChildren.length > 0; - } - if (Array.isArray(child?.subfolders)) { - return child.subfolders.length > 0; - } - if (typeof child?.has_children === 'boolean') { - return child.has_children; - } - if (typeof child?.hasChildren === 'boolean') { - return child.hasChildren; - } - if (typeof childNode?.hasChildren === 'boolean') { - return childNode.hasChildren; - } - return false; - })(); - next.set(child.id, { - id: child.id, - name: child.name, - parentId: child.parent_id ?? 'root', - children: previousChildren, - expanded: childNode?.expanded ?? false, - loaded: childNode?.loaded ?? false, - hasChildren: childHasChildren, - }); - }); - - return next; - }); - - if (prefetchDepth > 0 && childIds.length > 0 && tenantIdRef.current === requestTenantId) { - await Promise.allSettled( - childIds.map((childId) => - ensureFolderData(childId, { - includeDocuments: false, - force: false, - prefetchDepth: prefetchDepth - 1, - }), - ), - ); - } - - const enriched = { - ...hydrated, - __includesDocuments: includeDocuments, - }; - - if (includeDocuments) { - setFolderContents((prev) => { - if (tenantIdRef.current !== requestTenantId) { - return prev; - } - const next = new Map(prev); - next.set(folderId, enriched); - return next; - }); - } else { - setFolderContents((prev) => { - if (tenantIdRef.current !== requestTenantId) { - return prev; - } - const next = new Map(prev); - const existingEntry = next.get(folderId); - if (existingEntry) { - next.set(folderId, { - ...existingEntry, - ...hydrated, - documents: existingEntry.__includesDocuments - ? existingEntry.documents - : hydrated.documents, - __includesDocuments: existingEntry.__includesDocuments || false, - }); - } else { - next.set(folderId, enriched); - } - return next; - }); - } - - return enriched; - }, - [assetManager, folderContents], - ); - - const isInvalidFolderDrop = useCallback( - (sourceId, targetId) => { - if (!sourceId) return false; - if (!targetId || targetId === 'root') { - return false; - } - if (sourceId === targetId) { - return true; - } - - let current = targetId; - const visited = new Set(); - while (current && current !== 'root' && !visited.has(current)) { - visited.add(current); - if (current === sourceId) { - return true; - } - const node = folderNodes.get(current); - if (!node) break; - current = node.parentId ?? 'root'; - } - return false; - }, - [folderNodes], - ); - - const moveFolder = useCallback( - async (folderId, targetFolderId) => { - const node = folderNodes.get(folderId); - if (!node) { - setStatusMessage('Folder metadata unavailable. Try refreshing.', 'error'); - return; - } - - const previousParentKey = node.parentId ?? 'root'; - const targetKey = targetFolderId && targetFolderId !== 'root' ? targetFolderId : 'root'; - - if (previousParentKey === targetKey) { - return; - } - - const parent_id = targetKey === 'root' ? null : targetKey; - - try { - await api.patch(`/folders/${folderId}`, { parent_id }); - - setFolderNodes((prev) => { - const next = new Map(prev); - const currentNode = next.get(folderId); - if (!currentNode) { - return prev; - } - - const updatedNode = { ...currentNode, parentId: parent_id ?? null }; - next.set(folderId, updatedNode); - - const previousParent = next.get(previousParentKey); - if (previousParent) { - const remainingChildren = (previousParent.children || []).filter( - (childId) => childId !== folderId, - ); - next.set(previousParentKey, { - ...previousParent, - children: remainingChildren, - hasChildren: remainingChildren.length > 0, - }); - } - - if (!next.has(targetKey)) { - next.set(targetKey, { - id: targetKey, - name: targetKey === 'root' ? DEFAULT_FOLDER_NAME : 'Folder', - parentId: targetKey === 'root' ? null : null, - children: [], - expanded: targetKey === 'root', - loaded: false, - hasChildren: false, - }); - } - - const targetNode = next.get(targetKey); - if (targetNode && !targetNode.children.includes(folderId)) { - next.set(targetKey, { - ...targetNode, - children: [...targetNode.children, folderId], - hasChildren: true, - }); - } - - return next; - }); - - const refreshTargets = new Set([previousParentKey, targetKey]); - for (const key of refreshTargets) { - if (key === 'root') { - await ensureFolderData('root', { force: true, prefetchDepth: 1 }); - } else { - await ensureFolderData(key, { force: true, prefetchDepth: 1 }); - } - } - - if (selectedFolder === folderId) { - await ensureFolderData(folderId, { force: true, prefetchDepth: 1 }); - setSelectedFolder(folderId); - } - - setStatusMessage('Folder moved.', 'success'); - } catch (error) { - const message = error.response?.data?.error || 'Failed to move folder.'; - notifyApiError(error, message); - - const refreshTargets = new Set([previousParentKey, targetKey]); - for (const key of refreshTargets) { - if (key === 'root') { - await ensureFolderData('root', { force: true, prefetchDepth: 1 }); - } else { - await ensureFolderData(key, { force: true, prefetchDepth: 1 }); - } - } - } - }, - [ - folderNodes, - ensureFolderData, - selectedFolder, - setSelectedFolder, - setFolderNodes, - notifyApiError, - setStatusMessage, - ], - ); - - const refreshTags = useCallback(async () => { - const requestTenantId = tenantIdRef.current; - try { - const { data } = await api.get('/tags'); - if (tenantIdRef.current !== requestTenantId) { - return; - } - setTags(data || []); - } catch (error) { - if (tenantIdRef.current !== requestTenantId) { - return; - } - notifyApiError(error, 'Unable to load tags.'); - } - }, [notifyApiError]); - - const refreshCorrespondents = useCallback(async () => { - const requestTenantId = tenantIdRef.current; - try { - const { data } = await api.get('/correspondents'); - if (tenantIdRef.current !== requestTenantId) { - return; - } - setCorrespondents(data || []); - } catch (error) { - if (tenantIdRef.current !== requestTenantId) { - return; - } - notifyApiError(error, 'Unable to load correspondents.'); - } - }, [notifyApiError]); - - const { - passkeys, - passkeysSupported, - passkeysLoading, - registeringPasskey, - revokingPasskeyId, - refreshPasskeys, - registerPasskey, - revokePasskey, - } = usePasskeys({ - api, - notifyApiError, - setStatusMessage, - token, - }); - - const handleTagUpdate = useCallback( - async (tagId, changes) => { - if (!tagId) { - throw new Error('Missing tag identifier.'); - } - - const payload = {}; - if (typeof changes.label === 'string') { - payload.label = changes.label; - } - if (Object.prototype.hasOwnProperty.call(changes, 'color')) { - payload.color = changes.color; - } - - if (Object.keys(payload).length === 0) { - return false; - } - - try { - await api.patch(`/tags/${tagId}`, payload); - await refreshTags(); - setStatusMessage('Tag updated.', 'success'); - return true; - } catch (error) { - const message = error.response?.data?.error || 'Failed to update tag.'; - notifyApiError(error, message); - throw new Error(message); - } - }, - [refreshTags, notifyApiError, setStatusMessage], - ); - - const handleTagCreate = useCallback( - async ({ label, color } = {}) => { - const payload = tagManager.buildPayload({ label, color }); - try { - await api.post('/tags', payload); - await refreshTags(); - setStatusMessage('Tag created.', 'success'); - } catch (error) { - const message = error.response?.data?.error || 'Failed to create tag.'; - notifyApiError(error, message); - throw new Error(message); - } - }, - [refreshTags, notifyApiError, setStatusMessage, tagManager], - ); - - const handleCorrespondentUpdate = useCallback( - async (correspondentId, changes) => { - if (!correspondentId) { - throw new Error('Missing correspondent identifier.'); - } - - const payload = {}; - if (typeof changes.name === 'string') { - const trimmed = changes.name.trim(); - if (!trimmed) { - throw new Error('Correspondent name cannot be empty.'); - } - payload.name = trimmed; - } - - if (Object.keys(payload).length === 0) { - return false; - } - - try { - await api.patch(`/correspondents/${correspondentId}`, payload); - await refreshCorrespondents(); - setStatusMessage('Correspondent updated.', 'success'); - return true; - } catch (error) { - const message = error.response?.data?.error || 'Failed to update correspondent.'; - notifyApiError(error, message); - throw new Error(message); - } - }, - [refreshCorrespondents, notifyApiError, setStatusMessage], - ); - - const handleCorrespondentCreate = useCallback( - async ({ name }) => { - const trimmed = typeof name === 'string' ? name.trim() : ''; - if (!trimmed) { - throw new Error('Correspondent name is required.'); - } - try { - const { data } = await api.post('/correspondents', { name: trimmed }); - await refreshCorrespondents(); - setStatusMessage('Correspondent created.', 'success'); - return data; - } catch (error) { - const message = error.response?.data?.error || 'Failed to create correspondent.'; - notifyApiError(error, message); - throw new Error(message); - } - }, - [refreshCorrespondents, notifyApiError, setStatusMessage], - ); - - const handleCorrespondentDelete = useCallback( - async (correspondentId) => { - if (!correspondentId) { - throw new Error('Missing correspondent identifier.'); - } - - const stripFromDoc = (doc) => { - if (!doc || !Array.isArray(doc.correspondents)) { - return doc; - } - const next = doc.correspondents.filter((entry) => entry.id !== correspondentId); - if (next.length === doc.correspondents.length) { - return doc; - } - return { ...doc, correspondents: next }; - }; - - try { - await api.delete(`/correspondents/${correspondentId}`); - await refreshCorrespondents(); - - mapDocumentCaches(stripFromDoc); - - setStatusMessage('Correspondent deleted.', 'success'); - return true; - } catch (error) { - const message = error.response?.data?.error || 'Failed to delete correspondent.'; - notifyApiError(error, message); - throw new Error(message); - } - }, - [refreshCorrespondents, notifyApiError, setStatusMessage, mapDocumentCaches], - ); - - const refreshCurrentFolder = useCallback(async () => { - setLoading(true); - try { - const contents = await ensureFolderData(selectedFolder, { - force: true, - prefetchDepth: 1, - }); - applySelectedFolder(selectedFolder, contents); - } catch (error) { - notifyApiError(error, 'Failed to refresh folder.'); - } finally { - setLoading(false); - } - }, [selectedFolder, ensureFolderData, applySelectedFolder, notifyApiError]); - - const handleDocumentCorrespondentAttach = useCallback( - async ({ documentId, correspondentId }, { notify = true, refresh = true } = {}) => { - if (!documentId || !correspondentId) { - throw new Error('Missing document or correspondent.'); - } - try { - await api.post(`/documents/${documentId}/correspondents`, { - assignments: [{ correspondent_id: correspondentId }], - replace: false, - }); - if (refresh) { - await refreshCurrentFolder(); - } - if (notify) { - setStatusMessage('Correspondent assigned.', 'success'); - } - return true; - } catch (error) { - const message = error.response?.data?.error || 'Failed to assign correspondent.'; - notifyApiError(error, message); - throw new Error(message); - } - }, - [notifyApiError, refreshCurrentFolder, setStatusMessage], - ); - - const handleCorrespondentRemove = useCallback( - async ({ documentId, correspondentId }, { notify = true, refresh = true } = {}) => { - if (!documentId || !correspondentId) { - throw new Error('Missing document or correspondent.'); - } - try { - await api.delete(`/documents/${documentId}/correspondents/${correspondentId}`); - if (refresh) { - await refreshCurrentFolder(); - } - if (notify) { - setStatusMessage('Correspondent removed.', 'success'); - } - return true; - } catch (error) { - const message = error.response?.data?.error || 'Failed to remove correspondent.'; - notifyApiError(error, message); - throw new Error(message); - } - }, - [notifyApiError, refreshCurrentFolder, setStatusMessage], - ); - - const handleCorrespondentAdd = useCallback( - async ({ document, name, input = null, option = null }) => { - if (!document?.id) { - throw new Error('Missing document for correspondent assignment.'); - } - const trimmed = typeof name === 'string' ? name.trim() : ''; - if (!trimmed) { - setStatusMessage('Correspondent name is required.', 'error'); - return; - } - - let target = null; - if (option && option.id) { - target = correspondentLookupByName.get(trimmed.toLowerCase()) || option; - } else { - target = correspondentLookupByName.get(trimmed.toLowerCase()) || null; - } - if (!target) { - try { - target = await handleCorrespondentCreate({ name: trimmed }); - } catch (error) { - return; - } - } - - if (!target?.id) { - setStatusMessage('Unable to resolve correspondent.', 'error'); - return; - } - - try { - await handleDocumentCorrespondentAttach({ - documentId: document.id, - correspondentId: target.id, - }); - if (input) { - input.value = ''; - } - } catch (error) { - setStatusMessage('Failed to assign correspondent.', 'error'); - console.error('[documents] assign correspondent failed', error); - } - }, - [ - handleCorrespondentCreate, - handleDocumentCorrespondentAttach, - correspondentLookupByName, - setStatusMessage, - ], - ); - - const resolveTargetDocumentIds = useCallback( - (candidateIds) => { - const normalized = Array.isArray(candidateIds) - ? candidateIds.filter(Boolean) - : []; - if (normalized.length) { - return Array.from(new Set(normalized)); - } - return selectedDocumentIds; - }, - [selectedDocumentIds], - ); - - const handleTagDelete = useCallback( - async (tagId) => { - if (!tagId) { - throw new Error('Missing tag identifier.'); - } - - try { - await api.delete(`/tags/${tagId}`); - setActiveTagFilters((prev) => prev.filter((id) => id !== tagId)); - - const stripTagFromDoc = (doc) => { - if (!doc || !Array.isArray(doc.tags)) { - return doc; - } - const nextTags = doc.tags.filter((tag) => tag.id !== tagId); - if (nextTags.length === doc.tags.length) { - return doc; - } - return { ...doc, tags: nextTags }; - }; - - mapDocumentCaches(stripTagFromDoc); - - await refreshTags(); - setStatusMessage('Tag deleted.', 'success'); - return true; - } catch (error) { - const message = error.response?.data?.error || 'Failed to delete tag.'; - notifyApiError(error, message); - throw new Error(message); - } - }, - [ - refreshTags, - notifyApiError, - setStatusMessage, - mapDocumentCaches, - setActiveTagFilters, - ], - ); - - const expandFolderAncestors = useCallback( - (targetId) => { - if (!targetId || targetId === 'root') { - setFolderNodes((prev) => { - if (prev.get('root')?.expanded) { - return prev; - } - const next = new Map(prev); - const rootNode = next.get('root'); - if (rootNode) { - next.set('root', { ...rootNode, expanded: true }); - } - return next; - }); - return; - } - - setFolderNodes((prev) => { - const next = new Map(prev); - let currentId = targetId; - let guard = 0; - while (currentId && !next.has(currentId) && guard < 32) { - guard += 1; - const node = prev.get(currentId); - if (!node) { - break; - } - currentId = node.parentId ?? 'root'; - } - - currentId = targetId; - guard = 0; - while (currentId && guard < 32) { - guard += 1; - const node = next.get(currentId); - if (!node) { - break; - } - if (!node.expanded && currentId !== targetId) { - next.set(currentId, { ...node, expanded: true }); - } - currentId = node.parentId ?? 'root'; - if (!currentId || currentId === 'root') { - const rootNode = next.get('root'); - if (rootNode && !rootNode.expanded) { - next.set('root', { ...rootNode, expanded: true }); - } - break; - } - } - return next; - }); - }, - []); - - const ensureFolderAncestorsLoaded = useCallback( - async (targetId) => { - if (!targetId || targetId === 'root') { - return; - } - - const fetchAncestor = async (folderId, guard = 0) => { - if (!folderId || folderId === 'root' || guard > 32) { - return; - } - - const existing = folderNodes.get(folderId); - if (existing?.loaded) { - return; - } - - try { - const contents = await ensureFolderData(folderId, { - includeDocuments: false, - force: false, - prefetchDepth: 0, - }); - const parentId = contents?.folder?.parent_id ?? 'root'; - if (parentId && parentId !== 'root') { - await fetchAncestor(parentId, guard + 1); - } - } catch (error) { - console.warn('Failed to ensure ancestor folder for navigation', folderId, error); - } - }; - - await fetchAncestor(targetId, 0); - }, - [folderNodes, ensureFolderData], - ); - - const loadFolder = useCallback( - async (folderId, { showLoading = true, preserveSearch = false } = {}) => { - const targetId = folderId || 'root'; - setSelectedFolder(targetId); - await ensureFolderAncestorsLoaded(targetId); - expandFolderAncestors(targetId); - if (showLoading) setLoading(true); - try { - const contents = await ensureFolderData(targetId, { force: true, prefetchDepth: 1 }); - if (targetId !== 'root') { - try { - await ensureFolderData('root', { - force: false, - includeDocuments: false, - prefetchDepth: 1, - }); - } catch (error) { - console.warn('Failed to refresh root folder tree', error); - } - } - applySelectedFolder(targetId, contents); - if (!preserveSearch) { - setSearchResults(null); - } - } catch (error) { - notifyApiError(error, 'Failed to load folder contents.'); - } finally { - if (showLoading) setLoading(false); - } - }, - [ - ensureFolderData, - applySelectedFolder, - notifyApiError, - ensureFolderAncestorsLoaded, - expandFolderAncestors, - ], - ); - - const selectFolder = useCallback( - async (folderId, { replace = false, immediate = false } = {}) => { - const targetId = folderId && folderId !== 'root' ? folderId : 'root'; - - await ensureFolderAncestorsLoaded(targetId); - expandFolderAncestors(targetId); - - if (!navigate || immediate) { - await loadFolder(targetId, { preserveSearch: isFilterActive }); - setSelectedFolder(targetId); - return; - } - - const path = targetId === 'root' ? '/documents' : `/documents/folder/${targetId}`; - navigate(path, { replace }); - }, - [ - ensureFolderAncestorsLoaded, - expandFolderAncestors, - navigate, - loadFolder, - isFilterActive, - setSelectedFolder, - ], - ); - - const initializeAfterLogin = useCallback(async () => { - setLoading(true); - try { - await Promise.all([refreshTags(), refreshCorrespondents()]); - const initialFolder = routeFolderId && routeFolderId !== 'root' ? routeFolderId : 'root'; - await loadFolder(initialFolder, { showLoading: false }); - } catch (error) { - notifyApiError(error, 'Failed to initialize data.'); - throw error; - } finally { - setLoading(false); - } - }, [refreshTags, refreshCorrespondents, routeFolderId, loadFolder, notifyApiError]); - - useEffect(() => { - if (!token) { - return; - } - if (appStatus !== 'ready' && appStatus !== 'bootstrapping') { - return; - } - - const targetParam = routeFolderId ?? 'root'; - - if (targetParam === 'root' && routeDocumentId) { - return; - } - - const hasData = folderContents.has(targetParam); - if (targetParam !== selectedFolder || !hasData) { - selectFolder(targetParam, { immediate: true }); - } - }, [ - token, appStatus, - routeFolderId, - routeDocumentId, - selectedFolder, - folderContents, - isFilterActive, - selectFolder, - ]); - - const handleBulkCorrespondentAdd = useCallback( - async ({ name, input, documentIds }) => { - const trimmed = typeof name === 'string' ? name.trim() : ''; - if (!trimmed) { - setStatusMessage('Correspondent name is required.', 'error'); - return; - } - const targets = resolveTargetDocumentIds(documentIds); - if (!targets.length) { - setStatusMessage('Select documents before assigning correspondents.', 'error'); - return; - } - - let target = correspondentLookupByName.get(trimmed.toLowerCase()) || null; - if (!target) { - try { - target = await handleCorrespondentCreate({ name: trimmed }); - } catch (error) { - return; - } - } - - if (!target?.id) { - setStatusMessage('Unable to resolve correspondent.', 'error'); - return; - } - - const response = await api.post('/documents/bulk/correspondents', { - document_ids: targets, - assignments: [ - { - correspondent_id: target.id, - }, - ], - action: 'add', - }); - - const { assigned = 0, removed = 0 } = response.data || {}; - - await refreshCurrentFolder(); - const assignedSuffix = assigned === 1 ? '' : 's'; - if (removed > 0) { - const removedSuffix = removed === 1 ? '' : 's'; - setStatusMessage( - `Correspondent assigned (${assigned}) and replaced ${removed} link${removedSuffix}.`, - 'success', - ); - } else { - setStatusMessage( - `Correspondent assigned to ${assigned} document${assignedSuffix}.`, - 'success', - ); - } - - if (input) { - input.value = ''; - } - }, - [ - correspondentLookupByName, - handleCorrespondentCreate, - refreshCurrentFolder, - resolveTargetDocumentIds, - setStatusMessage, - ], - ); - - const handleBulkCorrespondentRemove = useCallback( - async ({ assignments = [], documentIds }) => { - if (!assignments.length) { - setStatusMessage('Select a correspondent to remove.', 'error'); - return; - } - - const targets = resolveTargetDocumentIds(documentIds); - - if (!targets.length) { - setStatusMessage('Select documents before removing correspondents.', 'error'); - return; - } - - const normalizedAssignments = assignments.map((entry) => ({ - correspondent_id: entry.correspondent_id, - })); - - const response = await api.post('/documents/bulk/correspondents', { - document_ids: targets, - assignments: normalizedAssignments, - action: 'remove', - }); - - const { assigned = 0, removed = 0 } = response.data || {}; - await refreshCurrentFolder(); - - if (removed > 0) { - const removedSuffix = removed === 1 ? '' : 's'; - setStatusMessage( - `Correspondent removed from ${removed} link${removedSuffix}.`, - 'success', - ); - } else if (assigned > 0) { - const assignedSuffix = assigned === 1 ? '' : 's'; - setStatusMessage(`Correspondent updated ${assigned} link${assignedSuffix}.`, 'info'); - } else { - setStatusMessage('No correspondents changed.', 'info'); - } - }, - [refreshCurrentFolder, resolveTargetDocumentIds, setStatusMessage], - ); - - useEffect(() => { - if (appStatus !== 'authenticated') { - return; - } - if (bootstrapInitializedRef.current) { - return; - } - - let cancelled = false; - const bootstrap = async () => { - bootstrapInitializedRef.current = true; - appDispatch({ type: 'BOOTSTRAP_START' }); - try { - await initializeAfterLogin(); - if (!cancelled) { - appDispatch({ type: 'BOOTSTRAP_SUCCESS' }); - } - } catch (error) { - if (!cancelled) { - appDispatch({ - type: 'BOOTSTRAP_FAILURE', - error: error?.message || 'Failed to initialize data.', - }); - bootstrapInitializedRef.current = false; - } - } - }; - - bootstrap(); - - return () => { - cancelled = true; - }; - }, [appStatus, appDispatch, initializeAfterLogin]); - - const bulkTagOperation = useCallback( - async ({ labels, action, documentIds }) => { - const normalized = labels.map((label) => label.trim()).filter((label) => label.length > 0); - if (!normalized.length) { - return { ok: false, reason: 'no-labels' }; - } - const targetDocumentIds = resolveTargetDocumentIds(documentIds); - if (!targetDocumentIds.length) { - return { ok: false, reason: 'no-selection' }; - } - - let tagIds = []; - - if (action === 'remove') { - const missing = normalized.find( - (label) => !tags.some((tag) => tag.label.toLowerCase() === label.toLowerCase()), - ); - if (missing) { - return { ok: false, reason: 'tag-missing', label: missing }; - } - - tagIds = normalized.map((label) => { - const tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase()); - return tag?.id; - }).filter(Boolean); - } - - setLoading(true); - try { - if (action === 'add') { - const createdIds = []; - for (const label of normalized) { - let tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase()) || null; - if (!tag) { - const payload = tagManager.buildPayload({ label }); - const { data } = await api.post('/tags', payload); - tag = data; - await refreshTags(); - } - createdIds.push(tag.id); - } - tagIds = Array.from(new Set(createdIds)); - } - - tagIds = Array.from(new Set(tagIds)); - - if (!tagIds.length) { - return { ok: false, reason: 'no-tags' }; - } - - await api.post('/documents/bulk/tags', { - document_ids: targetDocumentIds, - tag_ids: tagIds, - action, - }); - - await refreshCurrentFolder(); - - return { - ok: true, - tagCount: tagIds.length, - docsCount: targetDocumentIds.length, - }; - } catch (error) { - const message = - error.response?.data?.error || - (action === 'add' ? 'Failed to assign tags.' : 'Failed to remove tags.'); - notifyApiError(error, message); - return { ok: false, reason: 'request-failed' }; - } finally { - setLoading(false); - } - }, - [ - resolveTargetDocumentIds, - tags, - refreshTags, - refreshCurrentFolder, - notifyApiError, - setLoading, - tagManager, - ], - ); - - const handleBulkTagAddFromDetail = useCallback( - async ({ label, input, documentIds }) => { - const trimmed = typeof label === 'string' ? label.trim() : ''; - if (!trimmed) { - setStatusMessage('Enter a tag label.', 'error'); - return; - } - const targetIds = resolveTargetDocumentIds(documentIds); - if (!targetIds.length) { - setStatusMessage('Select documents before assigning tags.', 'error'); - return; - } - const result = await bulkTagOperation({ - labels: [trimmed], - action: 'add', - documentIds: targetIds, - }); - if (result?.ok) { - const { tagCount, docsCount } = result; - setStatusMessage( - `Assigned ${tagCount} tag${tagCount === 1 ? '' : 's'} to ${docsCount} document${ - docsCount === 1 ? '' : 's' - }.`, - 'success', - ); - if (input) { - input.value = ''; - } - } - }, - [bulkTagOperation, resolveTargetDocumentIds, setStatusMessage], - ); - - const handleBulkTagRemoveFromDetail = useCallback( - async ({ label, input, documentIds }) => { - const trimmed = typeof label === 'string' ? label.trim() : ''; - if (!trimmed) { - setStatusMessage('Enter a tag label to remove.', 'error'); - return; - } - const targetIds = resolveTargetDocumentIds(documentIds); - if (!targetIds.length) { - setStatusMessage('Select documents before removing tags.', 'error'); - return; - } - const result = await bulkTagOperation({ - labels: [trimmed], - action: 'remove', - documentIds: targetIds, - }); - if (result?.ok) { - const { docsCount } = result; - setStatusMessage( - `Removed tags from ${docsCount} document${docsCount === 1 ? '' : 's'}.`, - 'success', - ); - if (input) { - input.value = ''; - } - } else if (result?.reason === 'tag-missing') { - setStatusMessage(`Tag “${result.label}” not found.`, 'error'); - } - }, - [bulkTagOperation, resolveTargetDocumentIds, setStatusMessage], - ); - const handleBulkSelectionReanalyze = useCallback( - async (documentIdsOverride = null) => { - const targetIds = resolveTargetDocumentIds(documentIdsOverride); - if (!targetIds.length) { - setStatusMessage('Select documents before requesting re-analysis.', 'error'); - return; - } - - setLoading(true); - try { - const { data } = await api.post('/documents/bulk/reanalyze', { - document_ids: targetIds, - force: true, - }); - const queued = data?.queued ?? targetIds.length; - setStatusMessage( - `Queued re-analysis for ${queued} document${queued === 1 ? '' : 's'}.`, - 'success', - ); - } catch (error) { - const message = - error.response?.data?.error || 'Failed to queue document re-analysis.'; - notifyApiError(error, message); - } finally { - setLoading(false); - } - }, - [resolveTargetDocumentIds, notifyApiError, setStatusMessage], - ); - - const uploadFile = useCallback( - async (file, targetFolderId) => { - if (!file || file.size === 0) { - setStatusMessage('Skipped empty file.', 'error'); - return null; - } - - const formData = new FormData(); - formData.append('file', file, file.name); - if (targetFolderId && targetFolderId !== 'root') { - formData.append('folder_id', targetFolderId); - } - - try { - const { data, status } = await api.post('/documents', formData); - const duplicate = data?.reused || status === 200; - setStatusMessage( - duplicate - ? `${file.name} already exists; reused existing document.` - : `Uploaded ${file.name}`, - duplicate ? 'info' : 'success', - ); - return data; - } catch (error) { - const message = error.response?.data?.error || `Failed to upload ${file.name}.`; - notifyApiError(error, message); - throw error; - } - }, - [notifyApiError, setStatusMessage], - ); - - const folderPathCacheRef = useRef(new Map()); - - const ensureFolderPathOnServer = useCallback( - async (baseFolderId, segments) => { - const trimmedSegments = segments.map((segment) => segment.trim()).filter(Boolean); - if (trimmedSegments.length === 0) { - return baseFolderId ?? null; - } - - const cacheKey = `${baseFolderId ?? 'ROOT'}:${trimmedSegments.join('/')}`; - const cache = folderPathCacheRef.current; - if (cache.has(cacheKey)) { - return cache.get(cacheKey); - } - - const payload = { - parent_id: baseFolderId && baseFolderId !== 'root' ? baseFolderId : null, - segments: trimmedSegments, - }; - - const { data } = await api.post('/folders/path', payload); - cache.set(cacheKey, data.folder.id); - return data.folder.id; - }, - [], - ); - - const ensureAssetUrl = useCallback( - async (documentId, asset, { force = false, start = null, limit = null } = {}) => { - if (!documentId || !asset?.id) { - return null; - } - - try { - const entry = await assetManager.ensureAsset(documentId, asset, { - force, - start, - limit, - }); - - if (!entry) { - return null; - } - - setDocuments((prev) => - prev.map((doc) => (doc.id === documentId ? mergeAssetIntoDocument(doc, entry) : doc)), - ); - - setSearchResults((prev) => - Array.isArray(prev) - ? prev.map((doc) => (doc.id === documentId ? mergeAssetIntoDocument(doc, entry) : doc)) - : prev, - ); - - return entry; - } catch (error) { - notifyApiError(error, 'Unable to refresh document asset.'); - throw error; - } - }, - [assetManager, setDocuments, setSearchResults, notifyApiError], - ); - - const dragPreviewRef = useRef(null); - - const destroyDragPreview = useCallback(() => { - const node = dragPreviewRef.current; - if (node && node.parentNode) { - node.parentNode.removeChild(node); - } - dragPreviewRef.current = null; - }, []); - - useEffect(() => destroyDragPreview, [destroyDragPreview]); - - const createDragPreview = useCallback( - ({ documents = [], folders = [] } = {}) => { - destroyDragPreview(); - - const docEntries = (documents || []).filter(Boolean); - const folderEntries = (folders || []).filter(Boolean); - const totalCount = docEntries.length + folderEntries.length; - if (!totalCount) { - return null; - } - - const maxVisible = 4; - const size = 64; - const canvasSize = Math.round(size * 1.6); - - const visibleItems = []; - docEntries.slice(0, maxVisible).forEach((doc) => { - visibleItems.push({ type: 'document', payload: doc }); - }); - - if (visibleItems.length < maxVisible) { - folderEntries - .slice(0, maxVisible - visibleItems.length) - .forEach((folderId) => visibleItems.push({ type: 'folder', payload: folderId })); - } - - const wrapper = document.createElement('div'); - wrapper.className = 'document-drag-preview'; - wrapper.style.setProperty('--drag-preview-size', `${canvasSize}px`); - - visibleItems.forEach((item, index) => { - const layer = document.createElement('div'); - layer.className = 'document-drag-preview__thumb'; - const rotationMagnitude = Math.random() * 8 + 2; // 2..10 degrees - const rotation = (index % 2 === 0 ? 1 : -1) * rotationMagnitude; - layer.style.setProperty('--rotation-deg', `${rotation}deg`); - - if (item.type === 'document') { - const doc = item.payload; - const rowEl = doc?.id - ? document.getElementById(`document-row-${doc.id}`) || - document.getElementById(`document-card-${doc.id}`) - : null; - const thumbnailEl = rowEl?.querySelector('.document-thumbnail'); - const placeholderEl = rowEl?.querySelector('.thumb-placeholder'); - const wrapperEl = rowEl?.querySelector('.document-thumbnail-wrapper'); - const aspectAttr = wrapperEl?.dataset?.thumbnailAspect; - const aspectRatio = aspectAttr ? parseFloat(aspectAttr) : null; - - let thumbWidth = size; - let thumbHeight = size; - if (Number.isFinite(aspectRatio) && aspectRatio > 0) { - if (aspectRatio >= 1) { - thumbWidth = size; - thumbHeight = Math.max(size / aspectRatio, size * 0.5); - } else { - thumbHeight = size; - thumbWidth = Math.max(size * aspectRatio, size * 0.5); - } - } - layer.style.width = `${Math.round(thumbWidth)}px`; - layer.style.height = `${Math.round(thumbHeight)}px`; - - const thumbSrc = thumbnailEl?.currentSrc || thumbnailEl?.src || null; - - if (thumbSrc) { - layer.classList.add('document-drag-preview__thumb--image'); - layer.style.backgroundImage = `url("${thumbSrc}")`; - } else if (placeholderEl instanceof HTMLElement) { - const content = placeholderEl.cloneNode(true); - content.style.pointerEvents = 'none'; - layer.appendChild(content); - } else { - layer.textContent = 'DOC'; - } - } else { - const folderId = item.payload; - const rowEl = folderId ? document.getElementById(`folder-row-${folderId}`) : null; - const iconEl = rowEl?.querySelector('.thumb-icon'); - - let content = null; - if (iconEl instanceof HTMLElement) { - content = iconEl.cloneNode(true); - content.classList.add('document-drag-preview__folder-thumb'); - const svg = content.querySelector('svg'); - if (svg) { - svg.setAttribute('width', '48'); - svg.setAttribute('height', '48'); - } - } - - if (!content) { - content = document.createElement('div'); - content.className = 'document-drag-preview__folder-placeholder'; - content.textContent = 'Folder'; - } - - layer.appendChild(content); - } - - wrapper.appendChild(layer); - }); - - if (totalCount > 1) { - const badge = document.createElement('div'); - badge.className = 'document-drag-preview__count'; - badge.textContent = `${totalCount}`; - wrapper.appendChild(badge); - } - - document.body.appendChild(wrapper); - dragPreviewRef.current = wrapper; - return wrapper; - }, - [destroyDragPreview], - ); - - const handleDocumentDragStart = useCallback( - (event, documentOrId) => { - const documentId = typeof documentOrId === 'string' ? documentOrId : documentOrId?.id; - if (!documentId) { - return; - } - - const documentKey = resolveDocumentRowKey(documentId); - if (!documentKey) { - return; - } - - const isGridView = documentsViewMode === 'grid'; - const isAlreadySelected = selectedDocumentIds.includes(documentId); - const selection = isAlreadySelected - ? [...selectedDocumentIds] - : isGridView - ? [...selectedDocumentIds, documentId] - : [documentId]; - const folderSelection = selectedFolderIds.length ? [...selectedFolderIds] : []; - - if (!isAlreadySelected && !isGridView) { - applySelection([documentKey], { - anchor: documentKey, - interactedKeys: [documentKey], - }); - } - - const previewDocs = selection.map((id) => documentLookup.get(id) || null).filter(Boolean); - const previewNode = createDragPreview({ - documents: previewDocs, - folders: folderSelection, - }); - - setDraggedDocumentIds(selection); - if (folderSelection.length) { - setDraggedFolderId(folderSelection[0] || null); - } - event.dataTransfer.effectAllowed = 'move'; - try { - event.dataTransfer.setData( - 'application/x-papercrate-doc-list', - JSON.stringify(selection), - ); - if (folderSelection.length) { - event.dataTransfer.setData( - 'application/x-papercrate-folder-list', - JSON.stringify(folderSelection), - ); - if (folderSelection.length === 1) { - event.dataTransfer.setData('application/x-papercrate-folder', folderSelection[0]); - } - } - } catch (error) { - console.warn('[documents] Failed to populate drag payload', error); - } - if (previewNode) { - const width = previewNode.offsetWidth || 96; - const height = previewNode.offsetHeight || 96; - event.dataTransfer.setDragImage(previewNode, width / 2, height / 2); - } - event.currentTarget.classList.add('dragging'); - }, - [ - selectedDocumentIds, - selectedFolderIds, - applySelection, - documentLookup, - createDragPreview, - setDraggedFolderId, - documentsViewMode, - ], - ); - - const handleDocumentDragEnd = useCallback( - (event) => { - setDraggedDocumentIds([]); - event.currentTarget.classList.remove('dragging'); - destroyDragPreview(); - setDraggedFolderId(null); - }, - [destroyDragPreview, setDraggedFolderId], - ); - - const handleFolderDragStart = useCallback( - (event, folderId) => { - if (folderId === 'root') { - return; - } - event.stopPropagation(); - const folderKey = resolveFolderRowKey(folderId); - const isAlreadySelected = folderKey ? selectedEntries.includes(folderKey) : false; - - let effectiveFolderSelection = selectedFolderIds; - let effectiveDocumentSelection = selectedDocumentIds; - - if (!isAlreadySelected && folderKey) { - effectiveFolderSelection = [folderId]; - effectiveDocumentSelection = []; - handleRowSelection(folderKey, { preventDefault: () => {} }); - } - - const uniqueFolders = effectiveFolderSelection.length - ? Array.from(new Set(effectiveFolderSelection.filter(Boolean))) - : [folderId]; - - setDraggedFolderId(folderId); - if (effectiveDocumentSelection.length) { - setDraggedDocumentIds(effectiveDocumentSelection); - } - - event.dataTransfer.effectAllowed = 'move'; - try { - event.dataTransfer.setData( - 'application/x-papercrate-folder-list', - JSON.stringify(uniqueFolders), - ); - if (uniqueFolders.length === 1) { - event.dataTransfer.setData('application/x-papercrate-folder', uniqueFolders[0]); - } - if (effectiveDocumentSelection.length) { - event.dataTransfer.setData( - 'application/x-papercrate-doc-list', - JSON.stringify(effectiveDocumentSelection), - ); - } - } catch (error) { - console.warn('[folders] Failed to set drag payload', error); - } - - const previewDocs = effectiveDocumentSelection - .map((id) => documentLookup.get(id) || null) - .filter(Boolean); - const previewNode = createDragPreview({ - documents: previewDocs, - folders: uniqueFolders, - }); - if (previewNode) { - const width = previewNode.offsetWidth || 96; - const height = previewNode.offsetHeight || 96; - event.dataTransfer.setDragImage(previewNode, width / 2, height / 2); - } - event.currentTarget?.classList.add('dragging'); - }, - [ - selectedEntries, - selectedFolderIds, - selectedDocumentIds, - setDraggedFolderId, - setDraggedDocumentIds, - handleRowSelection, - documentLookup, - createDragPreview, - ], - ); - - const handleFolderDragEnd = useCallback( - (event) => { - if (event?.currentTarget) { - event.currentTarget.classList.remove('dragging'); - } - setDraggedFolderId(null); - setDraggedDocumentIds([]); - destroyDragPreview(); - }, - [setDraggedFolderId, setDraggedDocumentIds, destroyDragPreview], - ); - - const ensurePreviewUrl = useCallback( - async (documentId, { force = false } = {}) => { - if (!documentId) return null; - - const existing = previewEntries.get(documentId) || null; - const now = Date.now(); - const expiresAt = typeof existing?.expiresAt === 'number' ? existing.expiresAt : null; - if (!force && existing && (!expiresAt || expiresAt > now)) { - return existing; - } - - if (!force && previewInflightRef.current.has(documentId)) { - return previewInflightRef.current.get(documentId); - } - - const request = (async () => { - try { - const docResponse = await api.get(`/documents/${documentId}`); - const downloadPath = docResponse.data?.document?.current_version?.download_path; - if (!downloadPath || !resolveApiPath) { - throw new Error('Document missing download path'); - } - - const href = resolveApiPath(downloadPath); - const entry = { - url: href, - contentType: docResponse.data?.document?.current_version?.version?.content_type || null, - filename: docResponse.data?.document?.filename, - expiresAt: Date.now() + 5 * 60 * 1000, - }; - setPreviewEntries((prev) => { - const next = new Map(prev); - next.set(documentId, entry); - return next; - }); - return entry; - } catch (error) { - notifyApiError(error, 'Unable to fetch document preview.'); - throw error; - } finally { - previewInflightRef.current.delete(documentId); - } - })(); - - previewInflightRef.current.set(documentId, request); - return request; - }, - [previewEntries, notifyApiError], - ); - - const extractFilesFromDataTransfer = useCallback(async (dataTransfer) => { - if (!dataTransfer) { - throw new Error('No drop payload found.'); - } - - const items = Array.from(dataTransfer.items || []); - console.info('[Uploads] drop start', { items: items.length, files: (dataTransfer.files || []).length }); - - const results = []; - const seenKeys = new Set(); - - const pushFile = (file, ancestors = []) => { - if (!file) return; - const segments = (ancestors || []).filter(Boolean); - const key = `${segments.join('/')}/${file.name}:${file.size}`; - if (seenKeys.has(key)) { - // skipped duplicate - return; - } - seenKeys.add(key); - results.push({ file, segments }); - // queued file - }; - - const readAllEntries = async (reader) => { - const entries = []; - let batch = []; - do { - // eslint-disable-next-line no-await-in-loop - batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject)); - if (batch.length) { - entries.push(...batch); - } - } while (batch.length); - return entries; - }; - - const walkEntry = async (entry, ancestors = []) => { - if (!entry) return; - if (entry.isFile) { - const file = await new Promise((resolve, reject) => { - try { - entry.file(resolve, reject); - } catch (error) { - console.warn('[Uploads] entry.file failed', error); - reject(error); - } - }); - pushFile(file, ancestors); - return; - } - if (entry.isDirectory) { - const nextAncestors = entry.name ? [...ancestors, entry.name] : [...ancestors]; - const reader = entry.createReader(); - const entries = await readAllEntries(reader); - for (const child of entries) { - // eslint-disable-next-line no-await-in-loop - await walkEntry(child, nextAncestors); - } - } - }; - - await Promise.all( - items.map(async (item, index) => { - if (item.kind !== 'file') return; - - const fileFromItem = typeof item.getAsFile === 'function' ? item.getAsFile() : null; - if (fileFromItem) { - const relativePath = - typeof fileFromItem.webkitRelativePath === 'string' ? fileFromItem.webkitRelativePath : ''; - const segments = relativePath - ? relativePath - .split('/') - .slice(0, -1) - .filter(Boolean) - : []; - pushFile(fileFromItem, segments); - } - - if (typeof item.webkitGetAsEntry === 'function') { - try { - const entry = item.webkitGetAsEntry(); - if (entry) { - // processing entry - await walkEntry(entry, []); - return; - } - } catch (error) { - console.warn('[Uploads] webkitGetAsEntry failed', error); - } - } - - if (!fileFromItem) { - console.info('[Uploads] item missing file handle', index); - } - }), - ); - - Array.from(dataTransfer.files || []).forEach((file) => { - if (!file) return; - // FileList entry suppressed - const relativePath = - typeof file.webkitRelativePath === 'string' ? file.webkitRelativePath : ''; - const segments = relativePath - ? relativePath - .split('/') - .slice(0, -1) - .filter(Boolean) - : []; - pushFile(file, segments); - }); - - if (!results.length) { - throw new Error('No files detected in drop payload.'); - } - - console.info('[Uploads] prepared files', results.length); - - return results; - }, []); - - - const handleFileDrop = useCallback( - async (dataTransfer, targetFolderId) => { - if (!token) { - setStatusMessage('Please log in before uploading.', 'error'); - return; - } - - setLoading(true); - - try { - folderPathCacheRef.current.clear(); - - let extracted; - try { - extracted = await extractFilesFromDataTransfer(dataTransfer); - } catch (error) { - const message = error.message || 'Failed to process dropped files.'; - notifyApiError(error, message); - return; - } - - if (!extracted.length) { - setStatusMessage('No files to upload.', 'info'); - return; - } - const baseFolderId = - targetFolderId && targetFolderId !== 'root' ? targetFolderId : null; - - for (const { file, segments } of extracted) { - // eslint-disable-next-line no-await-in-loop - const destinationId = segments.length - ? await ensureFolderPathOnServer(baseFolderId, segments) - : baseFolderId; - - const uploadTarget = - destinationId ?? - (targetFolderId && targetFolderId !== 'root' ? targetFolderId : 'root'); - - // eslint-disable-next-line no-await-in-loop - await uploadFile(file, uploadTarget); - } - - await refreshCurrentFolder(); - - if ( - targetFolderId && - targetFolderId !== 'root' && - targetFolderId !== selectedFolder - ) { - await ensureFolderData(targetFolderId, { force: true, prefetchDepth: 1 }); - } - } catch (error) { - notifyApiError(error, 'Failed to upload files.'); - } finally { - setLoading(false); - } - }, - [ - token, - extractFilesFromDataTransfer, - ensureFolderPathOnServer, - uploadFile, - refreshCurrentFolder, - selectedFolder, - ensureFolderData, - notifyApiError, - setStatusMessage, - ], - ); - - const normalizeDocumentId = (value) => { - if (!value) return null; - if (typeof value === 'object' && value.id) { - return value.id; - } - return value; - }; - - const moveDocumentsToFolder = useCallback( - async (documentIds, targetFolderId) => { - const uniqueIds = Array.from( - new Set((documentIds || []).map((value) => normalizeDocumentId(value)).filter(Boolean)), - ); - if (!uniqueIds.length) return; - - const uniqueIdSet = new Set(uniqueIds); - const target = targetFolderId === 'root' ? null : targetFolderId; - const targetLabel = - target === null - ? DEFAULT_FOLDER_NAME - : folderLabelMap.get(targetFolderId) || 'target folder'; - - const movedDocs = uniqueIds - .map((id) => { - const doc = documentLookup.get(id); - if (!doc) { - return null; - } - return { - id, - sourceFolderId: doc.folder_id ?? null, - document: doc, - }; - }) - .filter(Boolean); - - const updatedDocsMap = new Map(); - const resolveTargetName = () => { - if (!targetLabel) { - return null; - } - const segments = String(targetLabel).split('/'); - return segments[segments.length - 1] || targetLabel; - }; - const targetName = resolveTargetName(); - - movedDocs.forEach(({ id, document }) => { - if (!document) { - return; - } - const updated = { - ...document, - folder_id: target, - }; - if (targetLabel) { - updated.folder_path = targetLabel; - if (targetName) { - updated.folder_name = targetName; - } - } else if (target === null) { - updated.folder_path = DEFAULT_FOLDER_NAME; - updated.folder_name = DEFAULT_FOLDER_NAME; - } - updatedDocsMap.set(id, updated); - }); - - const pruneRowCollection = (collection) => - collection.filter((key) => { - if (!isDocumentRowKey(key)) { - return true; - } - const id = getRowId(key); - return id ? !uniqueIdSet.has(id) : true; - }); - - setLoading(true); - try { - if (uniqueIds.length === 1) { - await api.patch(`/documents/${uniqueIds[0]}/folder`, { folder_id: target }); - } else { - await api.post('/documents/bulk/move', { - document_ids: uniqueIds, - folder_id: target, - }); - } - - const count = uniqueIds.length; - const suffix = count === 1 ? '' : 's'; - setStatusMessage(`Moved ${count} document${suffix} to ${targetLabel}.`, 'success'); - - if (updatedDocsMap.size) { - mapDocumentCaches((doc) => { - if (!doc || !uniqueIdSet.has(doc.id)) { - return doc; - } - const updated = updatedDocsMap.get(doc.id); - if (updated) { - return updated; - } - return { ...doc, folder_id: target }; - }); - } else { - mapDocumentCaches((doc) => { - if (!doc || !uniqueIdSet.has(doc.id)) { - return doc; - } - return { ...doc, folder_id: target }; - }); - } - - if (uniqueIdSet.size) { - setDocuments((prev) => prev.filter((doc) => !uniqueIdSet.has(doc.id))); - setFolderContents((prev) => { - if (!prev.size) { - return prev; - } - let changed = false; - const next = new Map(prev); - movedDocs.forEach(({ id, sourceFolderId }) => { - const sourceKey = sourceFolderId || 'root'; - const entry = next.get(sourceKey); - if (!entry?.documents?.length) { - return; - } - const filteredDocs = entry.documents.filter((doc) => doc.id !== id); - if (filteredDocs.length !== entry.documents.length) { - changed = true; - next.set(sourceKey, { ...entry, documents: filteredDocs }); - } - }); - return changed ? next : prev; - }); - - setSelectedEntries((prev) => pruneRowCollection(prev)); - setSelectionOrder((prev) => pruneRowCollection(prev)); - selectionOrderRef.current = pruneRowCollection(selectionOrderRef.current); - if ( - selectionAnchorRef.current && - isDocumentRowKey(selectionAnchorRef.current) && - uniqueIdSet.has(getRowId(selectionAnchorRef.current)) - ) { - selectionAnchorRef.current = null; - } - if (focusedDocumentId && uniqueIdSet.has(focusedDocumentId)) { - setFocusedDocumentId(null); - } - if ( - focusedRowKey && - isDocumentRowKey(focusedRowKey) && - uniqueIdSet.has(getRowId(focusedRowKey)) - ) { - setFocusedRowKey(null); - } - } - - if (targetFolderId && targetFolderId !== selectedFolder) { - await ensureFolderData(targetFolderId, { force: true, prefetchDepth: 1 }); - } - } catch (error) { - const message = error.response?.data?.error || 'Failed to move documents.'; - notifyApiError(error, message); - } finally { - setLoading(false); - } - }, - [ - documentLookup, - ensureFolderData, - folderLabelMap, - focusedDocumentId, - mapDocumentCaches, - notifyApiError, - selectedFolder, - setDocuments, - setFolderContents, - setFocusedDocumentId, - focusedRowKey, - setFocusedRowKey, - setSelectionOrder, - setSelectedEntries, - setStatusMessage, - selectionAnchorRef, - selectionOrderRef, - ], - ); - - const handleThumbnailRegeneration = useCallback( - async (documentId) => { - if (!token) { - setStatusMessage('Log in to manage assets.', 'error'); - return; - } - setLoading(true); - try { - await api.post(`/documents/${documentId}/assets`, null, { - params: { force: true }, - }); - setStatusMessage('Document re-analysis queued.', 'info'); - await refreshCurrentFolder(); - } catch (error) { - const message = - error.response?.data?.error || 'Failed to request thumbnail generation.'; - notifyApiError(error, message); - } finally { - setLoading(false); - } - }, - [token, refreshCurrentFolder, notifyApiError, setStatusMessage], - ); - - const ensurePreviewData = useCallback( - async (documentId) => { - if (!documentId) return null; - - const findInCache = () => { - const pool = searchResults ?? documents; - return pool.find((item) => item.id === documentId) || null; - }; - - let doc = findInCache(); - - if (!doc) { - const { data } = await api.get(`/documents/${documentId}`); - const hydratedDetail = assetManager.hydrateDetail(data); - const fetched = hydratedDetail?.document || data.document || data; - doc = fetched ? assetManager.hydrateDocument(fetched) : null; - if (!doc) { - throw new Error('Document metadata unavailable.'); - } - - setDocuments((prev) => { - if (prev.some((item) => item.id === doc.id)) { - return prev; - } - return [doc, ...prev]; - }); - } - - if (!previewReturnPathRef.current) { - const fallbackFolderId = doc?.folder_id || 'root'; - previewReturnPathRef.current = - fallbackFolderId === 'root' ? '/documents' : `/documents/folder/${fallbackFolderId}`; - } - - await ensurePreviewUrl(documentId, { force: false }); - setActivePreviewId(documentId); - return doc; - }, - [ - searchResults, - documents, - assetManager, - setDocuments, - ensurePreviewUrl, - setActivePreviewId, - ], - ); - - const openDocumentPreview = useCallback( - (documentId, { replace = false } = {}) => { - if (!documentId) return; - detailPanelControlRef.current.close(); - previewReturnPathRef.current = `${location.pathname}${location.search}`; - navigate(`/documents/${documentId}`, { replace }); - }, - [navigate, location.pathname, location.search], - ); - - const closeDocumentPreview = useCallback( - (folderId = null) => { - const fallbackPath = previewReturnPathRef.current; - previewReturnPathRef.current = null; - - if (fallbackPath) { - navigate(fallbackPath, { replace: false }); - return; - } - - const targetId = folderId || selectedFolder || 'root'; - const path = targetId === 'root' ? '/documents' : `/documents/folder/${targetId}`; - navigate(path, { replace: false }); - }, - [navigate, selectedFolder], - ); - - const handleDocumentListFocus = useCallback(() => { - if (focusedRowKey && navigableRowKeys.includes(focusedRowKey)) { - return; - } - - let resolvedKey = null; - for (let index = selectedEntries.length - 1; index >= 0; index -= 1) { - const candidate = selectedEntries[index]; - if (navigableRowKeys.includes(candidate)) { - resolvedKey = candidate; - break; - } - } - - if (!resolvedKey && navigableRows.length) { - resolvedKey = navigableRows[0].key; - } - - if (!resolvedKey) { - return; - } - - if (selectedEntries.length === 0) { - return; - } - - setFocusedRowKey(resolvedKey); - - if (!selectedEntries.includes(resolvedKey) && selectedEntries.length > 0) { - applySelection([resolvedKey], { anchor: resolvedKey, interactedKeys: [resolvedKey] }); - } - }, [ - focusedRowKey, - navigableRowKeys, - selectedEntries, - navigableRows, - applySelection, - setFocusedRowKey, - ]); - - const handleDocumentListKeyDown = useCallback( - (event) => { - const { key, shiftKey } = event; - const triggers = ['ArrowUp', 'ArrowDown', 'Home', 'End', 'Enter', ' ', 'Space', 'Spacebar']; - if (!triggers.includes(key)) { - return; - } - - if (!navigableRows.length) { - return; - } - - event.preventDefault(); - - let activeKey = - focusedRowKey && navigableRowKeys.includes(focusedRowKey) - ? focusedRowKey - : null; - - let initializedFromEmptyState = false; - - if (!activeKey) { - for (let index = selectedEntries.length - 1; index >= 0; index -= 1) { - const candidate = selectedEntries[index]; - if (navigableRowKeys.includes(candidate)) { - activeKey = candidate; - break; - } - } - } - - if (!activeKey) { - if (key === 'ArrowUp') { - const lastKey = navigableRowKeys[navigableRowKeys.length - 1]; - if (!lastKey) { - return; - } - activeKey = lastKey; - } else { - activeKey = navigableRowKeys[0]; - } - setFocusedRowKey(activeKey); - initializedFromEmptyState = true; - } - - let currentIndex = navigableRowKeys.indexOf(activeKey); - - if (key === 'Enter' || key === ' ' || key === 'Space' || key === 'Spacebar') { - const row = currentIndex === -1 ? navigableRows[0] : navigableRows[currentIndex]; - if (!row) { - return; - } - handleRowSelection(row.key, event); - if (row.type === 'folder') { - selectFolder(row.id); - } else if (row.type === 'document') { - openDocumentPreview(row.id); - } - return; - } - - let nextIndex = currentIndex; - - if (key === 'ArrowDown') { - if (initializedFromEmptyState && selectedEntries.length === 0) { - handleRowSelection(activeKey, { - shiftKey, - preventDefault: () => {}, - }); - return; - } - nextIndex = currentIndex === -1 ? 0 : Math.min(currentIndex + 1, navigableRows.length - 1); - } else if (key === 'ArrowUp') { - if (initializedFromEmptyState && selectedEntries.length === 0) { - handleRowSelection(activeKey, { - shiftKey, - preventDefault: () => {}, - }); - return; - } - nextIndex = currentIndex === -1 ? navigableRows.length - 1 : Math.max(currentIndex - 1, 0); - } else if (key === 'Home') { - nextIndex = 0; - } else if (key === 'End') { - nextIndex = navigableRows.length - 1; - } - - if (nextIndex === -1 || nextIndex >= navigableRows.length) { - return; - } - - if (nextIndex === currentIndex && key !== 'Home' && key !== 'End') { - return; - } - - const targetRow = navigableRows[nextIndex]; - if (!targetRow) { - return; - } - - setFocusedRowKey(targetRow.key); - - handleRowSelection(targetRow.key, { - shiftKey, - preventDefault: () => {}, - }); - }, - [ - navigableRows, - navigableRowKeys, - focusedRowKey, - selectedEntries, - handleRowSelection, - selectFolder, - openDocumentPreview, - setFocusedRowKey, - ], - ); - - useEffect(() => { - if (!previewDocumentId) return; - const handleKeyDown = (event) => { - if (event.key === 'Escape') { - closeDocumentPreview(); - } - }; - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [previewDocumentId, closeDocumentPreview]); - - useEffect(() => { - if (!previewDocumentId) { - return; - } - - let cancelled = false; - - ensurePreviewData(previewDocumentId).catch((error) => { - if (cancelled) { - return; - } - notifyApiError(error, 'Failed to open document preview.'); - closeDocumentPreview(); - }); - - return () => { - cancelled = true; - }; - }, [previewDocumentId, ensurePreviewData, notifyApiError, closeDocumentPreview]); - - const handleDocumentTitleUpdate = useCallback( - async (documentId, nextTitle) => { - const trimmed = nextTitle.trim(); - if (!trimmed) { - setStatusMessage('Document title cannot be empty.', 'error'); - return false; - } - - setLoading(true); - try { - const { data } = await api.patch(`/documents/${documentId}`, { title: trimmed }); - const updatedDocument = extractDocumentFromResponse(data); - - updateDocumentCaches(documentId, (doc) => { - if (updatedDocument) { - return { ...doc, ...updatedDocument }; - } - return { ...doc, title: trimmed }; - }); - - setStatusMessage('Document title updated.', 'success'); - return true; - } catch (error) { - const message = error.response?.data?.error || 'Failed to update document title.'; - notifyApiError(error, message); - return false; - } finally { - setLoading(false); - } - }, - [notifyApiError, setStatusMessage, updateDocumentCaches, extractDocumentFromResponse], - ); - - const handleDocumentIssuedUpdate = useCallback( - async (documentId, nextIssuedDate) => { - setLoading(true); - const payload = { issued_at: nextIssuedDate || null }; - try { - const { data } = await api.patch(`/documents/${documentId}`, payload); - const updatedDocument = extractDocumentFromResponse(data); - - updateDocumentCaches(documentId, (doc) => { - if (updatedDocument) { - return { ...doc, ...updatedDocument }; - } - return { ...doc, issued_at: payload.issued_at }; - }); - - const message = payload.issued_at ? 'Issued date updated.' : 'Issued date cleared.'; - setStatusMessage(message, 'success'); - return true; - } catch (error) { - const message = error.response?.data?.error || 'Failed to update issued date.'; - notifyApiError(error, message); - return false; - } finally { - setLoading(false); - } - }, - [extractDocumentFromResponse, notifyApiError, setStatusMessage, updateDocumentCaches], - ); - - const applyTagRemovalToCaches = useCallback( - (documentId, tagId) => { - if (!documentId || !tagId) { - return; - } - - updateDocumentCaches(documentId, (doc) => { - if (!Array.isArray(doc.tags)) { - return doc; - } - const nextTags = doc.tags.filter((tag) => tag.id !== tagId); - if (nextTags.length === doc.tags.length) { - return doc; - } - return { ...doc, tags: nextTags }; - }); - }, - [updateDocumentCaches], - ); - - const handleTagRemove = useCallback( - async (documentId, tagId, { refreshTagList = true, showMessage = true } = {}) => { - if (!documentId || !tagId) { - return false; - } - - try { - await api.delete(`/documents/${documentId}/tags/${tagId}`); - applyTagRemovalToCaches(documentId, tagId); - if (refreshTagList) { - await refreshTags(); - } - if (showMessage) { - setStatusMessage('Tag removed.', 'success'); - } - return true; - } catch (error) { - const message = error.response?.data?.error || 'Failed to remove tag.'; - notifyApiError(error, message); - return false; - } - }, - [refreshTags, notifyApiError, setStatusMessage, applyTagRemovalToCaches], - ); - - const handleTagAdd = useCallback( - async (document, label, extras = null) => { - const normalizedLabel = tagManager.normalizeLabel(label); - const optionCandidate = - extras && typeof extras === 'object' && 'option' in extras ? extras.option : null; - const input = - extras && typeof extras === 'object' && 'input' in extras ? extras.input : null; - - let tag = null; - if (optionCandidate && optionCandidate.id) { - tag = tags.find((item) => item.id === optionCandidate.id) || optionCandidate; - } - if (!tag) { - tag = - tags.find((item) => item.label.toLowerCase() === normalizedLabel.toLowerCase()) || null; - } - try { - if (!tag) { - const payload = tagManager.buildPayload({ label: normalizedLabel }); - const { data } = await api.post('/tags', payload); - tag = data; - await refreshTags(); - } - await api.post(`/documents/${document.id}/tags`, { tag_ids: [tag.id] }); - setStatusMessage('Tag assigned.', 'success'); - if (input && typeof input === 'object') { - input.value = ''; - } - await refreshCurrentFolder(); - } catch (error) { - notifyApiError(error, 'Failed to assign tag.'); - } - }, - [tags, refreshTags, refreshCurrentFolder, notifyApiError, setStatusMessage, tagManager], - ); - - const handleDocumentTagAttach = useCallback( - async ({ documentId, tagId, tag: tagData = null }) => { - if (!documentId || !tagId) { - return false; - } - - const resolveTagForCache = () => { - const lookupTag = tagLookupById.get(tagId); - const source = lookupTag ?? tagData; - if (!source || source.id == null || typeof source.label !== 'string') { - return null; - } - return { - id: source.id, - label: source.label, - color: Object.prototype.hasOwnProperty.call(source, 'color') ? source.color : null, - }; - }; - - try { - await api.post(`/documents/${documentId}/tags`, { tag_ids: [tagId] }); - updateDocumentCaches(documentId, (doc) => { - if (!doc) { - return doc; - } - const currentTags = Array.isArray(doc.tags) ? doc.tags : []; - if (currentTags.some((existing) => existing?.id === tagId)) { - return doc; - } - const resolvedTag = resolveTagForCache(); - if (!resolvedTag) { - return doc; - } - return { ...doc, tags: [...currentTags, resolvedTag] }; - }); - setStatusMessage('Tag assigned.', 'success'); - if (documentsViewMode !== 'desk') { - await refreshCurrentFolder(); - } - return true; - } catch (error) { - const message = error.response?.data?.error || 'Failed to assign tag.'; - notifyApiError(error, message); - return false; - } - }, - [ - refreshCurrentFolder, - documentsViewMode, - notifyApiError, - setStatusMessage, - updateDocumentCaches, - tagLookupById, - ], - ); - - const handleDocumentTagDrop = useCallback( - async (documentId, tag) => { - if (!documentId || !tag?.id) { - return; - } - - if (tag.sourceDocId && tag.sourceDocId === documentId) { - return; - } - - const attached = await handleDocumentTagAttach({ documentId, tagId: tag.id, tag }); - if (!attached) { - return; - } - - if (tag.sourceDocId && tag.sourceDocId !== documentId) { - await handleTagRemove(tag.sourceDocId, tag.id, { - refreshTagList: false, - showMessage: false, - }); - } - }, - [handleDocumentTagAttach, handleTagRemove], - ); - - const handleFolderDelete = useCallback( - async (folderId) => { - if (!token) { - setStatusMessage('Log in to manage folders.', 'error'); - return; - } - if (folderId === 'root') { - setStatusMessage('The root folder cannot be removed.', 'error'); - return; - } - setLoading(true); - try { - const contents = await ensureFolderData(folderId, { - force: true, - prefetchDepth: 1, - }); - const hasChildren = (contents.subfolders || []).length > 0; - const hasDocs = (contents.documents || []).length > 0; - if (hasChildren || hasDocs) { - setStatusMessage('Folder must be empty before it can be deleted.', 'error'); - return; - } - await api.delete(`/folders/${folderId}`); - setFolderNodes((prev) => { - const next = new Map(prev); - const node = next.get(folderId); - next.delete(folderId); - if (node) { - const parentId = node.parentId || 'root'; - const parentNode = next.get(parentId); - if (parentNode) { - const remaining = parentNode.children.filter((id) => id !== folderId); - next.set(parentId, { - ...parentNode, - children: remaining, - hasChildren: remaining.length > 0, - }); - } - } - return next; - }); - setFolderContents((prev) => { - const next = new Map(prev); - next.delete(folderId); - return next; - }); - if (selectedFolder === folderId) { - const node = folderNodes.get(folderId); - const parentId = node?.parentId || 'root'; - setSelectedFolder(parentId); - const parentContents = await ensureFolderData(parentId, { - force: true, - prefetchDepth: 1, - }); - applySelectedFolder(parentId, parentContents); - } else if (selectedFolder !== 'root') { - await ensureFolderData(selectedFolder, { force: true, prefetchDepth: 1 }); - } - setStatusMessage('Folder deleted.', 'success'); - } catch (error) { - const message = error.response?.data?.error || 'Failed to delete folder.'; - notifyApiError(error, message); - } finally { - setLoading(false); - } - }, - [ - token, - ensureFolderData, - selectedFolder, - folderNodes, - applySelectedFolder, - notifyApiError, - setStatusMessage, - ], - ); - - const handleFolderRename = useCallback( - async (folderId, nextName) => { - if (!token) { - setStatusMessage('Log in to rename folders.', 'error'); - return false; - } - if (!folderId || folderId === 'root') { - setStatusMessage('The root folder cannot be renamed.', 'error'); - return false; - } - const trimmed = typeof nextName === 'string' ? nextName.trim() : ''; - if (!trimmed) { - setStatusMessage('Folder name cannot be empty.', 'error'); - return false; - } - - setLoading(true); - try { - await api.patch(`/folders/${folderId}`, { name: trimmed }); - - setFolderNodes((prev) => { - const next = new Map(prev); - const node = next.get(folderId); - if (node) { - next.set(folderId, { ...node, name: trimmed }); - } - return next; - }); - - setFolderContents((prev) => { - if (!prev.has(folderId)) { - return prev; - } - const next = new Map(prev); - const existing = next.get(folderId) || {}; - const folderInfo = existing.folder - ? { ...existing.folder, name: trimmed } - : { id: folderId, name: trimmed }; - next.set(folderId, { ...existing, folder: folderInfo }); - return next; - }); - - setCurrentFolder((prev) => (prev?.id === folderId ? { ...prev, name: trimmed } : prev)); - - setStatusMessage('Folder renamed.', 'success'); - return true; - } catch (error) { - const message = error.response?.data?.error || 'Failed to rename folder.'; - notifyApiError(error, message); - return false; - } finally { - setLoading(false); - } - }, - [token, notifyApiError, setStatusMessage], - ); - - const handleFolderCreate = useCallback( - async (name) => { - if (!token) { - setStatusMessage('Log in to create folders.', 'error'); - return false; - } - if (!name.trim()) { - setStatusMessage('Folder name cannot be empty.', 'error'); - return false; - } - const payload = { - name: name.trim(), - parent_id: selectedFolder === 'root' ? null : selectedFolder, - }; - setLoading(true); - let succeeded = false; - try { - const { data } = await api.post('/folders', payload); - setStatusMessage('Folder created.', 'success'); - setFolderNodes((prev) => { - const next = new Map(prev); - const parentId = payload.parent_id || 'root'; - const parentNode = next.get(parentId); - if (parentNode) { - next.set(parentId, { - ...parentNode, - children: parentNode.children.concat([data.folder.id]), - loaded: true, - hasChildren: true, - }); - } - next.set(data.folder.id, { - id: data.folder.id, - name: data.folder.name, - parentId: parentId, - children: [], - expanded: false, - loaded: false, - hasChildren: false, - }); - return next; - }); - await ensureFolderData(selectedFolder, { force: true, prefetchDepth: 1 }); - await selectFolder(data.folder.id, { immediate: true }); - succeeded = true; - } catch (error) { - const message = error.response?.data?.error || 'Failed to create folder.'; - notifyApiError(error, message); - succeeded = false; - } finally { - setLoading(false); - } - return succeeded; - }, - [token, selectedFolder, ensureFolderData, notifyApiError, setStatusMessage, selectFolder], - ); - - const handlePromptCreateFolder = useCallback(async () => { - if (creatingFolder) { - return; - } - const input = window.prompt('New folder name'); - if (!input) { - return; - } - const trimmed = input.trim(); - if (!trimmed) { - setStatusMessage('Folder name cannot be empty.', 'error'); - return; - } - setCreatingFolder(true); - try { - const success = await handleFolderCreate(trimmed); - if (!success) { - setStatusMessage('Unable to create folder. Check the status message for details.', 'error'); - } - } finally { - setCreatingFolder(false); - } - }, [creatingFolder, handleFolderCreate, setStatusMessage]); - - const { managementModals, openTagsModal, openCorrespondentsModal } = useManagementModals({ - locationPathname: location.pathname, - tags, - refreshTags, - onTagCreate: handleTagCreate, - onTagUpdate: handleTagUpdate, - onTagDelete: handleTagDelete, - correspondents, - refreshCorrespondents, - onCorrespondentCreate: handleCorrespondentCreate, - onCorrespondentUpdate: handleCorrespondentUpdate, - onCorrespondentDelete: handleCorrespondentDelete, - setStatusMessage, + location, + shellRef, + dropOverlayState, + managementModals, + contextValue, + } = useDocumentsWorkspace({ + documentsViewMode: documentsPreferences.documentsViewMode, + documentsSortField: documentsPreferences.documentsSortField, + documentsSortDirection: documentsPreferences.documentsSortDirection, + documentsSortFieldRef: documentsPreferences.documentsSortFieldRef, + documentsSortDirectionRef: documentsPreferences.documentsSortDirectionRef, + onDocumentsViewModeChange: documentsPreferences.handleDocumentsViewModeChange, + onDocumentsSortFieldChange: documentsPreferences.handleDocumentsSortFieldChange, + onDocumentsSortDirectionToggle: documentsPreferences.handleDocumentsSortDirectionToggle, + searchIncludeDescendants: documentsPreferences.searchIncludeDescendants, + onToggleSearchIncludeDescendants: documentsPreferences.toggleSearchIncludeDescendants, + onSetSearchIncludeDescendants: documentsPreferences.setSearchIncludeDescendants, + sortRefreshReadyRef: documentsPreferences.sortRefreshReadyRef, + deskHelpOpen: documentsPreferences.deskHelpOpen, + setDeskHelpOpen: documentsPreferences.setDeskHelpOpen, + handleDeskExit: documentsPreferences.handleDeskExit, }); - const openSettings = useCallback(() => { - navigate('/settings'); - }, [navigate]); - - useEffect(() => { - if (!token) return undefined; - - if (!isFilterActive) { - setSearchResults(null); - setSearchLoading(false); - return undefined; - } - - let cancelled = false; - let started = false; - setSearchLoading(true); - - const debounce = setTimeout(async () => { - started = true; - setLoading(true); - try { - const params = {}; - const trimmedQuery = searchQuery.trim(); - if (trimmedQuery.length) { - params.query = trimmedQuery; - } - if (activeTagFilters.length) { - params.tags = activeTagFilters.join(','); - } - if (activeCorrespondentFilters.length) { - params.correspondents = activeCorrespondentFilters.join(','); - } - const folderIdentifier = selectedFolder === 'root' ? null : selectedFolder; - if (folderIdentifier) { - params.folder_id = folderIdentifier; - } - const { data } = await api.get('/documents', { params }); - if (cancelled) return; - - const results = assetManager.hydrateDocuments(data || []); - setSearchResults(results); - - if (!results.length) { - setSearchLoading(false); - setSelectedEntries([]); - setFocusedDocumentId(null); - selectionOrderRef.current = []; - setSelectionOrder([]); - selectionAnchorRef.current = null; - return; - } - - const resultKeys = results - .map((doc) => resolveDocumentRowKey(doc.id)) - .filter(Boolean); - - let targetKey = null; - let nextSelectionKeys = []; - - setSelectedEntries((previous) => { - const previousDocKeys = previous.filter(isDocumentRowKey); - const filtered = previousDocKeys.filter((key) => resultKeys.includes(key)); - if (filtered.length) { - targetKey = filtered[filtered.length - 1]; - nextSelectionKeys = filtered; - return filtered; - } - targetKey = null; - nextSelectionKeys = []; - return []; - }); - - selectionOrderRef.current = nextSelectionKeys; - setSelectionOrder(nextSelectionKeys); - - setFocusedDocumentId((previous) => { - if (previous && resultKeys.includes(resolveDocumentRowKey(previous))) { - return previous; - } - return null; - }); - - selectionAnchorRef.current = targetKey; - - // rely on hydrated search results; assets refresh on demand - } catch (error) { - if (cancelled) return; - notifyApiError(error, 'Search failed. Please try again.'); - setSearchResults(null); - } finally { - if (!cancelled && started) { - setLoading(false); - setSearchLoading(false); - } - } - }, 300); - - return () => { - cancelled = true; - clearTimeout(debounce); - if (started) { - setLoading(false); - setSearchLoading(false); - } - }; - }, [ - token, - isFilterActive, - searchQuery, - activeTagFilters, - activeCorrespondentFilters, - selectedFolder, - notifyApiError, - assetManager, - selectionOrderRef, - selectionAnchorRef, - setSelectedEntries, - setSelectionOrder, - setFocusedDocumentId, - ]); - - useEffect(() => { - if (!token) { - setDropOverlayState((prev) => ({ ...prev, active: false })); - dragCounterRef.current = 0; - return undefined; - } - - const handleDragEnter = (event) => { - if (!hasFiles(event)) return; - event.preventDefault(); - dragCounterRef.current += 1; - setDropOverlayState({ active: true, folderName: currentFolderName }); - }; - - const handleDragOver = (event) => { - if (!hasFiles(event)) return; - event.preventDefault(); - event.dataTransfer.dropEffect = 'copy'; - }; - - const handleDragLeave = (event) => { - if (!hasFiles(event)) return; - dragCounterRef.current = Math.max(0, dragCounterRef.current - 1); - if (dragCounterRef.current === 0) { - setDropOverlayState((prev) => ({ ...prev, active: false })); - } - }; - - const handleDrop = async (event) => { - if (!hasFiles(event)) return; - event.preventDefault(); - dragCounterRef.current = 0; - setDropOverlayState((prev) => ({ ...prev, active: false })); - await handleFileDrop(event.dataTransfer, selectedFolder); - }; - - const dropTarget = shellRef.current; - if (!dropTarget) { - return undefined; - } - - dropTarget.addEventListener('dragenter', handleDragEnter); - dropTarget.addEventListener('dragover', handleDragOver); - dropTarget.addEventListener('dragleave', handleDragLeave); - dropTarget.addEventListener('drop', handleDrop); - - return () => { - dropTarget.removeEventListener('dragenter', handleDragEnter); - dropTarget.removeEventListener('dragover', handleDragOver); - dropTarget.removeEventListener('dragleave', handleDragLeave); - dropTarget.removeEventListener('drop', handleDrop); - dragCounterRef.current = 0; - setDropOverlayState((prev) => ({ ...prev, active: false })); - }; - }, [token, handleFileDrop, currentFolderName, selectedFolder]); - - useEffect( - () => () => { - setTagRemovalCursor(false); - }, - [setTagRemovalCursor], - ); - - useEffect(() => { - const host = shellRef.current; - if (!host) { - return undefined; - } - - const isTagTransfer = (event) => isTagTransferEvent(event); - - const isDocumentDropTarget = (target) => - target instanceof Element ? Boolean(target.closest('[data-doc-id]')) : false; - - const handleTagDragOver = (event) => { - if (!isTagTransfer(event)) { - return; - } - if (isDocumentDropTarget(event.target)) { - setTagRemovalCursor(false); - return; - } - event.preventDefault(); - event.dataTransfer.dropEffect = 'move'; - setTagRemovalCursor(true); - }; - - const handleTagDragLeave = (event) => { - if (!isTagTransfer(event)) { - return; - } - const related = event.relatedTarget; - if (related instanceof Element && host.contains(related)) { - if (isDocumentDropTarget(related)) { - setTagRemovalCursor(false); - } - return; - } - setTagRemovalCursor(false); - }; - - const handleTagDrop = async (event) => { - if (!isTagTransfer(event)) { - return; - } - setTagRemovalCursor(false); - if (isDocumentDropTarget(event.target) || event.defaultPrevented) { - return; - } - event.preventDefault(); - event.stopPropagation(); - const raw = - event.dataTransfer.getData('application/x-papercrate-tag') || - event.dataTransfer.getData('text/papercrate-tag'); - if (!raw) { - return; - } - try { - const payload = JSON.parse(raw); - if (payload?.sourceDocId && payload?.id) { - await handleTagRemove(payload.sourceDocId, payload.id, { - refreshTagList: false, - showMessage: true, - }); - } - } catch (error) { - console.warn('Failed to remove tag from drop target', error); - } - }; - - const handleTagDragEnd = () => { - setTagRemovalCursor(false); - }; - - host.addEventListener('dragover', handleTagDragOver, true); - host.addEventListener('dragleave', handleTagDragLeave, true); - host.addEventListener('drop', handleTagDrop, true); - window.addEventListener('dragend', handleTagDragEnd, true); - - return () => { - host.removeEventListener('dragover', handleTagDragOver, true); - host.removeEventListener('dragleave', handleTagDragLeave, true); - host.removeEventListener('drop', handleTagDrop, true); - window.removeEventListener('dragend', handleTagDragEnd, true); - setTagRemovalCursor(false); - }; - }, [handleTagRemove, setTagRemovalCursor]); - - const handleLogout = useCallback(async () => { - try { - setLoading(true); - await api.post('/auth/logout'); - } catch (error) { - console.warn('[Auth] Failed to revoke refresh token during logout', error); - } finally { - setLoading(false); - appDispatch({ type: 'LOGOUT' }); - setStatusMessage('Logged out.', 'info'); - } - }, [appDispatch, setStatusMessage]); - - const folderClickHandlers = useMemo( - () => ({ - onToggle: async (folderId) => { - const node = folderNodes.get(folderId); - const nextExpanded = !(node?.expanded ?? false); - if (nextExpanded) { - try { - await ensureFolderData(folderId, { - includeDocuments: false, - prefetchDepth: 1, - }); - } catch (error) { - notifyApiError(error, 'Failed to load folder.'); - } - } else if (node && !node.loaded) { - try { - await ensureFolderData(folderId, { - includeDocuments: false, - prefetchDepth: 1, - }); - } catch (error) { - notifyApiError(error, 'Failed to load folder.'); - } - } - setFolderNodes((prev) => { - const next = new Map(prev); - const current = next.get(folderId); - if (!current) return prev; - next.set(folderId, { ...current, expanded: nextExpanded }); - return next; - }); - }, - onSelect: selectFolder, - onDrop: async (event, folderId) => { - event.preventDefault(); - event.stopPropagation(); - event.currentTarget.classList.remove('is-drop-target'); - - let folderIds = []; - try { - const rawFolderList = event.dataTransfer.getData('application/x-papercrate-folder-list'); - if (rawFolderList) { - const parsed = JSON.parse(rawFolderList); - if (Array.isArray(parsed)) { - folderIds = parsed.filter(Boolean); - } - } - } catch (error) { - console.warn('[folders] Failed to parse folder list drag payload', error); - } - - if (!folderIds.length) { - let folderSourceId = draggedFolderId; - if (!folderSourceId) { - try { - if (event.dataTransfer.types?.includes('application/x-papercrate-folder')) { - folderSourceId = event.dataTransfer.getData('application/x-papercrate-folder'); - } - } catch (error) { - console.warn('[folders] Failed to read folder id from drag payload', error); - } - } - - if (folderSourceId) { - folderIds = [folderSourceId]; - } - } - - folderIds = Array.from(new Set(folderIds.filter(Boolean))); - - if (folderIds.length) { - setDraggedFolderId(null); - const invalidMove = folderIds.some((sourceId) => isInvalidFolderDrop(sourceId, folderId)); - if (invalidMove) { - setStatusMessage( - 'Cannot move a folder into itself or one of its descendants.', - 'error', - ); - return; - } - - for (const sourceId of folderIds) { - // eslint-disable-next-line no-await-in-loop - await moveFolder(sourceId, folderId); - } - } - - if (hasFiles(event)) { - await handleFileDrop(event.dataTransfer, folderId); - return; - } - - let docIds = []; - try { - const raw = event.dataTransfer.getData('application/x-papercrate-doc-list'); - if (raw) { - const parsed = JSON.parse(raw); - if (Array.isArray(parsed)) { - docIds = parsed.filter(Boolean); - } - } - } catch (error) { - console.warn('[documents] Failed to parse document list drag payload', error); - } - - if (!docIds.length) { - try { - const single = event.dataTransfer.getData('application/x-papercrate-doc'); - if (single) { - docIds = [single]; - } - } catch (error) { - console.warn('[documents] Failed to read single document drag payload', error); - } - } - - if (!docIds.length && draggedDocumentIds.length) { - docIds = draggedDocumentIds; - } - - docIds = Array.from(new Set(docIds)); - - if (!docIds.length || folderId === selectedFolder) { - return; - } - - setDraggedDocumentIds([]); - await moveDocumentsToFolder(docIds, folderId); - }, - onDragOver: (event, folderId) => { - const folderDragActive = Boolean(draggedFolderId); - if (folderDragActive && isInvalidFolderDrop(draggedFolderId, folderId)) { - return; - } - - if (hasFiles(event)) { - event.preventDefault(); - event.dataTransfer.dropEffect = 'copy'; - event.currentTarget.classList.add('is-drop-target'); - return; - } - - if (draggedDocumentIds.length || folderDragActive) { - event.preventDefault(); - event.dataTransfer.dropEffect = 'move'; - event.currentTarget.classList.add('is-drop-target'); - } - }, - onDragLeave: (event) => { - event.currentTarget.classList.remove('is-drop-target'); - }, - }), - [ - draggedDocumentIds, - draggedFolderId, - ensureFolderData, - folderNodes, - handleFileDrop, - isInvalidFolderDrop, - moveDocumentsToFolder, - moveFolder, - notifyApiError, - selectFolder, - selectedFolder, - setDraggedDocumentIds, - setDraggedFolderId, - setFolderNodes, - setStatusMessage, - ], - ); - - const selectedDocument = useMemo(() => { - if (!focusedDocumentId) { - return null; - } - const list = searchResults ?? documents; - return list.find((doc) => doc.id === focusedDocumentId) || null; - }, [searchResults, documents, focusedDocumentId]); - - const handleDocumentDelete = useCallback( - async (documentId) => { - if (!documentId) return; - if (!token) { - setStatusMessage('Log in to manage documents.', 'error'); - return; - } - - const doc = documentLookup.get(documentId) || null; - const label = doc?.title; - - const confirmed = window.confirm(`Move "${label}" to trash? You can restore it from trash later.`); - if (!confirmed) { - return; - } - - setLoading(true); - try { - await api.delete(`/documents/${documentId}`); - - removeDocumentFromCaches(documentId); - - setPreviewEntries((prev) => { - if (!prev.has(documentId)) { - return prev; - } - const next = new Map(prev); - next.delete(documentId); - return next; - }); - previewInflightRef.current.delete(documentId); - - if (selectedDocumentIds.includes(documentId)) { - const remainingRowKeys = selectedDocumentIds - .filter((id) => id !== documentId) - .map((id) => resolveDocumentRowKey(id)) - .filter(Boolean); - const removedKey = resolveDocumentRowKey(documentId); - applySelection(remainingRowKeys, { - anchor: null, - interactedKeys: removedKey ? [removedKey] : [], - }); - } - - if (previewDocumentId === documentId) { - closeDocumentPreview(); - } - - setStatusMessage('Document deleted.', 'success'); - } catch (error) { - const message = error.response?.data?.error || 'Failed to delete document.'; - notifyApiError(error, message); - } finally { - setLoading(false); - } - }, - [ - token, - documentLookup, - setStatusMessage, - removeDocumentFromCaches, - setPreviewEntries, - previewInflightRef, - previewDocumentId, - closeDocumentPreview, - selectedDocumentIds, - applySelection, - notifyApiError, - ], - ); - - const orderedSelectedDocuments = useMemo(() => { - const ordered = []; - const seen = new Set(); - const pushDoc = (doc) => { - if (doc?.id && !seen.has(doc.id)) { - ordered.push(doc); - seen.add(doc.id); - } - }; - - selectionOrder.forEach((key) => { - if (!isDocumentRowKey(key)) { - return; - } - const docId = getRowId(key); - const doc = documentLookup.get(docId) || null; - pushDoc(doc); - }); - - selectedDocumentIds.forEach((id) => { - if (seen.has(id)) return; - const doc = documentLookup.get(id) || null; - pushDoc(doc); - }); - - return ordered; - }, [selectionOrder, documentLookup, selectedDocumentIds]); - - const { - detailPanelOpen, - detailPanelSelectedDocuments, - openDetailPanel, - closeDetailPanel, - } = useDetailPanel({ - selectedDocumentIds, - documentLookup, - orderedSelectedDocuments, - selectionOrder, - documentsViewMode, - getRowId, - isDocumentRowKey, - }); - - detailPanelControlRef.current = { - open: openDetailPanel, - close: closeDetailPanel, - }; - - const { breadcrumbs, missingBreadcrumbAncestors } = useMemo(() => { - const chain = []; - const seen = new Set(); - const pending = new Set(); - let currentId = selectedFolder || 'root'; - let guard = 0; - - while (currentId && !seen.has(currentId) && guard < 32) { - guard += 1; - seen.add(currentId); - - if (currentId === 'root') { - chain.push({ id: 'root', name: DEFAULT_FOLDER_NAME }); - currentId = null; - break; - } - - const node = folderNodes.get(currentId); - if (node) { - chain.push({ id: currentId, name: node.name || 'Folder' }); - currentId = node.parentId ?? 'root'; - continue; - } - - let fallbackName = '…'; - let parentId = null; - - if (currentFolder && currentFolder.id === currentId) { - fallbackName = currentFolder.name; - parentId = currentFolder.parent_id ?? 'root'; - } - - chain.push({ id: currentId, name: fallbackName }); - pending.add(currentId); - currentId = parentId; - } - - if (!chain.some((crumb) => crumb.id === 'root')) { - chain.push({ id: 'root', name: DEFAULT_FOLDER_NAME }); - } - - const ordered = []; - const seenOrdered = new Set(); - chain - .slice() - .reverse() - .forEach((crumb) => { - if (!seenOrdered.has(crumb.id)) { - seenOrdered.add(crumb.id); - ordered.push(crumb); - } - }); - - return { breadcrumbs: ordered, missingBreadcrumbAncestors: Array.from(pending) }; - }, [selectedFolder, folderNodes, currentFolder]); - - useEffect(() => { - if (!missingBreadcrumbAncestors.length) { - return; - } - - missingBreadcrumbAncestors.forEach((folderId) => { - if (!folderId || folderId === 'root') { - return; - } - if (breadcrumbFetchRef.current.has(folderId)) { - return; - } - - breadcrumbFetchRef.current.add(folderId); - ensureFolderData(folderId, { force: false }) - .catch((error) => { - console.warn('Failed to preload breadcrumb ancestor', folderId, error); - }) - .finally(() => { - breadcrumbFetchRef.current.delete(folderId); - }); - }); - }, [missingBreadcrumbAncestors, ensureFolderData]); - - useEffect(() => { - if (!orderedSelectedDocuments.length) { - return; - } - - const visited = new Set(); - - orderedSelectedDocuments.forEach((doc) => { - const folderId = doc?.folder_id; - if (!folderId) { - return; - } - let currentId = folderId; - let guard = 0; - while (currentId && currentId !== 'root' && guard < 32) { - guard += 1; - if (visited.has(currentId)) { - break; - } - visited.add(currentId); - const node = folderNodes.get(currentId); - if (!node) { - if (!detailFolderFetchRef.current.has(currentId)) { - detailFolderFetchRef.current.add(currentId); - ensureFolderData(currentId, { force: false, includeDocuments: false }) - .catch((error) => { - console.warn('Failed to preload folder metadata for detail path', currentId, error); - }) - .finally(() => { - detailFolderFetchRef.current.delete(currentId); - }); - } - break; - } - - const parentId = node.parentId ?? 'root'; - if (!parentId || parentId === 'root') { - break; - } - currentId = parentId; - } - }); - }, [orderedSelectedDocuments, folderNodes, ensureFolderData]); - - const resolveFolderPath = useCallback( - (folderId) => { - if (!folderId || folderId === 'root') { - return []; - } - - const segments = []; - const visited = new Set(); - let currentId = folderId; - let guard = 0; - - while (currentId && guard < 32 && !visited.has(currentId)) { - guard += 1; - visited.add(currentId); - - if (currentId === 'root') { - break; - } - - const node = folderNodes.get(currentId); - if (!node) { - segments.push({ id: currentId, name: '…' }); - break; - } - - segments.push({ id: node.id, name: node.name || 'Folder' }); - - const parentId = node.parentId ?? 'root'; - if (!parentId || parentId === 'root') { - segments.push({ id: 'root', name: DEFAULT_FOLDER_NAME }); - break; - } - - currentId = parentId; - } - - if (!segments.some((segment) => segment.id === 'root')) { - segments.push({ id: 'root', name: DEFAULT_FOLDER_NAME }); - } - - return segments.reverse(); - }, - [folderNodes], - ); - - const selectedPreviewEntry = useMemo(() => { - if (!selectedDocument) { - return null; - } - return previewEntries.get(selectedDocument.id) || null; - }, [selectedDocument, previewEntries]); - - const previewWorkspaceEntry = useMemo(() => { - if (!previewDocumentId) { - return null; - } - return previewEntries.get(previewDocumentId) || null; - }, [previewDocumentId, previewEntries]); - - const previewWorkspaceDocument = useMemo(() => { - if (!previewDocumentId) return null; - const pool = searchResults ?? documents; - return pool.find((doc) => doc.id === previewDocumentId) || null; - }, [previewDocumentId, searchResults, documents]); - - const previewActive = Boolean(previewDocumentId && previewWorkspaceDocument); - - const resolveThumbnailUrlForDoc = useCallback( - (doc) => - resolveDocumentAssetUrl(doc, 'thumbnail', { - ensureAssetUrl, - getAsset: getDocumentAsset, - }), - [ensureAssetUrl, getDocumentAsset], - ); - - const handleDocumentsViewModeChange = useCallback((mode) => { - const next = mode === 'grid' ? 'grid' : mode === 'desk' ? 'desk' : 'list'; - setDocumentsViewMode((previous) => { - if (next !== previous && typeof window !== 'undefined') { - try { - window.sessionStorage.setItem('papercrate_view_mode', next); - } catch (error) { - console.warn('[view-mode] failed to persist mode', error); - } - } - return next; - }); - }, []); - - const handleDeskExit = useCallback(() => { - const fallback = lastNonDeskViewRef.current && lastNonDeskViewRef.current !== 'desk' - ? lastNonDeskViewRef.current - : 'list'; - handleDocumentsViewModeChange(fallback); - }, [handleDocumentsViewModeChange]); - - const handleTenantSelect = useCallback( - async (tenantOption, { refreshOnly = false } = {}) => { - const requestedTenantId = tenantOption?.id ?? null; - if (!refreshOnly && (!requestedTenantId || requestedTenantId === currentTenantId)) { - return; - } - - setLoading(true); - try { - if (!refreshOnly) { - setStatusMessage('Switching tenant…', 'info'); - } - - if (refreshOnly) { - const { data } = await api.get('/auth/tenants'); - appDispatch({ - type: 'SET_TENANTS', - tenants: Array.isArray(data?.tenants) ? data.tenants : [], - }); - return; - } - - const { data } = await api.post('/auth/select-tenant', { tenant_id: requestedTenantId }); - if (!data?.access_token) { - throw new Error('Missing access token in tenant switch response.'); - } - - appDispatch({ type: 'LOGOUT' }); - resetWorkspaceState(); - - appDispatch({ - type: 'LOGIN_SUCCESS', - token: data.access_token, - tenant: data.tenant || null, - }); - - api.defaults.headers.common.Authorization = `Bearer ${data.access_token}`; - tokenRef.current = data.access_token; - tenantIdRef.current = data?.tenant?.id ?? null; - - if (Array.isArray(data?.tenants)) { - appDispatch({ type: 'SET_TENANTS', tenants: data.tenants }); - } - - handleDocumentsViewModeChange('list'); - navigate('/documents', { replace: true }); - - await Promise.all([refreshTags(), refreshCorrespondents()]); - await loadFolder('root', { showLoading: false, preserveSearch: false }); - - const tenantLabel = data?.tenant?.name || data?.tenant?.id || 'tenant'; - setStatusMessage(`Switched to ${tenantLabel}.`, 'info'); - } catch (error) { - notifyApiError(error, 'Failed to switch tenant.'); - } finally { - setLoading(false); - } - }, - [ - currentTenantId, - appDispatch, - notifyApiError, - setStatusMessage, - resetWorkspaceState, - handleDocumentsViewModeChange, - navigate, - refreshTags, - refreshCorrespondents, - loadFolder, - ], - ); - - const documentsTableProps = useMemo( - () => ({ - currentFolderName, - breadcrumbs, - onRefresh: refreshCurrentFolder, - subfolders: currentSubfolders, - documents, - searchResults, - isFilterActive, - onFolderSelect: selectFolder, - onFolderDrop: folderClickHandlers.onDrop, - onFolderDragOver: folderClickHandlers.onDragOver, - onFolderDragLeave: folderClickHandlers.onDragLeave, - onFolderDragStart: handleFolderDragStart, - onFolderDragEnd: handleFolderDragEnd, - draggedFolderId, - onFolderDelete: handleFolderDelete, - onFolderRename: handleFolderRename, - onDocumentOpen: openDocumentPreview, - onDocumentDelete: handleDocumentDelete, - onDocumentRename: handleDocumentTitleUpdate, - selectedDocumentIds, - selectedFolderIds, - focusedRowKey, - draggingDocumentIds: draggedDocumentIds, - onDocumentDragStart: handleDocumentDragStart, - onDocumentDragEnd: handleDocumentDragEnd, - isSearchLoading: searchLoading, - tagLookupById, - activeCorrespondentIds: activeCorrespondentFilters, - onDocumentListFocus: handleDocumentListFocus, - onDocumentListKeyDown: handleDocumentListKeyDown, - onFocusedRowChange: setFocusedRowKey, - ensureAssetUrl, - getDocumentAsset, - getDownloadHref: (doc) => - doc?.current_version?.download_path - ? resolveApiPath(doc.current_version.download_path) - : null, - onTagClick: toggleTagFilter, - onCorrespondentClick: toggleCorrespondentFilter, - onDocumentTagDrop: handleDocumentTagDrop, - viewMode: documentsViewMode, - onViewModeChange: handleDocumentsViewModeChange, - onClearSelection: clearDocumentSelection, - onRowSelection: handleRowSelection, - onOpenDetailPanel: openDetailPanel, - }), - [ - activeCorrespondentFilters, - breadcrumbs, - clearDocumentSelection, - currentFolderName, - currentSubfolders, - documents, - documentsViewMode, - draggedDocumentIds, - draggedFolderId, - focusedRowKey, - folderClickHandlers, - handleDocumentDelete, - handleDocumentDragEnd, - handleDocumentDragStart, - handleDocumentListFocus, - handleDocumentListKeyDown, - handleDocumentTagDrop, - handleDocumentTitleUpdate, - handleDocumentsViewModeChange, - handleFolderDelete, - handleFolderDragEnd, - handleFolderDragStart, - handleFolderRename, - handleRowSelection, - isFilterActive, - openDetailPanel, - openDocumentPreview, - refreshCurrentFolder, - searchLoading, - searchResults, - selectFolder, - selectedDocumentIds, - selectedFolderIds, - setFocusedRowKey, - tagLookupById, - toggleCorrespondentFilter, - toggleTagFilter, - ensureAssetUrl, - getDocumentAsset, - ], - ); - - const sidebarProps = useMemo( - () => ({ - folderNodes, - onToggle: folderClickHandlers.onToggle, - onSelect: folderClickHandlers.onSelect, - onDrop: folderClickHandlers.onDrop, - onDragOver: folderClickHandlers.onDragOver, - onDragLeave: folderClickHandlers.onDragLeave, - onDeleteFolder: handleFolderDelete, - onRenameFolder: handleFolderRename, - selectedFolder, - onFolderDragStart: handleFolderDragStart, - onFolderDragEnd: handleFolderDragEnd, - draggedFolderId, - onCreateFolder: handlePromptCreateFolder, - creatingFolder, - tags, - activeTagIds: activeTagFilters, - onToggleTagFilter: toggleTagFilter, - onCreateTag: (label) => handleTagCreate({ label }), - correspondents, - activeCorrespondentIds: activeCorrespondentFilters, - onToggleCorrespondentFilter: toggleCorrespondentFilter, - onCreateCorrespondent: (name) => handleCorrespondentCreate({ name }), - appStatus, - loading, - previewActive, - searchQuery, - onSearchChange: handleSearchChange, - onSearchSubmit: handleSearchSubmit, - onSearchClear: clearFilters, - isFilterActive, - onLogout: handleLogout, - status, - tenantName, - tenants: tenantOptions, - activeTenantId: currentTenantId, - onSelectTenant: handleTenantSelect, - onOpenSettings: openSettings, - }), - [ - activeCorrespondentFilters, - activeTagFilters, - appStatus, - clearFilters, - correspondents, - currentTenantId, - folderClickHandlers, - folderNodes, - handleFolderDelete, - handleFolderDragEnd, - handleFolderDragStart, - handleFolderRename, - handleLogout, - handleSearchChange, - handleSearchSubmit, - handleTenantSelect, - loading, - openSettings, - previewActive, - searchQuery, - draggedFolderId, - isFilterActive, - selectedFolder, - status, - tags, - tenantOptions, - tenantName, - toggleCorrespondentFilter, - toggleTagFilter, - handleTagCreate, - handleCorrespondentCreate, - handlePromptCreateFolder, - creatingFolder, - ], - ); - - const handleDetailPanelClose = useCallback(() => { - closeDetailPanel(); - }, [closeDetailPanel]); - - const detailPanelProps = useMemo( - () => ({ - selectedDocuments: detailPanelSelectedDocuments, - tags, - tagLookupById, - onTagAdd: handleTagAdd, - onTagRemove: handleTagRemove, - onRegenerateThumbnails: handleThumbnailRegeneration, - previewEntry: selectedPreviewEntry, - onOpenPreview: openDocumentPreview, - onBulkTagAdd: handleBulkTagAddFromDetail, - onBulkTagRemove: handleBulkTagRemoveFromDetail, - onBulkReanalyze: handleBulkSelectionReanalyze, - onBulkCorrespondentAdd: handleBulkCorrespondentAdd, - onBulkCorrespondentRemove: handleBulkCorrespondentRemove, - onPromoteSelection: promoteSelectionOrder, - activePreviewId, - onUpdateTitle: handleDocumentTitleUpdate, - onUpdateIssued: handleDocumentIssuedUpdate, - ensureAssetUrl, - getDocumentAsset, - ensurePreviewData, - correspondents, - onCorrespondentAdd: handleCorrespondentAdd, - onCorrespondentRemove: handleCorrespondentRemove, - resolveApiPath, - onFolderNavigate: selectFolder, - onClose: handleDetailPanelClose, - resolveFolderPath, - }), - [ - activePreviewId, - correspondents, - detailPanelSelectedDocuments, - ensureAssetUrl, - ensurePreviewData, - getDocumentAsset, - handleBulkCorrespondentAdd, - handleBulkCorrespondentRemove, - handleBulkSelectionReanalyze, - handleBulkTagAddFromDetail, - handleBulkTagRemoveFromDetail, - handleCorrespondentAdd, - handleCorrespondentRemove, - handleDetailPanelClose, - handleDocumentTitleUpdate, - handleDocumentIssuedUpdate, - handleTagAdd, - handleTagRemove, - handleThumbnailRegeneration, - openDocumentPreview, - promoteSelectionOrder, - resolveFolderPath, - selectFolder, - selectedPreviewEntry, - tags, - tagLookupById, - ], - ); - - const handleDeskInspectDocument = useCallback( - (docId) => { - if (!docId) { - return; - } - const rowKey = resolveDocumentRowKey(docId); - if (rowKey) { - applySelection([rowKey], { anchor: rowKey, interactedKeys: [rowKey] }); - } - openDetailPanel({ documentIds: [docId] }); - }, - [applySelection, openDetailPanel], - ); - - const handleDeskDocumentPointerSelect = useCallback( - (docId, event) => { - if (!docId) { - return; - } - const rowKey = resolveDocumentRowKey(docId); - if (!rowKey) { - return; - } - handleRowSelection(rowKey, event); - }, - [handleRowSelection], - ); - - const handleDeskDocumentStackSelect = useCallback( - (docIds) => { - if (!Array.isArray(docIds) || docIds.length === 0) { - return; - } - - const rowKeys = docIds - .map((id) => resolveDocumentRowKey(id)) - .filter(Boolean); - - if (!rowKeys.length) { - return; - } - - applySelection(rowKeys, { - anchor: rowKeys[0], - interactedKeys: rowKeys, - }); - }, - [applySelection], - ); - - const handleDeskHelpOpen = useCallback(() => { - setDeskHelpOpen(true); - }, []); - - const handleDeskHelpClose = useCallback(() => { - setDeskHelpOpen(false); - }, []); - - const deskWorkspaceProps = useMemo( - () => ({ - documents, - searchResults, - breadcrumbs, - currentFolderName, - viewMode: documentsViewMode, - onViewModeChange: handleDocumentsViewModeChange, - onExit: handleDeskExit, - onRefresh: refreshCurrentFolder, - onDocumentOpen: openDocumentPreview, - onInspectDocument: handleDeskInspectDocument, - onDocumentPointerSelect: handleDeskDocumentPointerSelect, - onDocumentStackSelect: handleDeskDocumentStackSelect, - onOpenHelp: handleDeskHelpOpen, - helpOpen: deskHelpOpen, - onHelpClose: handleDeskHelpClose, - tenantId: currentTenantId, - selectedDocumentIds, - onClearSelection: clearDocumentSelection, - detailPanelOpen, - onCloseDetailPanel: handleDetailPanelClose, - resolveThumbnailUrl: resolveThumbnailUrlForDoc, - onAssignTagToDocument: handleDocumentTagAttach, - onRemoveTagFromDocument: handleTagRemove, - ensureAssetUrl, - getDocumentAsset, - activeTagIds: activeTagFilters, - }), - [ - documents, - searchResults, - breadcrumbs, - currentFolderName, - documentsViewMode, - handleDocumentsViewModeChange, - handleDeskExit, - refreshCurrentFolder, - openDocumentPreview, - handleDeskInspectDocument, - handleDeskDocumentPointerSelect, - handleDeskDocumentStackSelect, - handleDeskHelpOpen, - handleDeskHelpClose, - currentTenantId, - deskHelpOpen, - selectedDocumentIds, - clearDocumentSelection, - detailPanelOpen, - handleDetailPanelClose, - resolveThumbnailUrlForDoc, - handleDocumentTagAttach, - handleTagRemove, - ensureAssetUrl, - getDocumentAsset, - activeTagFilters, - ], - ); - - const contextValue = useMemo( - () => ({ - token, - appStatus, - status, - setStatusMessage, - dropOverlayState, - handleLogout, - sidebarProps, - tags, - refreshTags, - handleTagUpdate, - handleTagDelete, - handleDocumentTagAttach, - correspondents, - refreshCorrespondents, - handleCorrespondentUpdate, - handleCorrespondentCreate, - handleCorrespondentDelete, - handleDocumentCorrespondentAttach, - handleCorrespondentRemove, - handleCorrespondentAdd, - passkeys, - passkeysSupported, - passkeysLoading, - registeringPasskey, - revokingPasskeyId, - refreshPasskeys, - registerPasskey, - revokePasskey, - previewActive, - previewWorkspaceDocument, - previewWorkspaceEntry, - previewDocumentId, - closeDocumentPreview, - handleThumbnailRegeneration, - documentsTableProps, - detailPanelProps, - documentsViewMode, - deskWorkspaceProps, - ensurePreviewData, - ensureAssetUrl, - resolveApiPath, - getDocumentAsset, - notifyApiError, - openTagsModal, - openCorrespondentsModal, - openSettings, - detailPanelOpen, - openDetailPanel, - }), - [ - token, - appStatus, - status, - setStatusMessage, - dropOverlayState, - handleLogout, - sidebarProps, - tags, - refreshTags, - handleTagUpdate, - handleTagDelete, - handleDocumentTagAttach, - correspondents, - refreshCorrespondents, - handleCorrespondentUpdate, - handleCorrespondentCreate, - handleCorrespondentDelete, - handleDocumentCorrespondentAttach, - handleCorrespondentRemove, - handleCorrespondentAdd, - passkeys, - passkeysSupported, - passkeysLoading, - registeringPasskey, - revokingPasskeyId, - refreshPasskeys, - registerPasskey, - revokePasskey, - previewActive, - previewWorkspaceDocument, - previewWorkspaceEntry, - previewDocumentId, - closeDocumentPreview, - handleThumbnailRegeneration, - documentsTableProps, - detailPanelProps, - documentsViewMode, - deskWorkspaceProps, - ensurePreviewData, - ensureAssetUrl, - getDocumentAsset, - notifyApiError, - openTagsModal, - openCorrespondentsModal, - openSettings, - detailPanelOpen, - openDetailPanel, - ], - ); - if (['logged-out', 'authenticating', 'selecting-tenant'].includes(appStatus)) { const shouldRememberLastLocation = appStatus !== 'logged-out'; return ( @@ -5055,5 +61,4 @@ const AppLayout = () => { ); }; - export default AppLayout; diff --git a/frontend/src/app/DocumentsRoute.jsx b/frontend/src/app/DocumentsRoute.jsx index e54a2a9..6fcc108 100644 --- a/frontend/src/app/DocumentsRoute.jsx +++ b/frontend/src/app/DocumentsRoute.jsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAppShell } from '../appShellContext'; import DocumentsLayout from './DocumentsLayout'; @@ -21,7 +21,6 @@ const DocumentsRouteContent = () => { previewWorkspaceEntry, previewDocumentId, closeDocumentPreview, - handleThumbnailRegeneration, ensurePreviewData, resolveApiPath, ensureAssetUrl, @@ -84,12 +83,18 @@ const DocumentsRouteContent = () => { getDocumentAsset, resolveApiPath, notifyApiError, - handleThumbnailRegeneration, closeDocumentPreview, parentBreadcrumb, onNavigateParent: handleNavigateParent, }); + useEffect(() => { + document.body.classList.add('has-main-content'); + return () => { + document.body.classList.remove('has-main-content'); + }; + }, []); + if (!surface) { return ( @@ -141,13 +146,23 @@ const DocumentsRouteContent = () => {
{header ? ( - +
+ {(header.selectionLabel || header.floatingActions) ? ( +
+ {header.selectionLabel ? ( + {header.selectionLabel} + ) : null} + {header.floatingActions || null} +
+ ) : null} + +
) : null}
{surface.content}
{surface.detail || null} diff --git a/frontend/src/app/SettingsRoute.jsx b/frontend/src/app/SettingsRoute.jsx index ad6b8ac..165bfa0 100644 --- a/frontend/src/app/SettingsRoute.jsx +++ b/frontend/src/app/SettingsRoute.jsx @@ -3,6 +3,8 @@ import { useNavigate } from 'react-router-dom'; import SettingsModal from '../settings/SettingsModal'; import { useAppShell } from '../appShellContext'; import useApiTokens from '../settings/useApiTokens'; +import useCapabilitySets from '../settings/useCapabilitySets'; +import useCapabilities from '../settings/useCapabilities'; import { api } from './appState'; const SettingsRoute = () => { @@ -23,24 +25,49 @@ const SettingsRoute = () => { const { tokens, - loading, - creating, + loading: tokensLoading, + creating: creatingToken, deletingId, regeneratingId, - updatingId, createdSecret, - refresh, - create, - revoke, - regenerate, - updateCapabilities, + refresh: refreshTokens, + create: createToken, + revoke: revokeToken, + regenerate: regenerateToken, dismissSecret, } = useApiTokens({ api, token, notifyApiError, setStatusMessage }); + const { + capabilitySets, + capabilitySetsLoading, + creatingCapabilitySet, + savingCapabilitySetId, + deletingCapabilitySetId, + supportsCapabilitySetLabels, + refreshCapabilitySets, + createCapabilitySet, + updateCapabilitySet, + deleteCapabilitySet, + } = useCapabilitySets({ api, token, notifyApiError, setStatusMessage }); + + const { + capabilities, + capabilitiesLoading, + refreshCapabilities, + } = useCapabilities({ api, notifyApiError, token }); + useEffect(() => { - refresh(); + refreshTokens(); + refreshCapabilitySets(); + refreshCapabilities(); refreshPasskeys(); - }, [refresh, refreshPasskeys]); + }, [refreshTokens, refreshCapabilitySets, refreshCapabilities, refreshPasskeys]); + + const handleRefresh = useCallback(() => { + refreshTokens(); + refreshCapabilitySets(); + refreshCapabilities(); + }, [refreshTokens, refreshCapabilitySets, refreshCapabilities]); const handleClose = useCallback(() => { dismissSecret(); @@ -67,18 +94,29 @@ const SettingsRoute = () => { open onClose={handleClose} tokens={tokens} - loading={loading} - creating={creating} + loading={tokensLoading} + creating={creatingToken} deletingId={deletingId} regeneratingId={regeneratingId} - updatingId={updatingId} - onRefresh={refresh} - onCreate={create} - onDelete={revoke} - onRegenerate={regenerate} - onUpdateCapabilities={updateCapabilities} + onRefresh={handleRefresh} + onCreate={createToken} + onDelete={revokeToken} + onRegenerate={regenerateToken} createdToken={createdSecret} onDismissCreatedToken={dismissSecret} + capabilitySets={capabilitySets} + capabilitySetsLoading={capabilitySetsLoading} + creatingCapabilitySet={creatingCapabilitySet} + savingCapabilitySetId={savingCapabilitySetId} + deletingCapabilitySetId={deletingCapabilitySetId} + supportsCapabilitySetLabels={supportsCapabilitySetLabels} + onRefreshCapabilitySets={refreshCapabilitySets} + capabilities={capabilities} + capabilitiesLoading={capabilitiesLoading} + onRefreshCapabilities={refreshCapabilities} + onCreateCapabilitySet={createCapabilitySet} + onUpdateCapabilitySet={updateCapabilitySet} + onDeleteCapabilitySet={deleteCapabilitySet} passkeys={passkeys} passkeysSupported={passkeysSupported} passkeysLoading={passkeysLoading} diff --git a/frontend/src/app/appLayoutUtils.js b/frontend/src/app/appLayoutUtils.js new file mode 100644 index 0000000..1896b6a --- /dev/null +++ b/frontend/src/app/appLayoutUtils.js @@ -0,0 +1,132 @@ +import { createAssetView } from '../asset_manager'; + +export const ASSET_PRESIGN_TTL_MS = 240 * 1000; // backend issues 5 min tokens; refresh slightly early +export const DEFAULT_FOLDER_NAME = 'Documents'; +export const DEFAULT_SORT_FIELD = 'title'; +export const DEFAULT_SORT_DIRECTION = 'asc'; +export const SORT_FIELD_VALUES = ['title', 'issued_at', 'created_at', 'updated_at']; +export const TAG_FILTER_UNTAGGED = '__UNTAGGED__'; + +const ROW_KEY_SEPARATOR = ':'; +const DOCUMENT_ROW_PREFIX = 'document'; +const FOLDER_ROW_PREFIX = 'folder'; + +export const resolveApiPath = (path = '') => path; + +const makeRowKey = (type, id) => + id ? `${type}${ROW_KEY_SEPARATOR}${id}` : `${type}${ROW_KEY_SEPARATOR}`; + +const getRowType = (key) => (typeof key === 'string' ? key.split(ROW_KEY_SEPARATOR, 1)[0] : ''); + +export const getRowId = (key) => { + if (typeof key !== 'string') return ''; + const separatorIndex = key.indexOf(ROW_KEY_SEPARATOR); + if (separatorIndex === -1) return key; + return key.slice(separatorIndex + 1); +}; + +export const isDocumentRowKey = (key) => getRowType(key) === DOCUMENT_ROW_PREFIX; +export const isFolderRowKey = (key) => getRowType(key) === FOLDER_ROW_PREFIX; + +export const resolveDocumentRowKey = (documentId) => + documentId ? makeRowKey(DOCUMENT_ROW_PREFIX, documentId) : null; + +export const resolveFolderRowKey = (folderId) => + folderId ? makeRowKey(FOLDER_ROW_PREFIX, folderId) : null; + +export const hasFiles = (event) => + Array.from(event.dataTransfer?.types || []).includes('Files'); + +const isAssetEquivalent = (lhs, rhs) => { + if (!lhs || !rhs) return false; + const lhsView = createAssetView(lhs); + const rhsView = createAssetView(rhs); + const lhsPrimaryMetadata = lhsView.getPrimaryMetadata() || lhs?.metadata; + const rhsPrimaryMetadata = rhsView.getPrimaryMetadata() || rhs?.metadata; + const lhsCardinality = lhsView.getCardinality() || lhs?.cardinality || null; + const rhsCardinality = rhsView.getCardinality() || rhs?.cardinality || null; + const lhsObjects = lhsView.getObjects(); + const rhsObjects = rhsView.getObjects(); + const objectsComparable = + lhsObjects.length === rhsObjects.length + && lhsObjects.every((entry, index) => { + const other = rhsObjects[index]; + if (!other) return false; + if (entry.ordinal !== other.ordinal) return false; + if (entry.url && other.url && entry.url === other.url) { + return true; + } + if (!entry.url && !other.url) { + return JSON.stringify(entry.metadata || null) === JSON.stringify(other.metadata || null); + } + return entry.url === other.url; + }); + return ( + lhs.id === rhs.id + && lhs.url === rhs.url + && lhsPrimaryMetadata?.width === rhsPrimaryMetadata?.width + && lhsPrimaryMetadata?.height === rhsPrimaryMetadata?.height + && lhs.mime_type === rhs.mime_type + && lhs.asset_type === rhs.asset_type + && lhs.updated_at === rhs.updated_at + && lhsCardinality === rhsCardinality + && objectsComparable + ); +}; + +const mergeAssetIntoGroup = (group, assetData) => { + if (!assetData || !assetData.asset_type) { + if (Array.isArray(group)) { + return group; + } + return group || {}; + } + + if (Array.isArray(group) || !group) { + const list = Array.isArray(group) ? group : []; + const index = list.findIndex((item) => item?.id === assetData.id); + if (index >= 0) { + const existing = list[index]; + if (isAssetEquivalent(existing, assetData)) { + return list; + } + const next = list.slice(); + next[index] = { ...existing, ...assetData }; + return next; + } + return list.concat({ ...assetData }); + } + + const key = assetData.asset_type; + const previous = group?.[key]; + if (previous && isAssetEquivalent(previous, assetData)) { + return group; + } + + const next = { ...(group || {}) }; + next[key] = { ...(previous || {}), ...assetData }; + return next; +}; + +export const mergeAssetIntoDocument = (doc, assetData) => { + if (!doc) return doc; + const existingGroup = doc.current_version?.assets || null; + const nextGroup = mergeAssetIntoGroup(existingGroup, assetData); + if (nextGroup === existingGroup) { + return doc; + } + const updatedCurrentVersion = doc.current_version + ? { ...doc.current_version, assets: nextGroup } + : { assets: nextGroup }; + return { ...doc, current_version: updatedCurrentVersion }; +}; + +export const createRootNode = () => ({ + id: 'root', + name: DEFAULT_FOLDER_NAME, + parentId: null, + children: [], + expanded: true, + loaded: false, + hasChildren: false, +}); diff --git a/frontend/src/app/appState.js b/frontend/src/app/appState.js index a9b168a..01c5bf3 100644 --- a/frontend/src/app/appState.js +++ b/frontend/src/app/appState.js @@ -1,10 +1,5 @@ import React, { useContext, useEffect, useMemo, useReducer } from 'react'; -import axios from 'axios'; - -const api = axios.create({ - baseURL: '/api', - withCredentials: true, -}); +import api from '../lib/api'; const storage = typeof window !== 'undefined' ? window.sessionStorage : undefined; diff --git a/frontend/src/app/useDetailPanel.js b/frontend/src/app/useDetailPanel.js index f2d6ec4..9df6fee 100644 --- a/frontend/src/app/useDetailPanel.js +++ b/frontend/src/app/useDetailPanel.js @@ -3,147 +3,74 @@ import { useCallback, useEffect, useRef, useState } from 'react'; export const useDetailPanel = ({ documentLookup, orderedSelectedDocuments, - selectionOrder, - documentsViewMode, - getRowId, - isDocumentRowKey, }) => { const [detailPanelOpen, setDetailPanelOpen] = useState(false); - const [detailPanelDocIds, setDetailPanelDocIds] = useState([]); - const [detailPanelDocs, setDetailPanelDocs] = useState([]); - const lastScrolledDetailDocRef = useRef(null); + const [detailPanelDocId, setDetailPanelDocId] = useState(null); + const [detailPanelDocument, setDetailPanelDocument] = useState(null); const latestOrderedDocsRef = useRef([]); useEffect(() => { latestOrderedDocsRef.current = orderedSelectedDocuments; if (detailPanelOpen && orderedSelectedDocuments.length) { - const snapshotIds = orderedSelectedDocuments - .map((doc) => doc?.id) - .filter((id) => typeof id === 'string' || typeof id === 'number'); - if (snapshotIds.length) { - setDetailPanelDocIds(snapshotIds); + const nextDoc = orderedSelectedDocuments[orderedSelectedDocuments.length - 1]; + if (nextDoc?.id) { + setDetailPanelDocId(nextDoc.id); + setDetailPanelDocument(nextDoc); } } }, [orderedSelectedDocuments, detailPanelOpen]); - const resolveDocsForIds = useCallback( - (ids, fallbackDocs = []) => { - if (!ids?.length) { - return []; - } - const fallbackMap = new Map((fallbackDocs || []).map((doc) => [doc?.id, doc])); - return ids - .map((id) => documentLookup.get(id) || fallbackMap.get(id) || null) - .filter(Boolean); - }, - [documentLookup], - ); - useEffect(() => { - if (!detailPanelDocIds.length) { - setDetailPanelDocs((prev) => (prev.length ? [] : prev)); - return; - } - - setDetailPanelDocs((prevDocs) => { - const resolved = resolveDocsForIds(detailPanelDocIds, prevDocs); - if (resolved.length === prevDocs.length && resolved.every((doc, index) => doc === prevDocs[index])) { - return prevDocs; + if (!detailPanelDocId) { + if (!detailPanelOpen) { + setDetailPanelDocument(null); } - return resolved; - }); - }, [detailPanelDocIds, resolveDocsForIds]); - - useEffect(() => { - if (!detailPanelOpen) { - lastScrolledDetailDocRef.current = null; return; } - - if (!orderedSelectedDocuments.length) { - lastScrolledDetailDocRef.current = null; - return; + const resolved = documentLookup.get(detailPanelDocId); + if (resolved && resolved !== detailPanelDocument) { + setDetailPanelDocument(resolved); } - - let lastSelectedId = null; - for (let index = selectionOrder.length - 1; index >= 0; index -= 1) { - const key = selectionOrder[index]; - if (isDocumentRowKey(key)) { - lastSelectedId = getRowId(key); - if (lastSelectedId) { - break; - } - } - } - - if (!lastSelectedId && orderedSelectedDocuments.length) { - const fallbackDoc = orderedSelectedDocuments[orderedSelectedDocuments.length - 1]; - lastSelectedId = fallbackDoc?.id || null; - } - - if (!lastSelectedId || lastScrolledDetailDocRef.current === lastSelectedId) { - return; - } - - if (typeof document === 'undefined') { - return; - } - - const targetElement = - document.getElementById(`document-row-${lastSelectedId}`) - || document.getElementById(`document-card-${lastSelectedId}`); - - if (!targetElement) { - return; - } - - lastScrolledDetailDocRef.current = lastSelectedId; - requestAnimationFrame(() => { - targetElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); - }); - }, [ - detailPanelOpen, - orderedSelectedDocuments, - selectionOrder, - documentsViewMode, - getRowId, - isDocumentRowKey, - ]); - - const detailPanelSelectedDocuments = detailPanelDocs; + }, [detailPanelDocId, documentLookup, detailPanelDocument, detailPanelOpen]); const openDetailPanel = useCallback( - ({ documentIds: explicitIds, documents: explicitDocs } = {}) => { - let sourceDocs = Array.isArray(explicitDocs) ? explicitDocs : null; - let snapshotIds = Array.isArray(explicitIds) - ? explicitIds.filter((id) => typeof id === 'string' || typeof id === 'number') - : null; + ({ documentId, document, documentIds, documents } = {}) => { + let targetDoc = document || null; + let targetId = documentId ?? document?.id ?? null; - if (!snapshotIds?.length) { - if (!sourceDocs || !sourceDocs.length) { - sourceDocs = latestOrderedDocsRef.current; - } - snapshotIds = Array.isArray(sourceDocs) - ? sourceDocs - .map((doc) => doc?.id) - .filter((id) => typeof id === 'string' || typeof id === 'number') - : []; + if (!targetDoc && Array.isArray(documents) && documents.length) { + targetDoc = documents[documents.length - 1]; + targetId = targetDoc?.id ?? targetId; } - const uniqueIds = []; - snapshotIds.forEach((id) => { - if (!uniqueIds.includes(id)) { - uniqueIds.push(id); + if (!targetDoc && Array.isArray(documentIds) && documentIds.length) { + targetId = documentIds[documentIds.length - 1]; + } + + if (!targetDoc && targetId != null) { + targetDoc = documentLookup.get(String(targetId)) || null; + } + + if (!targetDoc) { + const fallbackDocs = latestOrderedDocsRef.current; + const fallbackDoc = Array.isArray(fallbackDocs) && fallbackDocs.length + ? fallbackDocs[fallbackDocs.length - 1] + : null; + if (fallbackDoc) { + targetDoc = fallbackDoc; + targetId = fallbackDoc.id; } - }); + } - const resolvedDocs = resolveDocsForIds(uniqueIds, sourceDocs || latestOrderedDocsRef.current); + if (!targetDoc && targetId == null) { + return; + } - setDetailPanelDocIds(uniqueIds); - setDetailPanelDocs(resolvedDocs); - setDetailPanelOpen(true); + setDetailPanelDocId(targetDoc?.id || targetId || null); + setDetailPanelDocument(targetDoc || null); + setDetailPanelOpen(Boolean(targetDoc || targetId)); }, - [resolveDocsForIds], + [documentLookup], ); const closeDetailPanel = useCallback(() => { @@ -152,7 +79,7 @@ export const useDetailPanel = ({ return { detailPanelOpen, - detailPanelSelectedDocuments, + detailPanelDocument, openDetailPanel, closeDetailPanel, setDetailPanelOpen, diff --git a/frontend/src/app/useDocumentPreview.js b/frontend/src/app/useDocumentPreview.js new file mode 100644 index 0000000..4489ec3 --- /dev/null +++ b/frontend/src/app/useDocumentPreview.js @@ -0,0 +1,221 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +const useDocumentPreview = ({ + routeDocumentId, + documents, + searchResults, + setDocuments, + selectedFolder, + assetManager, + api, + resolveApiPath, + notifyApiError, + navigate, + locationPathname, + locationSearch, + detailPanelControlRef, + setActivePreviewId, +}) => { + const [previewEntries, setPreviewEntries] = useState(() => new Map()); + const previewInflightRef = useRef(new Map()); + const previewReturnPathRef = useRef(null); + + const resetPreviewState = useCallback(() => { + setPreviewEntries(() => new Map()); + previewInflightRef.current = new Map(); + previewReturnPathRef.current = null; + }, []); + + const removePreviewEntries = useCallback((ids) => { + if (!Array.isArray(ids) || ids.length === 0) { + return; + } + setPreviewEntries((prev) => { + if (!prev.size) { + return prev; + } + let changed = false; + const next = new Map(prev); + ids.forEach((id) => { + if (next.delete(id)) { + changed = true; + } + previewInflightRef.current.delete(id); + }); + return changed ? next : prev; + }); + }, []); + + const ensurePreviewUrl = useCallback( + async (documentId, { force = false } = {}) => { + if (!documentId) return null; + + const existing = previewEntries.get(documentId) || null; + const now = Date.now(); + const expiresAt = typeof existing?.expiresAt === 'number' ? existing.expiresAt : null; + if (!force && existing && (!expiresAt || expiresAt > now)) { + return existing; + } + + if (!force && previewInflightRef.current.has(documentId)) { + return previewInflightRef.current.get(documentId); + } + + const request = (async () => { + try { + const docResponse = await api.get(`/documents/${documentId}`); + const downloadPath = docResponse.data?.document?.current_version?.download_path; + if (!downloadPath || !resolveApiPath) { + throw new Error('Document missing download path'); + } + + const href = resolveApiPath(downloadPath); + const entry = { + url: href, + contentType: docResponse.data?.document?.current_version?.version?.content_type || null, + filename: docResponse.data?.document?.filename, + expiresAt: Date.now() + 5 * 60 * 1000, + }; + setPreviewEntries((prev) => { + const next = new Map(prev); + next.set(documentId, entry); + return next; + }); + return entry; + } catch (error) { + notifyApiError(error, 'Unable to fetch document preview.'); + throw error; + } finally { + previewInflightRef.current.delete(documentId); + } + })(); + + previewInflightRef.current.set(documentId, request); + return request; + }, + [previewEntries, api, resolveApiPath, notifyApiError, setPreviewEntries], + ); + + const ensurePreviewData = useCallback( + async (documentId) => { + if (!documentId) return null; + + const findInCache = () => { + const pool = searchResults ?? documents; + return pool.find((item) => item.id === documentId) || null; + }; + + let doc = findInCache(); + + if (!doc) { + const { data } = await api.get(`/documents/${documentId}`); + const hydratedDetail = assetManager.hydrateDetail(data); + const fetched = hydratedDetail?.document || data.document || data; + doc = fetched ? assetManager.hydrateDocument(fetched) : null; + if (!doc) { + throw new Error('Document metadata unavailable.'); + } + + setDocuments((prev) => { + if (prev.some((item) => item.id === doc.id)) { + return prev; + } + return [doc, ...prev]; + }); + } + + if (!previewReturnPathRef.current) { + const fallbackFolderId = doc?.folder_id || 'root'; + previewReturnPathRef.current = + fallbackFolderId === 'root' ? '/documents' : `/documents/folder/${fallbackFolderId}`; + } + + await ensurePreviewUrl(documentId, { force: false }); + setActivePreviewId(documentId); + return doc; + }, + [ + searchResults, + documents, + assetManager, + setDocuments, + ensurePreviewUrl, + setActivePreviewId, + api, + ], + ); + + const openDocumentPreview = useCallback( + (documentId, { replace = false } = {}) => { + if (!documentId) return; + detailPanelControlRef.current.close(); + previewReturnPathRef.current = `${locationPathname}${locationSearch}`; + navigate(`/documents/${documentId}`, { replace }); + }, + [navigate, locationPathname, locationSearch, detailPanelControlRef], + ); + + const closeDocumentPreview = useCallback( + (folderId = null) => { + const fallbackPath = previewReturnPathRef.current; + previewReturnPathRef.current = null; + + if (fallbackPath) { + navigate(fallbackPath, { replace: false }); + return; + } + + const targetId = folderId || selectedFolder || 'root'; + const path = targetId === 'root' ? '/documents' : `/documents/folder/${targetId}`; + navigate(path, { replace: false }); + }, + [navigate, selectedFolder], + ); + + useEffect(() => { + if (!routeDocumentId) { + return undefined; + } + + const handleKeyDown = (event) => { + if (event.key === 'Escape') { + closeDocumentPreview(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [routeDocumentId, closeDocumentPreview]); + + useEffect(() => { + if (!routeDocumentId) { + return undefined; + } + + let cancelled = false; + + ensurePreviewData(routeDocumentId).catch((error) => { + if (cancelled) { + return; + } + notifyApiError(error, 'Failed to open document preview.'); + closeDocumentPreview(); + }); + + return () => { + cancelled = true; + }; + }, [routeDocumentId, ensurePreviewData, notifyApiError, closeDocumentPreview]); + + return { + previewEntries, + ensurePreviewUrl, + ensurePreviewData, + openDocumentPreview, + closeDocumentPreview, + resetPreviewState, + removePreviewEntries, + }; +}; + +export default useDocumentPreview; diff --git a/frontend/src/app/useDocumentSelection.js b/frontend/src/app/useDocumentSelection.js index 171ff8e..16f3b83 100644 --- a/frontend/src/app/useDocumentSelection.js +++ b/frontend/src/app/useDocumentSelection.js @@ -143,7 +143,7 @@ export const useDocumentSelection = ({ applySelection([], { anchor: null, interactedKeys: [] }); }, [applySelection]); - const handleRowSelection = useCallback( + const handleEntrySelection = useCallback( (rowKey, event) => { const visibleRowKeySet = visibleRowKeySetRef.current; const navigableRowKeys = navigableRowKeysRef.current; @@ -241,7 +241,7 @@ export const useDocumentSelection = ({ setFocusedRowKey, applySelection, clearSelection, - handleRowSelection, + handleEntrySelection, promoteSelectionOrder, configureSelectionEnvironment, }; diff --git a/frontend/src/app/useDocumentsPreferences.js b/frontend/src/app/useDocumentsPreferences.js new file mode 100644 index 0000000..89a139a --- /dev/null +++ b/frontend/src/app/useDocumentsPreferences.js @@ -0,0 +1,138 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + DEFAULT_SORT_DIRECTION, + DEFAULT_SORT_FIELD, + SORT_FIELD_VALUES, +} from './appLayoutUtils'; + +const VIEW_MODE_STORAGE_KEY = 'papercrate_view_mode'; +const SORT_FIELD_STORAGE_KEY = 'papercrate_sort_field'; +const SORT_DIRECTION_STORAGE_KEY = 'papercrate_sort_direction'; +const INCLUDE_DESCENDANTS_STORAGE_KEY = 'papercrate_include_descendants'; + +const readSessionStorage = (key) => { + if (typeof window === 'undefined') { + return null; + } + try { + return window.sessionStorage.getItem(key); + } catch (error) { + console.warn(`[session-storage] failed to read ${key}`, error); + return null; + } +}; + +const writeSessionStorage = (key, value) => { + if (typeof window === 'undefined') { + return; + } + try { + window.sessionStorage.setItem(key, value); + } catch (error) { + console.warn(`[session-storage] failed to persist ${key}`, error); + } +}; + +export const useDocumentsPreferences = () => { + const [documentsViewMode, setDocumentsViewModeState] = useState(() => { + const stored = readSessionStorage(VIEW_MODE_STORAGE_KEY); + if (stored === 'grid' || stored === 'desk') { + return stored; + } + return 'list'; + }); + + const [deskHelpOpen, setDeskHelpOpen] = useState(false); + const lastNonDeskViewRef = useRef(documentsViewMode === 'desk' ? 'list' : documentsViewMode); + + useEffect(() => { + if (documentsViewMode !== 'desk') { + lastNonDeskViewRef.current = documentsViewMode; + } else if (deskHelpOpen) { + setDeskHelpOpen(false); + } + }, [documentsViewMode, deskHelpOpen]); + + const setDocumentsViewMode = useCallback((mode) => { + const next = mode === 'grid' ? 'grid' : mode === 'desk' ? 'desk' : 'list'; + setDocumentsViewModeState((previous) => { + if (next !== previous) { + writeSessionStorage(VIEW_MODE_STORAGE_KEY, next); + } + return next; + }); + }, []); + + const handleDeskExit = useCallback(() => { + const fallback = lastNonDeskViewRef.current && lastNonDeskViewRef.current !== 'desk' + ? lastNonDeskViewRef.current + : 'list'; + setDocumentsViewMode(fallback); + }, [setDocumentsViewMode]); + + const [documentsSortField, setDocumentsSortField] = useState(() => { + const stored = readSessionStorage(SORT_FIELD_STORAGE_KEY); + return SORT_FIELD_VALUES.includes(stored) ? stored : DEFAULT_SORT_FIELD; + }); + const documentsSortFieldRef = useRef(documentsSortField); + useEffect(() => { + documentsSortFieldRef.current = documentsSortField; + writeSessionStorage(SORT_FIELD_STORAGE_KEY, documentsSortField); + }, [documentsSortField]); + + const [documentsSortDirection, setDocumentsSortDirection] = useState(() => { + const stored = readSessionStorage(SORT_DIRECTION_STORAGE_KEY); + return stored === 'desc' || stored === 'asc' ? stored : DEFAULT_SORT_DIRECTION; + }); + const documentsSortDirectionRef = useRef(documentsSortDirection); + useEffect(() => { + documentsSortDirectionRef.current = documentsSortDirection; + writeSessionStorage(SORT_DIRECTION_STORAGE_KEY, documentsSortDirection); + }, [documentsSortDirection]); + + const handleDocumentsSortFieldChange = useCallback((field) => { + const nextField = SORT_FIELD_VALUES.includes(field) ? field : DEFAULT_SORT_FIELD; + setDocumentsSortField((previous) => (previous === nextField ? previous : nextField)); + }, []); + + const handleDocumentsSortDirectionToggle = useCallback(() => { + setDocumentsSortDirection((previous) => (previous === 'asc' ? 'desc' : 'asc')); + }, []); + + const [searchIncludeDescendants, setSearchIncludeDescendants] = useState(() => { + const stored = readSessionStorage(INCLUDE_DESCENDANTS_STORAGE_KEY); + if (stored === 'true') return true; + if (stored === 'false') return false; + return true; + }); + useEffect(() => { + writeSessionStorage( + INCLUDE_DESCENDANTS_STORAGE_KEY, + searchIncludeDescendants ? 'true' : 'false', + ); + }, [searchIncludeDescendants]); + + const toggleSearchIncludeDescendants = useCallback(() => { + setSearchIncludeDescendants((previous) => !previous); + }, []); + + const sortRefreshReadyRef = useRef(false); + + return { + documentsViewMode, + handleDocumentsViewModeChange: setDocumentsViewMode, + handleDeskExit, + deskHelpOpen, + setDeskHelpOpen, + documentsSortField, + documentsSortDirection, + documentsSortFieldRef, + documentsSortDirectionRef, + handleDocumentsSortFieldChange, + handleDocumentsSortDirectionToggle, + searchIncludeDescendants, + setSearchIncludeDescendants, + toggleSearchIncludeDescendants, + sortRefreshReadyRef, + }; +}; diff --git a/frontend/src/app/useDocumentsSearch.js b/frontend/src/app/useDocumentsSearch.js new file mode 100644 index 0000000..e5e0447 --- /dev/null +++ b/frontend/src/app/useDocumentsSearch.js @@ -0,0 +1,238 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + TAG_FILTER_UNTAGGED, + resolveDocumentRowKey, + isDocumentRowKey, +} from './appLayoutUtils'; + +const useDocumentsSearch = ({ + api, + assetManager, + token, + selectedFolder, + navigate, + locationPathname, + isDocumentsRoute, + selectionHelpers, + searchIncludeDescendants, + documentsSortField, + documentsSortDirection, + notifyApiError, + setLoading, + setSearchIncludeDescendants, +}) => { + const [searchQuery, setSearchQuery] = useState(''); + const [activeTagFilters, setActiveTagFilters] = useState([]); + const [activeCorrespondentFilters, setActiveCorrespondentFilters] = useState([]); + const [searchResults, setSearchResults] = useState(null); + const [searchLoading, setSearchLoading] = useState(false); + + const toggleTagFilter = useCallback((tagId) => { + if (!tagId) return; + setActiveTagFilters((previous) => { + if (tagId === TAG_FILTER_UNTAGGED) { + return previous.includes(TAG_FILTER_UNTAGGED) ? [] : [TAG_FILTER_UNTAGGED]; + } + const sanitized = previous.filter((id) => id !== TAG_FILTER_UNTAGGED); + if (sanitized.includes(tagId)) { + return sanitized.filter((id) => id !== tagId); + } + return sanitized.concat([tagId]); + }); + }, []); + + const toggleCorrespondentFilter = useCallback((correspondentId) => { + setActiveCorrespondentFilters((previous) => { + if (!correspondentId) { + return []; + } + return previous.includes(correspondentId) ? [] : [correspondentId]; + }); + }, []); + + const isFilterActive = useMemo( + () => + searchQuery.trim().length > 0 + || activeTagFilters.length > 0 + || activeCorrespondentFilters.length > 0, + [searchQuery, activeTagFilters, activeCorrespondentFilters], + ); + + const clearFilters = useCallback(() => { + setSearchQuery(''); + setActiveTagFilters([]); + setActiveCorrespondentFilters([]); + setSearchLoading(false); + setSearchIncludeDescendants(true); + }, [ + setSearchIncludeDescendants, + ]); + + const handleSearchChange = useCallback((value) => { + setSearchQuery(value); + }, []); + + const handleSearchSubmit = useCallback(() => { + if (!navigate) return; + const targetFolder = selectedFolder && selectedFolder !== 'root' ? selectedFolder : 'root'; + const targetPath = targetFolder === 'root' ? '/documents' : `/documents/folder/${targetFolder}`; + if (!isDocumentsRoute || locationPathname !== targetPath) { + navigate(targetPath, { replace: false }); + } + }, [navigate, selectedFolder, isDocumentsRoute, locationPathname]); + + useEffect(() => { + if (!token) return undefined; + + if (!isFilterActive) { + setSearchResults(null); + setSearchLoading(false); + return undefined; + } + + let cancelled = false; + let started = false; + setSearchLoading(true); + + const debounce = setTimeout(async () => { + started = true; + setLoading(true); + try { + const params = {}; + const trimmedQuery = searchQuery.trim(); + if (trimmedQuery.length) { + params.query = trimmedQuery; + } + if (activeTagFilters.length) { + const onlyUntagged = activeTagFilters.length === 1 + && activeTagFilters[0] === TAG_FILTER_UNTAGGED; + if (onlyUntagged) { + params.tags = 'none'; + } else { + const tagIds = activeTagFilters.filter((id) => id !== TAG_FILTER_UNTAGGED); + if (tagIds.length) { + params.tags = tagIds.join(','); + } + } + } + if (activeCorrespondentFilters.length) { + params.correspondents = activeCorrespondentFilters.join(','); + } + const folderIdentifier = selectedFolder === 'root' ? null : selectedFolder; + if (folderIdentifier) { + params.folder_id = folderIdentifier; + } + if (!searchIncludeDescendants) { + params.include_descendants = false; + } + if (documentsSortField) { + params.sort = documentsSortField; + } + if (documentsSortDirection) { + params.dir = documentsSortDirection; + } + const { data } = await api.get('/documents', { params }); + if (cancelled) return; + + const results = assetManager.hydrateDocuments(data || []); + setSearchResults(results); + + if (!results.length) { + setSearchLoading(false); + selectionHelpers.setSelectedEntries([]); + selectionHelpers.setFocusedDocumentId(null); + selectionHelpers.selectionOrderRef.current = []; + selectionHelpers.setSelectionOrder([]); + selectionHelpers.selectionAnchorRef.current = null; + return; + } + + const resultKeys = results + .map((doc) => resolveDocumentRowKey(doc.id)) + .filter(Boolean); + + let targetKey = null; + let nextSelectionKeys = []; + + selectionHelpers.setSelectedEntries((previous) => { + const previousDocKeys = previous.filter(isDocumentRowKey); + const filtered = previousDocKeys.filter((key) => resultKeys.includes(key)); + if (filtered.length) { + targetKey = filtered[filtered.length - 1]; + nextSelectionKeys = filtered; + return filtered; + } + targetKey = null; + nextSelectionKeys = []; + return []; + }); + + selectionHelpers.selectionOrderRef.current = nextSelectionKeys; + selectionHelpers.setSelectionOrder(nextSelectionKeys); + + selectionHelpers.setFocusedDocumentId((previous) => { + if (previous && resultKeys.includes(resolveDocumentRowKey(previous))) { + return previous; + } + return null; + }); + + selectionHelpers.selectionAnchorRef.current = targetKey; + } catch (error) { + if (cancelled) return; + notifyApiError(error, 'Search failed. Please try again.'); + setSearchResults(null); + } finally { + if (!cancelled && started) { + setLoading(false); + setSearchLoading(false); + } + } + }, 300); + + return () => { + cancelled = true; + clearTimeout(debounce); + if (started) { + setLoading(false); + setSearchLoading(false); + } + }; + }, [ + api, + token, + isFilterActive, + searchQuery, + activeTagFilters, + activeCorrespondentFilters, + searchIncludeDescendants, + documentsSortField, + documentsSortDirection, + selectedFolder, + notifyApiError, + assetManager, + selectionHelpers, + setLoading, + ]); + + return { + searchQuery, + setSearchQuery, + searchResults, + setSearchResults, + searchLoading, + setSearchLoading, + activeTagFilters, + setActiveTagFilters, + activeCorrespondentFilters, + setActiveCorrespondentFilters, + toggleTagFilter, + toggleCorrespondentFilter, + isFilterActive, + clearFilters, + handleSearchChange, + handleSearchSubmit, + }; +}; + +export default useDocumentsSearch; diff --git a/frontend/src/app/useEntryPointerHandler.js b/frontend/src/app/useEntryPointerHandler.js new file mode 100644 index 0000000..3fa1c29 --- /dev/null +++ b/frontend/src/app/useEntryPointerHandler.js @@ -0,0 +1,48 @@ +import { useCallback } from 'react'; +import { useEntryPointerHandler as useEntryPointerCore, isPointerModifierEvent, isPrimaryPointerEvent } from '../documents/useEntryPointer'; + +export const useEntryPointer = ({ + resolveDocumentRowKey, + resolveFolderRowKey, + onSelectDocument, + onInspectDocument, + onSelectFolder, +}) => { + const coreHandler = useEntryPointerCore({ + resolveDocumentRowKey, + resolveFolderRowKey, + onSelectDocument: (documentId, event, meta) => { + const { modifierClick, primaryClick, rowKey } = meta; + onSelectDocument(documentId, event, { modifierClick, primaryClick, rowKey }); + if (!modifierClick && primaryClick && typeof onInspectDocument === 'function') { + onInspectDocument(documentId, meta); + } + }, + onSelectFolder, + }); + + return useCallback((entry, event) => { + if (!entry) { + return; + } + if (entry.type !== 'document') { + coreHandler(entry, event); + return; + } + + const modifierClick = isPointerModifierEvent(event); + const primaryClick = isPrimaryPointerEvent(event); + + onSelectDocument(entry.id, event, { + modifierClick, + primaryClick, + rowKey: entry.key, + }); + + if (!modifierClick && primaryClick) { + onInspectDocument?.(entry.id, { modifierClick, primaryClick, rowKey: entry.key }); + } + }, [coreHandler, onInspectDocument, onSelectDocument]); +}; + +export default useEntryPointer; diff --git a/frontend/src/app/useWorkspaceSelection.js b/frontend/src/app/useWorkspaceSelection.js new file mode 100644 index 0000000..db31f4b --- /dev/null +++ b/frontend/src/app/useWorkspaceSelection.js @@ -0,0 +1,109 @@ +import { useCallback, useMemo } from 'react'; +import { useDocumentSelection } from './useDocumentSelection'; + +const identity = (value) => value; + +export const useWorkspaceSelection = ({ + resolveDocumentRowKey, + resolveFolderRowKey, + isDocumentRowKey, + isFolderRowKey, + getRowId, + onInspectDocument = identity, + onInspectFolder = identity, +} = {}) => { + const selection = useDocumentSelection({ + resolveDocumentRowKey, + resolveFolderRowKey, + isDocumentRowKey, + isFolderRowKey, + getRowId, + }); + + const { + selectedEntries, + setSelectedEntries, + selectionOrder, + setSelectionOrder, + selectionOrderRef, + selectionAnchorRef, + selectionInitializedRef, + focusedDocumentId, + setFocusedDocumentId, + focusedRowKey, + setFocusedRowKey, + applySelection, + clearSelection, + handleEntrySelection, + promoteSelectionOrder, + configureSelectionEnvironment, + } = selection; + + const selectedDocumentIds = useMemo( + () => + selectedEntries + .filter((entry) => isDocumentRowKey(entry)) + .map((entry) => getRowId(entry)) + .filter(Boolean), + [selectedEntries, isDocumentRowKey, getRowId], + ); + + const selectedFolderIds = useMemo( + () => + selectedEntries + .filter((entry) => isFolderRowKey(entry)) + .map((entry) => getRowId(entry)) + .filter(Boolean), + [selectedEntries, isFolderRowKey, getRowId], + ); + + const selectEntry = useCallback( + (entry, event) => { + const rowKey = typeof entry === 'string' ? entry : entry?.rowKey; + if (!rowKey) return; + handleEntrySelection(rowKey, event); + }, + [handleEntrySelection], + ); + + const inspectDocument = useCallback( + (documentId) => { + if (!documentId) return; + onInspectDocument(documentId); + }, + [onInspectDocument], + ); + + const inspectFolder = useCallback( + (folderId) => { + if (!folderId) return; + onInspectFolder(folderId); + }, + [onInspectFolder], + ); + + return { + selectedEntries, + selectedDocumentIds, + selectedFolderIds, + selectionOrder, + selectionOrderRef, + selectionAnchorRef, + selectionInitializedRef, + focusedDocumentId, + setFocusedDocumentId, + focusedRowKey, + setFocusedRowKey, + applySelection, + clearSelection, + handleEntrySelection: selectEntry, + promoteSelectionOrder, + configureSelectionEnvironment, + setSelectedEntries, + setSelectionOrder, + inspectDocument, + inspectFolder, + }; +}; + +export default useWorkspaceSelection; diff --git a/frontend/src/app/useWorkspaceSurface.js b/frontend/src/app/useWorkspaceSurface.js index 7912f16..c3ec2db 100644 --- a/frontend/src/app/useWorkspaceSurface.js +++ b/frontend/src/app/useWorkspaceSurface.js @@ -2,7 +2,7 @@ import React, { useCallback, useMemo } from 'react'; import { SidebarExpandIcon } from '../ui/icons'; import { createDocumentsSurface } from '../documents/DocumentsPanel'; import { createDocumentViewerSurface } from '../preview/DocumentViewerPanel'; -import { createDesktopSurface } from '../DesktopWorkspace'; +import { createDesktopSurface } from '../desktop/DesktopWorkspace'; export const useWorkspaceSurface = ({ sidebarCollapsed, @@ -20,7 +20,6 @@ export const useWorkspaceSurface = ({ getDocumentAsset, resolveApiPath, notifyApiError, - handleThumbnailRegeneration, closeDocumentPreview, parentBreadcrumb, onNavigateParent, @@ -92,7 +91,6 @@ export const useWorkspaceSurface = ({ getDocumentAsset, resolveApiPath, notifyApiError, - onRegenerate: handleThumbnailRegeneration, onClose: closeDocumentPreview, renderSidebarToggle, tagLookupById, @@ -117,7 +115,6 @@ export const useWorkspaceSurface = ({ getDocumentAsset, resolveApiPath, notifyApiError, - handleThumbnailRegeneration, closeDocumentPreview, renderSidebarToggle, detailPanelProps, diff --git a/frontend/src/desktop/DesktopDocumentCard.jsx b/frontend/src/desktop/DesktopDocumentCard.jsx new file mode 100644 index 0000000..7bb23f8 --- /dev/null +++ b/frontend/src/desktop/DesktopDocumentCard.jsx @@ -0,0 +1,127 @@ +import React, { useMemo } from 'react'; +import DesktopPreviewCard from './DesktopPreviewCard'; +import { resolveCorrespondents } from '../documents/correspondents'; +import { getTagColorStyle } from '../utils/colors'; +import { preventAll } from './events'; + +const DesktopDocumentCard = ({ + doc, + style, + shouldLoad, + dragging, + matchesFilter, + tagTargetActive, + tagTargetPending, + selected, + docTagTokens, + ensureAssetUrl, + getDocumentAsset, + handleNavigatorSnapshot, + cardPointerHandlers, + onDocumentOpen, + onTagDragEnter, + onTagDragOver, + onTagDragLeave, + onTagDrop, + onDocTagPointerDown, + onDocTagDragStart, + onDocTagDrag, + onDocTagDragEnd, + pendingRemovalTag, + registerNode, +}) => { + const correspondents = useMemo(() => resolveCorrespondents(doc), [doc]); + const tags = Array.isArray(doc?.tags) ? doc.tags : []; + + const itemClasses = ['desk-item']; + if (dragging) itemClasses.push('is-dragging'); + if (tagTargetActive) itemClasses.push('is-tag-target'); + if (tagTargetPending) itemClasses.push('is-tag-pending'); + if (!matchesFilter) itemClasses.push('is-filtered-out'); + if (selected) itemClasses.push('is-selected'); + + const ariaHidden = matchesFilter ? undefined : 'true'; + const dataTagIds = docTagTokens || undefined; + + return ( +
onTagDragEnter(event, doc.id)} + onDragOver={(event) => onTagDragOver(event, doc.id)} + onDragLeave={(event) => onTagDragLeave(event, doc.id)} + onDrop={(event) => onTagDrop(event, doc)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + preventAll(event); + onDocumentOpen?.(doc.id); + } + }} + > +
+ + {correspondents.length > 0 && ( + + )} + {tags.length > 0 && ( + + )} +
+
+ ); +}; + +export default React.memo(DesktopDocumentCard); diff --git a/frontend/src/desktop/DesktopPreviewCard.jsx b/frontend/src/desktop/DesktopPreviewCard.jsx new file mode 100644 index 0000000..201c637 --- /dev/null +++ b/frontend/src/desktop/DesktopPreviewCard.jsx @@ -0,0 +1,146 @@ +import React, { useEffect } from 'react'; +import { ArrowLeftIcon, ArrowRightIcon } from '../ui/icons'; +import { useAssetNavigator } from '../hooks/useAssetNavigator'; +import { preventAll } from './events'; + +const DesktopPreviewCard = ({ + doc, + title, + ensureAssetUrl, + getDocumentAsset, + prefetch = 3, + onNavigatorSnapshot, + shouldLoad = true, +}) => { + const navigator = useAssetNavigator({ + document: doc, + assetType: 'preview', + ensureAssetUrl: shouldLoad ? ensureAssetUrl : null, + getAsset: getDocumentAsset, + prefetch, + }); + + const { currentUrl, cardinality, canGoPrev, canGoNext, currentMetadata, ordinal } = navigator; + const docId = doc?.id ?? null; + + const metadataWidth = Number(currentMetadata?.width); + const metadataHeight = Number(currentMetadata?.height); + + useEffect(() => { + if (!onNavigatorSnapshot || !docId) { + return undefined; + } + const snapshot = { + url: currentUrl || null, + alt: title, + canGoPrev, + canGoNext, + goPrev: navigator.goPrev, + goNext: navigator.goNext, + ordinal, + width: Number.isFinite(metadataWidth) && metadataWidth > 0 ? metadataWidth : null, + height: Number.isFinite(metadataHeight) && metadataHeight > 0 ? metadataHeight : null, + }; + onNavigatorSnapshot(docId, snapshot); + return () => onNavigatorSnapshot(docId, null); + }, [ + docId, + currentUrl, + title, + canGoPrev, + canGoNext, + ordinal, + metadataWidth, + metadataHeight, + navigator.goPrev, + navigator.goNext, + onNavigatorSnapshot, + ]); + + const hasPreview = Boolean(currentUrl); + const cardClasses = ['desk-item__card']; + if (!hasPreview) cardClasses.push('desk-item__card--empty'); + const showNav = hasPreview && (cardinality > 1 || canGoPrev || canGoNext); + + return ( +
{ + if (event instanceof DragEvent) { + event.preventDefault(); + } + }} + > + {hasPreview ? ( + {title} event.preventDefault()} + /> + ) : ( +
+
DOC
+
+ {title} +
+
+ )} + {showNav ? ( +
+ + +
+ ) : null} +
+ ); +}; + +export default DesktopPreviewCard; + diff --git a/frontend/src/DesktopWorkspace.css b/frontend/src/desktop/DesktopWorkspace.css similarity index 88% rename from frontend/src/DesktopWorkspace.css rename to frontend/src/desktop/DesktopWorkspace.css index 6489c37..30975f0 100644 --- a/frontend/src/DesktopWorkspace.css +++ b/frontend/src/desktop/DesktopWorkspace.css @@ -16,6 +16,9 @@ transition: box-shadow 0.16s ease; outline: none; will-change: transform; + -webkit-user-select: none; + user-select: none; + -webkit-touch-callout: none; } .desk-shell { @@ -32,6 +35,12 @@ position: relative; overflow: hidden; margin: 0; + outline: none; +} + +.desk-canvas:focus, +.desk-canvas:focus-visible { + outline: none; } .desk-empty { @@ -98,7 +107,6 @@ } .desk-item__tags { - --tag-scale: 1; position: absolute; top: 0; right: 0; @@ -107,23 +115,54 @@ gap: 0.35rem; align-items: flex-end; transform-origin: top right; - transform: scale(var(--tag-scale)) translate(-0.5em, 0.5em); + transform: translate(-0.5em, 0.5em); 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; + background: color-mix(in oklch, var(--surface-subtle) 90%, 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; pointer-events: auto; cursor: grab; transition: transform 0.16s ease, opacity 0.2s ease, box-shadow 0.2s ease; - box-shadow: 2px 2px 4px var(--shadow-medium); } .desk-help-overlay { position: fixed; inset: 0; - z-index: 1400; + z-index: 5000000; display: flex; align-items: center; justify-content: center; @@ -220,7 +259,6 @@ .tag-chip--draggable.is-drag-hidden { opacity: 0.4; - pointer-events: none; } .desk-item__tags .tag-chip { diff --git a/frontend/src/desktop/DesktopWorkspace.jsx b/frontend/src/desktop/DesktopWorkspace.jsx new file mode 100644 index 0000000..ad65cd2 --- /dev/null +++ b/frontend/src/desktop/DesktopWorkspace.jsx @@ -0,0 +1,1190 @@ +import React, { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from 'react'; +import { createPortal } from 'react-dom'; +import { resolveDocumentAssetUrl, createAssetView } from '../asset_manager'; +import { CloseIcon } from '../ui/icons'; +import SelectionFloatingActions from '../documents/SelectionFloatingActions'; +import { createDocumentsTableHeaderActions } from '../documents/DocumentsPanel'; +import createWorkspaceSurfaceConfig from '../documents/workspaceHeader'; +import DetailPanel from '../detail/DetailPanel'; +import { formatTransform } from './math'; +import useDocumentDrag from './useDocumentDrag'; +import PreviewZoomOverlay from '../detail/PreviewZoomOverlay'; +import { + WorkspaceEngine, + DESK_CANVAS_PADDING, + DESK_DEFAULT_CANVAS_HEIGHT, + DESK_DEFAULT_CANVAS_WIDTH, + clampCardDimensions, + computeFallbackCardSize, + useWorkspaceSnapshot, +} from './workspaceEngine'; +import useDeskPointer from './pointer/useDeskPointer'; +import useDeskTagInteractions from './tags/useDeskTagInteractions'; +import DesktopDocumentCard from './DesktopDocumentCard'; +import './DesktopWorkspace.css'; + +const DEBUG_DRAG = false; +const DEBUG_FOCUS = false; + +const usePreviewMetadata = (documents, getDocumentAsset, ensureAssetUrl) => { + const [metadataMap, setMetadataMap] = useState(() => new Map()); + + useEffect(() => { + let cancelled = false; + const docs = Array.isArray(documents) ? documents : []; + if (!docs.length) { + setMetadataMap(new Map()); + return () => { + cancelled = true; + }; + } + + const fetchMetadataForDoc = async (doc) => { + if (!doc?.id) { + return null; + } + + const docId = String(doc.id); + const resolveAsset = (type) => (typeof getDocumentAsset === 'function' ? getDocumentAsset(doc, type) : null); + + let asset = resolveAsset('preview') || resolveAsset('thumbnail'); + let view = createAssetView(asset); + let metadata = view.getPrimaryMetadata(); + + const hasDimensions = (meta) => + Number.isFinite(Number(meta?.width)) && Number.isFinite(Number(meta?.height)) && + Number(meta.width) > 0 && Number(meta.height) > 0; + + if (!hasDimensions(metadata) && ensureAssetUrl && docId && asset?.id) { + try { + const ensured = await ensureAssetUrl(doc.id, asset, { start: 1, limit: 1 }); + if (ensured) { + asset = ensured; + view = createAssetView(asset); + metadata = view.getPrimaryMetadata(); + } + } catch (error) { + console.warn('[desk] ensureDocumentSize metadata fetch failed', error); + } + } + + if (!hasDimensions(metadata)) { + return null; + } + + const width = Number(metadata.width); + const height = Number(metadata.height); + if (!Number.isFinite(width) || !Number.isFinite(height)) { + return null; + } + + return [docId, { width, height }]; + }; + + Promise.all(docs.map((doc) => fetchMetadataForDoc(doc))) + .then((entries) => { + if (cancelled) return; + const next = new Map(); + entries.forEach((entry) => { + if (entry) { + next.set(entry[0], entry[1]); + } + }); + setMetadataMap(next); + }) + .catch(() => { + if (!cancelled) { + setMetadataMap(new Map()); + } + }); + + return () => { + cancelled = true; + }; + }, [documents, getDocumentAsset, ensureAssetUrl]); + + return metadataMap; +}; + +const DesktopWorkspace = ({ + documents = [], + searchResults = null, + onDocumentOpen, + onInspectDocument = null, + onEntryPointer = null, + onDocumentStackSelect = null, + onPromoteSelection = null, + onAssignTagToDocument = null, + onRemoveTagFromDocument = null, + ensureAssetUrl = null, + getDocumentAsset = () => null, + activeTagIds = [], + selectedDocumentIds = [], + onClearSelection = null, + detailPanelOpen = false, + onCloseDetailPanel = null, + helpOpen = false, + onHelpClose = null, + tenantId = null, + viewId = 'default', +}) => { + const items = useMemo(() => (searchResults ? searchResults : documents), [documents, searchResults]); + + const allowLayoutPersistence = Boolean(tenantId && viewId && viewId.startsWith('folder:')); + + const containerRef = useRef(null); + const itemRefs = useRef(new Map()); + const dragTransformsRef = useRef(new Map()); + const [overlayDocId, setOverlayDocId] = useState(null); + const [overlayOriginRect, setOverlayOriginRect] = useState(null); + const [overlayOriginTransform, setOverlayOriginTransform] = useState(null); + const [previewSnapshots, setPreviewSnapshots] = useState(() => new Map()); + const [docSizeVersion, setDocSizeVersion] = useState(0); + const docSizeMapRef = useRef(new Map()); + const ensureDocumentSize = useCallback((doc) => { + if (!doc?.id) { + return null; + } + return docSizeMapRef.current.get(String(doc.id)) || null; + }, []); + const previewMetadata = usePreviewMetadata(items, getDocumentAsset, ensureAssetUrl); + const documentLookup = useMemo(() => { + const map = new Map(); + items.forEach((doc) => { + const key = doc?.id != null ? String(doc.id) : null; + if (key) { + map.set(key, doc); + } + }); + return map; + }, [items]); + + const engineRef = useRef(null); + if (!engineRef.current) { + engineRef.current = new WorkspaceEngine({ + allowLayoutPersistence, + tenantId, + viewId, + }); + } + const engine = engineRef.current; + + useEffect(() => { + engine.updateConfig({ allowLayoutPersistence, tenantId, viewId }); + }, [engine, allowLayoutPersistence, tenantId, viewId]); + + useEffect(() => { + const nextItems = searchResults ? searchResults : documents; + engine.setItems(nextItems || []); + }, [engine, documents, searchResults]); + + useEffect(() => { + const map = new Map(); + items.forEach((doc) => { + const key = doc?.id != null ? String(doc.id) : null; + if (key) { + map.set(key, doc); + } + }); + engine.setDocumentLookup(map); + }, [engine, items]); + + useEffect(() => { + engine.setEnsureDocumentSize(ensureDocumentSize); + }, [engine, ensureDocumentSize]); + + const workspaceSnapshot = useWorkspaceSnapshot(engine, useSyncExternalStore); + const { + layout: layoutSnapshot, + canvasSize, + visibleDocIds, + draggingId, + tagDropTargetId, + pendingTagDocId, + pendingRemovalTag, + initialLoadDone, + } = workspaceSnapshot; + + useEffect(() => { + const shouldWaitForPersisted = allowLayoutPersistence && !initialLoadDone; + + if (shouldWaitForPersisted) { + return; + } + + engine.ensureLayoutForItems(); + }, [engine, docSizeVersion, initialLoadDone, items.length, allowLayoutPersistence]); + + useEffect(() => { + engine.setItemRefs(itemRefs); + }, [engine, itemRefs]); + + const layoutRef = useRef(layoutSnapshot); + layoutRef.current = engine.layout; + + const bringToFront = useCallback((docId) => { + engine.bringToFront(docId); + }, [engine]); + + const markLayoutDirty = useCallback(() => { + engine.markLayoutDirty(); + }, [engine]); + + const recalcVisibleDocIds = useCallback(() => { + engine.recalcVisibleDocIds(); + }, [engine]); + + const setDraggingId = useCallback((value) => { + engine.setDraggingId(value); + }, [engine]); + + const applySnapshotDimensions = useCallback((docKey, snapshot) => { + const width = Number(snapshot?.width); + const height = Number(snapshot?.height); + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + return; + } + const normalized = clampCardDimensions(width, height); + if (!normalized) { + return; + } + const existing = docSizeMapRef.current.get(docKey); + if (existing && existing.width === normalized.width && existing.height === normalized.height) { + return; + } + const next = new Map(docSizeMapRef.current); + next.set(docKey, { ...normalized, source: 'snapshot' }); + docSizeMapRef.current = next; + setDocSizeVersion((value) => value + 1); + }, []); + const handleNavigatorSnapshot = useCallback( + (docId, snapshot) => { + const docKey = docId != null ? String(docId) : null; + if (!docKey) { + return; + } + + setPreviewSnapshots((previous) => { + const prevSnapshot = previous.get(docKey); + if (!snapshot) { + if (!previous.has(docKey)) { + return previous; + } + const next = new Map(previous); + next.delete(docKey); + return next; + } + + const next = new Map(previous); + const sameSnapshot = + prevSnapshot && + prevSnapshot.url === snapshot.url && + prevSnapshot.alt === snapshot.alt && + prevSnapshot.canGoPrev === snapshot.canGoPrev && + prevSnapshot.canGoNext === snapshot.canGoNext && + prevSnapshot.goPrev === snapshot.goPrev && + prevSnapshot.goNext === snapshot.goNext && + prevSnapshot.ordinal === snapshot.ordinal && + prevSnapshot.width === snapshot.width && + prevSnapshot.height === snapshot.height; + if (sameSnapshot) { + return previous; + } + next.set(docKey, snapshot); + return next; + }); + + if (snapshot) { + applySnapshotDimensions(docKey, snapshot); + } + }, + [applySnapshotDimensions], + ); + const activeTagSet = useMemo(() => { + if (!Array.isArray(activeTagIds) || activeTagIds.length === 0) { + return new Set(); + } + const set = new Set(); + activeTagIds.forEach((id) => { + if (id != null) { + set.add(String(id)); + } + }); + return set; + }, [activeTagIds]); + + useLayoutEffect(() => { + const container = containerRef.current; + if (!container) { + engine.setCanvasSize({ width: 0, height: 0 }); + return undefined; + } + + const commitSize = () => { + const rect = container.getBoundingClientRect(); + const width = Math.floor(rect.width) || 0; + const height = Math.floor(rect.height) || 0; + engine.setCanvasSize({ width, height }); + }; + + commitSize(); + + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', commitSize); + return () => { + window.removeEventListener('resize', commitSize); + }; + } + + const observer = new ResizeObserver(commitSize); + observer.observe(container); + return () => observer.disconnect(); + }, [engine]); + + const resolvePreviewDimensions = useCallback( + (doc) => { + if (!doc?.id) { + return null; + } + return previewMetadata.get(String(doc.id)) || null; + }, + [previewMetadata], + ); + + useEffect(() => { + if (!ensureAssetUrl) { + return; + } + + visibleDocIds.forEach((docId) => { + const doc = documentLookup.get(docId); + if (!doc) { + return; + } + resolveDocumentAssetUrl(doc, 'preview', { + ensureAssetUrl, + getAsset: getDocumentAsset, + }); + }); + }, [visibleDocIds, ensureAssetUrl, getDocumentAsset, documentLookup]); + + const requestCanvasFocus = useCallback(() => { + const canvas = containerRef.current; + if (!canvas || typeof canvas.focus !== 'function') { + return; + } + + const focusTarget = () => { + try { + canvas.focus({ preventScroll: true }); + } catch (error) { + if (DEBUG_FOCUS) { + void error; + } + } + }; + + if (typeof window === 'undefined') { + focusTarget(); + return; + } + + if (typeof window.requestAnimationFrame === 'function') { + window.requestAnimationFrame(() => { + focusTarget(); + }); + } else { + setTimeout(() => { + focusTarget(); + }, 0); + } + }, []); + + const tagInteractions = useDeskTagInteractions({ + engine, + onAssignTagToDocument, + onRemoveTagFromDocument, + requestCanvasFocus, + }); + + const { + handleTagDragEnterDoc, + handleTagDragOverDoc, + handleTagDragLeaveDoc, + handleTagDropOnDoc, + handleCanvasDragOver, + handleCanvasDragLeave, + handleCanvasDrop, + handleDocTagPointerDown, + handleDocTagDragStart, + handleDocTagDrag, + handleDocTagDragEnd, + } = tagInteractions; + + useEffect(() => { + const current = docSizeMapRef.current; + const next = new Map(current); + const itemKeys = new Set(items.filter((doc) => doc?.id != null).map((doc) => String(doc.id))); + let changed = false; + + items.forEach((doc) => { + if (!doc?.id) { + return; + } + const key = String(doc.id); + const existing = next.get(key) || null; + const meta = previewMetadata.get(key); + if (meta) { + const normalized = clampCardDimensions(meta.width, meta.height); + if (normalized) { + if (existing?.source === 'snapshot') { + return; + } + if (!existing || existing.width !== normalized.width || existing.height !== normalized.height || existing.source !== 'metadata') { + next.set(key, { ...normalized, source: 'metadata' }); + changed = true; + } + return; + } + } + + if (!existing) { + const fallback = computeFallbackCardSize(key); + if (fallback) { + next.set(key, { ...fallback, source: 'fallback' }); + changed = true; + } + } + }); + + current.forEach((_, key) => { + if (!itemKeys.has(key)) { + next.delete(key); + changed = true; + } + }); + + if (changed) { + docSizeMapRef.current = next; + setDocSizeVersion((value) => value + 1); + } + }, [items, previewMetadata]); + + const overlayDisplay = useMemo(() => { + if (!overlayDocId) { + return null; + } + const snapshot = previewSnapshots.get(overlayDocId); + if (!snapshot || !snapshot.url) { + return null; + } + const doc = documentLookup.get(overlayDocId); + const alt = snapshot.alt || doc?.title; + return { + url: snapshot.url, + alt, + canGoPrev: snapshot.canGoPrev, + canGoNext: snapshot.canGoNext, + goPrev: snapshot.goPrev, + goNext: snapshot.goNext, + }; + }, [overlayDocId, previewSnapshots, documentLookup]); + + const closeOverlay = useCallback(() => { + setOverlayDocId(null); + setOverlayOriginRect(null); + setOverlayOriginTransform(null); + }, []); + + useEffect(() => { + if (overlayDocId && !documentLookup.has(overlayDocId)) { + setOverlayDocId(null); + setOverlayOriginRect(null); + setOverlayOriginTransform(null); + } + }, [overlayDocId, documentLookup]); + + const resolveBaseMetrics = useCallback( + (doc, cardWidth, cardHeight) => { + const previewDims = doc ? resolvePreviewDimensions(doc) : null; + if (previewDims?.width && previewDims?.height) { + const baseWidth = Math.max(previewDims.width, cardWidth); + const baseHeight = Math.max(previewDims.height, cardHeight); + const scaleX = cardWidth / baseWidth; + const scaleY = cardHeight / baseHeight; + const baseScale = Math.min(scaleX, scaleY, 1); + return { + baseWidth, + baseHeight, + baseScale: Number.isFinite(baseScale) && baseScale > 0 ? baseScale : 1, + }; + } + return { + baseWidth: cardWidth, + baseHeight: cardHeight, + baseScale: 1, + }; + }, + [resolvePreviewDimensions], + ); + + + useEffect(() => { + if (draggingId && !items.some((doc) => String(doc.id) === draggingId)) { + setDraggingId(null); + } + }, [draggingId, items, setDraggingId]); + + const openOverlayForDoc = useCallback( + (docId, originInfo = null) => { + if (!docId) { + return; + } + const docKey = String(docId); + const snapshot = previewSnapshots.get(docKey); + if (!snapshot || !snapshot.url) { + return; + } + const container = itemRefs.current.get(docKey); + const imageNode = container?.querySelector?.('.desk-item__card img'); + if (!container || !imageNode) { + return; + } + const rect = imageNode.getBoundingClientRect(); + let originTransform = null; + if (originInfo) { + const { rotation = 0, scale = 1, width: originWidth, height: originHeight } = originInfo; + originTransform = { + rotation, + scaleX: scale, + scaleY: scale, + baseWidth: originWidth, + baseHeight: originHeight, + }; + } + if (!originTransform) { + const entry = engine.getLayout(docKey); + const doc = documentLookup.get(docKey) || null; + const sizeInfo = ensureDocumentSize(doc); + if (!sizeInfo) { + return; + } + const { width: cardWidth, height: cardHeight } = sizeInfo; + const { baseWidth, baseHeight, baseScale } = resolveBaseMetrics(doc, cardWidth, cardHeight); + const effectiveWidth = baseWidth * baseScale; + const effectiveHeight = baseHeight * baseScale; + originTransform = { + rotation: entry?.rotation ?? 0, + scaleX: baseScale, + scaleY: baseScale, + baseWidth: Number.isFinite(effectiveWidth) && effectiveWidth > 0 ? effectiveWidth : cardWidth, + baseHeight: Number.isFinite(effectiveHeight) && effectiveHeight > 0 ? effectiveHeight : cardHeight, + }; + } + bringToFront(docId); + setOverlayOriginRect(rect); + setOverlayOriginTransform(originTransform); + setOverlayDocId(docKey); + }, + [ + bringToFront, + previewSnapshots, + itemRefs, + setOverlayOriginTransform, + ensureDocumentSize, + resolveBaseMetrics, + documentLookup, + engine, + ], + ); + + const dragSettings = useMemo( + () => ({ + canvasPadding: DESK_CANVAS_PADDING, + defaultCanvasWidth: DESK_DEFAULT_CANVAS_WIDTH, + defaultCanvasHeight: DESK_DEFAULT_CANVAS_HEIGHT, + debugDrag: DEBUG_DRAG, + }), + [], + ); + + const viewProps = useMemo( + () => ({ + engine, + items, + containerRef, + handleCanvasDragOver, + handleCanvasDragLeave, + handleCanvasDrop, + ensureDocumentSize, + layoutSnapshot, + layoutRef, + dragTransformsRef, + itemRefs, + visibleDocIds, + draggingId, + tagDropTargetId, + pendingTagDocId, + pendingRemovalTag, + onDocumentOpen, + ensureAssetUrl, + getDocumentAsset, + handleNavigatorSnapshot, + activeTagSet, + handleTagDragEnterDoc, + handleTagDragOverDoc, + handleTagDragLeaveDoc, + handleTagDropOnDoc, + handleDocTagPointerDown, + handleDocTagDragStart, + handleDocTagDrag, + handleDocTagDragEnd, + overlayDisplay, + closeOverlay, + overlayOriginRect, + overlayOriginTransform, + onEntryPointer, + onDocumentStackSelect, + onPromoteSelection, + selectedDocumentIds, + onClearSelection, + detailPanelOpen, + onCloseDetailPanel, + documentLookup, + resolveBaseMetrics, + bringToFront, + setDraggingId, + canvasSize, + openOverlayForDoc, + recalcVisibleDocIds, + dragSettings, + onInspectDocument, + markLayoutDirty, + }), + [ + activeTagSet, + bringToFront, + canvasSize, + closeOverlay, + containerRef, + draggingId, + dragSettings, + engine, + ensureAssetUrl, + ensureDocumentSize, + getDocumentAsset, + dragTransformsRef, + handleCanvasDragLeave, + handleCanvasDragOver, + handleCanvasDrop, + handleDocTagDrag, + handleDocTagDragEnd, + handleDocTagDragStart, + handleDocTagPointerDown, + handleNavigatorSnapshot, + handleTagDragEnterDoc, + handleTagDragLeaveDoc, + handleTagDragOverDoc, + handleTagDropOnDoc, + itemRefs, + items, + layoutRef, + layoutSnapshot, + onClearSelection, + onCloseDetailPanel, + onDocumentOpen, + onDocumentStackSelect, + onEntryPointer, + onPromoteSelection, + openOverlayForDoc, + overlayDisplay, + overlayOriginRect, + overlayOriginTransform, + documentLookup, + pendingRemovalTag, + pendingTagDocId, + recalcVisibleDocIds, + resolveBaseMetrics, + setDraggingId, + selectedDocumentIds, + detailPanelOpen, + onInspectDocument, + markLayoutDirty, + tagDropTargetId, + visibleDocIds, + ], + ); + return ( + <> + + + + ); +}; + +const DesktopWorkspaceView = ({ + engine, + items, + containerRef, + handleCanvasDragOver, + handleCanvasDragLeave, + handleCanvasDrop, + ensureDocumentSize, + layoutSnapshot, + layoutRef, + itemRefs, + visibleDocIds, + draggingId, + tagDropTargetId, + pendingTagDocId, + pendingRemovalTag, + onDocumentOpen, + ensureAssetUrl, + getDocumentAsset, + handleNavigatorSnapshot, + activeTagSet, + handleTagDragEnterDoc, + handleTagDragOverDoc, + handleTagDragLeaveDoc, + handleTagDropOnDoc, + handleDocTagPointerDown, + handleDocTagDragStart, + handleDocTagDrag, + handleDocTagDragEnd, + overlayDisplay, + closeOverlay, + overlayOriginRect, + overlayOriginTransform, + onEntryPointer, + onDocumentStackSelect, + onPromoteSelection, + selectedDocumentIds, + onClearSelection, + detailPanelOpen, + onCloseDetailPanel, + documentLookup, + resolveBaseMetrics, + bringToFront, + setDraggingId, + canvasSize, + openOverlayForDoc, + recalcVisibleDocIds, + dragSettings, + onInspectDocument, + markLayoutDirty, + dragTransformsRef, +}) => { + const { handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel } = + useDocumentDrag({ + engine, + layoutRef, + dragTransformsRef, + itemRefs, + documentLookup, + ensureDocumentSize, + resolveBaseMetrics, + bringToFront, + setDraggingId, + canvasSize, + openOverlayForDoc, + recalcVisibleDocIds, + settings: dragSettings, + containerRef, + onInspectDocument, + onDocumentStackSelect, + selectedDocumentIds, + markLayoutDirty, + }); + + const { getCardPointerHandlers, handleShellKeyDown, focusShell } = useDeskPointer({ + containerRef, + items, + layoutRef, + ensureDocumentSize, + activeTagSet, + handlePointerDown, + handlePointerMove, + handlePointerUp, + handlePointerCancel, + onEntryPointer, + onDocumentStackSelect, + onPromoteSelection, + onDocumentOpen, + selectedDocumentIds, + detailPanelOpen, + onCloseDetailPanel, + openOverlayForDoc, + }); + + useEffect(() => { + focusShell(); + }, [focusShell]); + + useEffect(() => { + if (selectedDocumentIds.length) { + focusShell(); + } + }, [focusShell, selectedDocumentIds.length]); + + useEffect(() => { + if (!detailPanelOpen) { + focusShell(); + } + }, [detailPanelOpen, focusShell]); + + + + const allSizesReady = items.every((doc) => ensureDocumentSize(doc)); + + return ( + <> +
{ + if (event.target === event.currentTarget && typeof onClearSelection === 'function') { + onClearSelection(); + } + focusShell(); + }} + > +
{ + if (event.target === event.currentTarget && typeof onClearSelection === 'function') { + onClearSelection(); + } + focusShell(); + }} + > + {!allSizesReady ? ( +
+

Loading previews…

+
+ ) : items.length === 0 ? ( +
+

No documents to show here yet. Drop files to make this space come alive.

+
+ ) : ( + items.map((doc) => { + const sizeInfo = ensureDocumentSize(doc); + if (!sizeInfo) { + return null; + } + const { width: cardWidth, height: cardHeight } = sizeInfo; + const docKey = doc?.id != null ? String(doc.id) : null; + const dragOverride = docKey ? dragTransformsRef.current.get(docKey) : null; + const layout = docKey ? layoutRef.current.get(docKey) : null; + if (!dragOverride && (!layout && (!docKey || !layoutSnapshot.has(docKey)))) { + return null; + } + const centerX = dragOverride?.centerX ?? layout?.centerX; + const centerY = dragOverride?.centerY ?? layout?.centerY; + if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) { + return null; + } + const rotation = dragOverride?.rotation ?? layout?.rotation ?? 0; + const scale = dragOverride?.scale ?? 1; + const originX = centerX - cardWidth / 2; + const originY = centerY - cardHeight / 2; + const transform = formatTransform( + Math.round(originX), + Math.round(originY), + rotation, + scale, + ); + const style = { + transform, + zIndex: layout?.z ?? 1, + width: Math.round(cardWidth), + height: Math.round(cardHeight), + }; + const shouldLoad = docKey ? visibleDocIds.has(docKey) : false; + const dragging = docKey ? draggingId === docKey : false; + const docTagKeys = Array.isArray(doc?.tags) + ? doc.tags.map((tag) => (tag ? tag.id : null)).filter(Boolean) + : []; + const matchesFilter = + activeTagSet.size === 0 || docTagKeys.some((key) => activeTagSet.has(key)); + const dropActive = docKey ? tagDropTargetId === docKey : false; + const dropPending = docKey ? pendingTagDocId === docKey : false; + const isSelected = selectedDocumentIds.includes(doc.id); + const docTagTokens = docTagKeys.join(' '); + const cardPointerHandlers = getCardPointerHandlers(doc); + const registerNode = (node) => { + if (!docKey) { + return; + } + if (node) { + itemRefs.current.set(docKey, node); + } else { + itemRefs.current.delete(docKey); + } + }; + + return ( + + ); + }) + )} +
+
+ + + ); +}; + +export default DesktopWorkspace; + +const DesktopHelpOverlay = ({ open = false, onClose = null }) => { + const portalTarget = typeof document !== 'undefined' ? document.body : null; + const closeButtonRef = useRef(null); + const previousFocusRef = useRef(null); + + const handleClose = useCallback(() => { + if (typeof onClose === 'function') { + onClose(); + } + }, [onClose]); + + useEffect(() => { + if (!open || typeof window === 'undefined') { + return undefined; + } + + const handleKeyDown = (event) => { + if (!event) { + return; + } + if (event.key === 'Escape') { + event.preventDefault(); + handleClose(); + } + }; + + window.addEventListener('keydown', handleKeyDown, true); + return () => window.removeEventListener('keydown', handleKeyDown, true); + }, [open, handleClose]); + + useEffect(() => { + if (!open) { + const previous = previousFocusRef.current; + if (previous && typeof previous.focus === 'function') { + previous.focus(); + } + previousFocusRef.current = null; + return; + } + + if (typeof document !== 'undefined') { + previousFocusRef.current = document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + } + + if (closeButtonRef.current && typeof closeButtonRef.current.focus === 'function') { + closeButtonRef.current.focus(); + } + }, [open]); + + if (!open || !portalTarget) { + return null; + } + + return createPortal( +
+
+
+
+

Desk view tips

+ +
+
+

Use the desk as a freeform workspace for triage and quick comparisons.

+
    +
  • Single-click a document to open it in the detail panel.
  • +
  • Double-click to open the zoomed preview.
  • +
  • + Drag selected cards to reposition them; build a selection with + {' '} + Cmd/Ctrl + {' '}+ click or Shift-click. +
  • +
  • + Cmd/Ctrl + click with an empty selection scoops up the stack under + {' '}the pointer. +
  • +
  • Space clears the current selection.
  • +
  • + Drag tags from the sidebar onto a card to assign them, or fling a + {' '}tag away to remove it. +
  • +
+
+
+ +
+
+
, + portalTarget, + ); +}; + +export const createDesktopSurface = ({ + workspaceProps, + renderSidebarToggle, + parentBreadcrumb, + onNavigateParent, + detailProps = null, + detailOpen = false, +}) => { + if (!workspaceProps) { + return null; + } + + const { + currentFolderName, + searchResults, + onRefresh, + viewMode, + onViewModeChange, + selectedDocumentIds, + selectedFolderIds, + onDeleteSelection, + onClearSelection, + tags, + correspondents, + documentLookup, + tagLookupById, + onBulkTagAdd, + onBulkTagRemove, + onBulkCorrespondentAdd, + onBulkCorrespondentRemove, + onBulkReanalyze, + folderOptions, + onMoveDocumentsToFolder, + searchIncludeDescendants, + onToggleSearchIncludeDescendants, + } = workspaceProps; + + const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName; + const subtitle = Array.isArray(searchResults) + ? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}` + : null; + + + const documentSelectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0; + const folderSelectionCount = Array.isArray(selectedFolderIds) ? selectedFolderIds.length : 0; + const selectionCount = documentSelectionCount + folderSelectionCount; + const actions = createDocumentsTableHeaderActions({ + viewMode: viewMode || 'desk', + onViewModeChange, + onRefresh, + onShowDeskHelp: viewMode === 'desk' ? workspaceProps?.onOpenHelp : null, + includeDescendants: searchIncludeDescendants, + onToggleIncludeDescendants: onToggleSearchIncludeDescendants, + }); + + const floatingActions = selectionCount > 0 + ? ( + + ) + : null; + + const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null; + const detail = detailOpen && detailProps ? : null; + const surfaceConfig = createWorkspaceSurfaceConfig({ + key: 'workspace', + variant: 'workspace', + title, + subtitle, + sidebarToggle, + parentBreadcrumb, + onNavigateParent, + actions, + breadcrumbs: workspaceProps?.breadcrumbs || null, + selectionLabel: null, + floatingActions, + content: ( + + ), + detail, + }); + + return { + ...surfaceConfig, + supportsDetail: Boolean(detailProps), + }; +}; diff --git a/frontend/src/desktop/context.js b/frontend/src/desktop/context.js deleted file mode 100644 index 1c381f2..0000000 --- a/frontend/src/desktop/context.js +++ /dev/null @@ -1,17 +0,0 @@ -import React, { createContext, useContext } from 'react'; - -const DesktopContext = createContext(null); - -export const DesktopProvider = ({ value, children }) => ( - {children} -); - -export const useDesktopContext = () => { - const context = useContext(DesktopContext); - if (!context) { - throw new Error('useDesktopContext must be used within a DesktopProvider'); - } - return context; -}; - -export default DesktopContext; diff --git a/frontend/src/desktop/db.js b/frontend/src/desktop/db.js new file mode 100644 index 0000000..c1cdbf0 --- /dev/null +++ b/frontend/src/desktop/db.js @@ -0,0 +1,183 @@ +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; +}; + +const requestToPromise = (request, defaultValue) => new Promise((resolve, reject) => { + request.onsuccess = () => { + const { result } = request; + resolve(result ?? defaultValue); + }; + request.onerror = () => { + reject(request.error || new Error('IndexedDB request failed')); + }; +}); + +const iterateCursor = (request, iteratee) => new Promise((resolve, reject) => { + request.onsuccess = (event) => { + const cursor = event.target.result; + if (!cursor) { + resolve(); + return; + } + try { + iteratee(cursor); + cursor.continue(); + } catch (error) { + reject(error); + } + }; + request.onerror = () => { + reject(request.error || new Error('IndexedDB cursor failed')); + }; +}); + +const transactionComplete = (transaction) => new Promise((resolve, reject) => { + transaction.oncomplete = () => { + resolve(); + }; + transaction.onerror = () => { + reject(transaction.error || new Error('IndexedDB transaction failed')); + }; + transaction.onabort = () => { + reject(transaction.error || new Error('IndexedDB transaction aborted')); + }; +}); + +const withStore = async (mode, handler) => { + const db = await openDatabase(); + const transaction = db.transaction(LAYOUT_STORE, mode); + const store = transaction.objectStore(LAYOUT_STORE); + const done = transactionComplete(transaction); + try { + const result = await handler(store, transaction); + await done; + return result; + } catch (error) { + try { + transaction.abort(); + } catch (abortError) { + console.warn('[desk] Failed to abort transaction', abortError); + } + try { + await done; + } catch (suppressed) { + // noop – prefer original error + } + throw error; + } +}; + +export const fetchLayoutRecords = async ({ tenantId, viewId }) => { + if (!tenantId || !viewId) { + return []; + } + + try { + return await withStore('readonly', (store) => { + const index = store.index('tenantViewIdx'); + return requestToPromise(index.getAll([tenantId, viewId]), []); + }); + } 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 { + await withStore('readwrite', (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, + }); + }); + }); + } catch (error) { + console.warn('[desk] Failed to upsert layout records', error); + } +}; + +export const deleteTenantLayouts = async (tenantId) => { + if (!tenantId) { + return; + } + try { + await withStore('readwrite', (store) => { + const index = store.index('tenantIdx'); + const request = index.openCursor(tenantId); + return iterateCursor(request, (cursor) => { + cursor.delete(); + }); + }); + } 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/desktop/events.js b/frontend/src/desktop/events.js index 043c99f..e803064 100644 --- a/frontend/src/desktop/events.js +++ b/frontend/src/desktop/events.js @@ -13,3 +13,19 @@ export const preventAll = (event) => { console.warn('[events] stopPropagation failed', error); } }; + +export const safeInvoke = (fn, ...args) => (typeof fn === 'function' ? fn(...args) : undefined); + +export const getPointerPosition = (event, { fallbackToPage = true } = {}) => { + if (!event) { + return { x: 0, y: 0 }; + } + const clientX = Number.isFinite(event.clientX) ? event.clientX : null; + const clientY = Number.isFinite(event.clientY) ? event.clientY : null; + const pageX = fallbackToPage && Number.isFinite(event.pageX) ? event.pageX : null; + const pageY = fallbackToPage && Number.isFinite(event.pageY) ? event.pageY : null; + return { + x: clientX ?? pageX ?? 0, + y: clientY ?? pageY ?? 0, + }; +}; diff --git a/frontend/src/desktop/pointer/pointerUtils.js b/frontend/src/desktop/pointer/pointerUtils.js new file mode 100644 index 0000000..160d78a --- /dev/null +++ b/frontend/src/desktop/pointer/pointerUtils.js @@ -0,0 +1,131 @@ +import { safeInvoke } from '../events.js'; + +export const CLICK_ACTIONS = { + selectSingle: 'selectSingle', + openDetail: 'openDetail', + addCard: 'addCard', + addStack: 'addStack', + none: 'none', +}; + +export const DRAG_ACTIONS = { + dragSelectSingle: 'dragSelectSingle', + dragSelection: 'dragSelection', + dragSelectStack: 'dragSelectStack', + none: 'none', +}; + +export const STACK_HIT_EPSILON = 4; +export const POINTER_DRAG_THRESHOLD_SQUARED = 16; +export const LONG_PRESS_DURATION_MS = 450; + +export const withinThreshold = (dx, dy, thresholdSquared) => (dx * dx + dy * dy) <= thresholdSquared; + +export const createPointerIntent = ({ + doc, + entryDescriptor, + selectedDocumentIds, + metaKey, + pointerButton, + pointerType, + stackHits, +}) => { + const alreadySelected = selectedDocumentIds.includes(doc.id); + const selectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0; + + let clickAction = CLICK_ACTIONS.none; + let dragAction = DRAG_ACTIONS.none; + + if (metaKey) { + clickAction = CLICK_ACTIONS.addStack; + dragAction = DRAG_ACTIONS.dragSelectStack; + } else if (alreadySelected) { + clickAction = CLICK_ACTIONS.selectSingle; + dragAction = selectionCount > 1 ? DRAG_ACTIONS.dragSelection : DRAG_ACTIONS.dragSelectSingle; + } else { + clickAction = CLICK_ACTIONS.selectSingle; + dragAction = DRAG_ACTIONS.dragSelectSingle; + } + + const stackList = Array.isArray(stackHits) && stackHits.length > 0 + ? stackHits.slice() + : [String(doc.id)]; + + const stackDocIdsForDrag = dragAction === DRAG_ACTIONS.dragSelectStack ? stackList : null; + const stackDocIdsForClick = clickAction === CLICK_ACTIONS.addStack ? stackList : null; + + return { + docId: doc.id, + entryDescriptor, + pointerType, + pointerButton, + selectedAtDown: alreadySelected, + selectionCountAtDown: selectionCount, + metaKey, + clickAction, + dragAction, + stackDocIdsForDrag, + stackDocIdsForClick, + stackReplaceOnClick: clickAction === CLICK_ACTIONS.addStack, + stackReplaceOnDrag: dragAction === DRAG_ACTIONS.dragSelectStack, + clickSelectionApplied: false, + stackSelectionApplied: false, + longPressTriggered: false, + }; +}; + +export const applyClickPlanImmediately = ({ intent, event, onEntryPointer, onDocumentStackSelect }) => { + switch (intent.clickAction) { + case CLICK_ACTIONS.selectSingle: + case CLICK_ACTIONS.addCard: + safeInvoke(onEntryPointer, intent.entryDescriptor, event); + intent.clickSelectionApplied = true; + break; + case CLICK_ACTIONS.addStack: + if (Array.isArray(intent.stackDocIdsForClick) && intent.stackDocIdsForClick.length > 0) { + safeInvoke( + onDocumentStackSelect, + intent.stackDocIdsForClick, + event, + { replace: intent.stackReplaceOnClick }, + ); + intent.clickSelectionApplied = true; + intent.stackSelectionApplied = true; + } + break; + case CLICK_ACTIONS.openDetail: + default: + intent.clickSelectionApplied = true; + break; + } +}; + +export const finalizeClickSelection = ({ intent, event, onEntryPointer, onDocumentStackSelect }) => { + if (!intent || intent.clickSelectionApplied) { + return; + } + + applyClickPlanImmediately({ intent, event, onEntryPointer, onDocumentStackSelect }); +}; + +export const applyLongPressSelection = ({ intent, stackDocIds, syntheticEvent, onDocumentStackSelect }) => { + if (!intent) { + return; + } + + const stackCopy = Array.isArray(stackDocIds) && stackDocIds.length > 0 + ? stackDocIds.slice() + : [intent.docId]; + + safeInvoke(onDocumentStackSelect, stackCopy, syntheticEvent, { replace: true }); + + intent.clickAction = CLICK_ACTIONS.addStack; + intent.dragAction = DRAG_ACTIONS.dragSelectStack; + intent.stackDocIdsForClick = stackCopy; + intent.stackDocIdsForDrag = stackCopy; + intent.stackReplaceOnClick = true; + intent.stackReplaceOnDrag = true; + intent.clickSelectionApplied = true; + intent.stackSelectionApplied = true; + intent.longPressTriggered = true; +}; diff --git a/frontend/src/desktop/pointer/useDeskPointer.js b/frontend/src/desktop/pointer/useDeskPointer.js new file mode 100644 index 0000000..855f955 --- /dev/null +++ b/frontend/src/desktop/pointer/useDeskPointer.js @@ -0,0 +1,437 @@ +import { + useCallback, + useEffect, + useRef, +} from 'react'; +import { + CLICK_ACTIONS, + LONG_PRESS_DURATION_MS, + POINTER_DRAG_THRESHOLD_SQUARED, + STACK_HIT_EPSILON, + applyClickPlanImmediately, + applyLongPressSelection, + createPointerIntent, + finalizeClickSelection, + withinThreshold, +} from './pointerUtils'; +import { getPointerPosition, safeInvoke } from '../events.js'; + +const buildEntryDescriptor = (docId) => ({ + type: 'document', + id: docId, + key: `document:${docId}`, +}); + +export const useDeskPointer = ({ + containerRef, + items, + layoutRef, + ensureDocumentSize, + activeTagSet, + handlePointerDown, + handlePointerMove, + handlePointerUp, + handlePointerCancel, + onEntryPointer, + onDocumentStackSelect, + onPromoteSelection, + onDocumentOpen, + selectedDocumentIds, + detailPanelOpen, + onCloseDetailPanel, + openOverlayForDoc = null, +}) => { + const pointerIntentRef = useRef(null); + const pointerStartRef = useRef({ x: 0, y: 0 }); + const pointerMovedRef = useRef(false); + const longPressTimerRef = useRef(null); + const longPressActiveRef = useRef(false); + const resetLongPressState = useCallback(() => { + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current); + longPressTimerRef.current = null; + } + longPressActiveRef.current = false; + }, []); + + const resolveStackDocIds = useCallback( + (event, targetDocId = null) => { + const container = containerRef.current; + if (!container || !event) { + return []; + } + + const rect = container.getBoundingClientRect(); + const pointerCanvasX = event.clientX - rect.left; + const pointerCanvasY = event.clientY - rect.top; + + if (!Number.isFinite(pointerCanvasX) || !Number.isFinite(pointerCanvasY)) { + return []; + } + + const candidates = []; + + items.forEach((doc) => { + if (!doc?.id) { + return; + } + const docKey = String(doc.id); + const layout = layoutRef.current.get(docKey); + if (!layout) { + return; + } + + const sizeInfo = ensureDocumentSize(doc); + if (!sizeInfo) { + return; + } + const { width, height } = sizeInfo; + if (!width || !height) { + return; + } + + if (activeTagSet.size) { + const docTagKeys = Array.isArray(doc.tags) + ? doc.tags.map((tag) => (tag ? tag.id : null)).filter(Boolean) + : []; + if (!docTagKeys.some((key) => activeTagSet.has(key))) { + return; + } + } + + const centerX = Number(layout.centerX); + const centerY = Number(layout.centerY); + if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) { + return; + } + + const rotationDeg = Number(layout.rotation) || 0; + const rotationRad = (rotationDeg * Math.PI) / 180; + const dx = pointerCanvasX - centerX; + const dy = pointerCanvasY - centerY; + const cosRotation = Math.cos(-rotationRad); + const sinRotation = Math.sin(-rotationRad); + const localX = dx * cosRotation - dy * sinRotation; + const localY = dx * sinRotation + dy * cosRotation; + const halfWidth = width / 2; + const halfHeight = height / 2; + + const containsPointer = + Math.abs(localX) <= halfWidth + STACK_HIT_EPSILON + && Math.abs(localY) <= halfHeight + STACK_HIT_EPSILON; + + candidates.push({ + id: docKey, + z: Number.isFinite(layout.z) ? layout.z : 0, + centerX, + centerY, + width, + height, + halfWidth, + halfHeight, + containsPointer, + }); + }); + + const pointerCandidates = candidates.filter((candidate) => candidate.containsPointer); + if (!pointerCandidates.length) { + return []; + } + + const sortedByZ = [...pointerCandidates].sort((a, b) => (b.z ?? 0) - (a.z ?? 0)); + const targetKey = targetDocId != null ? String(targetDocId) : sortedByZ[0].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 = selected.findIndex((entry) => entry.id === targetKey); + if (targetIndex > 0) { + const [targetEntry] = selected.splice(targetIndex, 1); + selected.unshift(targetEntry); + } + } + + return selected + .map((candidate) => candidate.id) + .filter((id, index, array) => array.indexOf(id) === index); + }, + [activeTagSet, containerRef, ensureDocumentSize, items, layoutRef], + ); + + const scheduleLongPress = useCallback( + ({ doc, modifierActive, pointerType }) => { + if (modifierActive || pointerType !== 'touch') { + longPressActiveRef.current = false; + return; + } + + longPressActiveRef.current = true; + if (typeof window === 'undefined') { + return; + } + + longPressTimerRef.current = window.setTimeout(() => { + if (!longPressActiveRef.current || pointerMovedRef.current) { + resetLongPressState(); + return; + } + + const intent = pointerIntentRef.current; + if (!intent || intent.docId !== doc.id) { + resetLongPressState(); + return; + } + + const syntheticEvent = { + clientX: pointerStartRef.current.x, + clientY: pointerStartRef.current.y, + }; + const stackHits = resolveStackDocIds(syntheticEvent, doc.id); + applyLongPressSelection({ + intent, + stackDocIds: stackHits, + syntheticEvent, + onDocumentStackSelect, + }); + pointerIntentRef.current = intent; + resetLongPressState(); + }, LONG_PRESS_DURATION_MS); + }, + [onDocumentStackSelect, resolveStackDocIds, resetLongPressState], + ); + + useEffect(() => () => resetLongPressState(), [resetLongPressState]); + + const handleCardPointerDown = useCallback( + (event, doc) => { + if (!doc?.id) { + return; + } + + pointerStartRef.current = getPointerPosition(event, { fallbackToPage: false }); + pointerMovedRef.current = false; + resetLongPressState(); + + const pointerButton = typeof event.button === 'number' ? event.button : 0; + const pointerType = typeof event.pointerType === 'string' ? event.pointerType : ''; + const metaKey = (event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey; + const modifierActive = Boolean(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey); + + const entryDescriptor = buildEntryDescriptor(doc.id); + const stackHits = metaKey ? resolveStackDocIds(event, doc.id) : null; + + const intent = createPointerIntent({ + doc, + entryDescriptor, + selectedDocumentIds, + metaKey, + pointerButton, + pointerType, + stackHits, + }); + + if (intent.selectedAtDown) { + safeInvoke(onPromoteSelection, doc.id, event); + } + + applyClickPlanImmediately({ + intent, + event, + onEntryPointer, + onDocumentStackSelect, + }); + + pointerIntentRef.current = intent; + + handlePointerDown(event, doc.id, { + stackDocIds: intent.stackDocIdsForDrag, + stackSelectionApplied: intent.stackSelectionApplied, + wasSelected: intent.selectedAtDown, + modifierActive, + stackReplace: intent.stackReplaceOnDrag, + }); + + scheduleLongPress({ + doc, + modifierActive, + pointerType, + }); + }, + [ + handlePointerDown, + onPromoteSelection, + onEntryPointer, + onDocumentStackSelect, + resolveStackDocIds, + resetLongPressState, + scheduleLongPress, + selectedDocumentIds, + ], + ); + + const handleCardPointerMove = useCallback( + (event) => { + const start = pointerStartRef.current; + const { x, y } = getPointerPosition(event, { fallbackToPage: false }); + const dx = x - start.x; + const dy = y - start.y; + if (!withinThreshold(dx, dy, POINTER_DRAG_THRESHOLD_SQUARED)) { + pointerMovedRef.current = true; + resetLongPressState(); + } + handlePointerMove(event); + }, + [handlePointerMove, resetLongPressState], + ); + + const handleCardPointerUp = useCallback( + (event, doc) => { + const pointerState = pointerIntentRef.current; + const pointerMoved = pointerMovedRef.current; + + resetLongPressState(); + handlePointerUp(event); + + if (!pointerMoved && pointerState) { + finalizeClickSelection({ + intent: pointerState, + event, + onEntryPointer, + onDocumentStackSelect, + }); + + if ( + pointerState.clickAction === CLICK_ACTIONS.openDetail + && !pointerState.longPressTriggered + && pointerState.docId === doc.id + ) { + const expectedButton = typeof pointerState.pointerButton === 'number' + ? pointerState.pointerButton + : 0; + const releasedButton = typeof event.button === 'number' + ? event.button + : expectedButton; + const isPrimaryRelease = expectedButton === 0 && releasedButton === 0; + const stillSelected = Array.isArray(selectedDocumentIds) + && selectedDocumentIds.includes(doc.id); + if (isPrimaryRelease && stillSelected) { + const useSelection = pointerState.selectedAtDown && pointerState.selectionCountAtDown > 0; + safeInvoke(onDocumentOpen, doc.id, { useSelection }); + } + } + } + + pointerIntentRef.current = null; + pointerMovedRef.current = false; + }, + [ + handlePointerUp, + onDocumentOpen, + onDocumentStackSelect, + onEntryPointer, + resetLongPressState, + selectedDocumentIds, + ], + ); + + const handleCardPointerCancel = useCallback( + (event) => { + pointerMovedRef.current = false; + resetLongPressState(); + pointerIntentRef.current = null; + handlePointerCancel(event); + }, + [handlePointerCancel, resetLongPressState], + ); + + const getCardPointerHandlers = useCallback( + (doc) => ({ + onPointerDown: (event) => handleCardPointerDown(event, doc), + onPointerMove: handleCardPointerMove, + onPointerUp: (event) => handleCardPointerUp(event, doc), + onPointerCancel: handleCardPointerCancel, + }), + [ + handleCardPointerCancel, + handleCardPointerDown, + handleCardPointerMove, + handleCardPointerUp, + ], + ); + + const handleShellKeyDown = useCallback( + (event) => { + if (!event || event.defaultPrevented) { + return; + } + + const { key } = event; + if (key !== ' ' && key !== 'Space' && key !== 'Spacebar') { + return; + } + + const target = event.target; + if (target instanceof HTMLElement) { + const tagName = target.tagName ? target.tagName.toLowerCase() : ''; + if ( + target.isContentEditable + || tagName === 'input' + || tagName === 'textarea' + || tagName === 'select' + || tagName === 'button' + ) { + return; + } + } + + if ( + typeof openOverlayForDoc === 'function' + && Array.isArray(selectedDocumentIds) + && selectedDocumentIds.length > 0 + ) { + event.preventDefault(); + const targetId = selectedDocumentIds[selectedDocumentIds.length - 1]; + if (targetId) { + openOverlayForDoc(targetId); + } + return; + } + + if (detailPanelOpen) { + event.preventDefault(); + safeInvoke(onCloseDetailPanel); + } + }, + [detailPanelOpen, onCloseDetailPanel, openOverlayForDoc, selectedDocumentIds], + ); + + return { + getCardPointerHandlers, + handleShellKeyDown, + focusShell: () => { + const shell = containerRef.current; + if (shell && typeof shell.focus === 'function') { + shell.focus({ preventScroll: true }); + } + }, + }; +}; + +export default useDeskPointer; diff --git a/frontend/src/desktop/tags/useDeskTagInteractions.js b/frontend/src/desktop/tags/useDeskTagInteractions.js new file mode 100644 index 0000000..b1c4e7b --- /dev/null +++ b/frontend/src/desktop/tags/useDeskTagInteractions.js @@ -0,0 +1,352 @@ +import { + useCallback, + useEffect, + useRef, +} from 'react'; +import { getPointerPosition, preventAll, safeInvoke } from '../events.js'; +import { + isTagTransferEvent, + parseTagTransferPayload, + writeTagTransferData, +} from '../../documents/tagTransfer'; + +const TAG_REMOVE_DISTANCE = 160; +const DEBUG_DROP = false; + +const createDragPreview = (node, clientX, clientY) => { + if (!(node instanceof HTMLElement)) { + return null; + } + const rect = node.getBoundingClientRect(); + const safeClientX = Number.isFinite(clientX) ? clientX : rect.left + rect.width / 2; + const safeClientY = Number.isFinite(clientY) ? clientY : rect.top + rect.height / 2; + const offsetX = Math.min(Math.max(safeClientX - rect.left, 0), rect.width); + const offsetY = Math.min(Math.max(safeClientY - rect.top, 0), rect.height); + const clone = node.cloneNode(true); + clone.style.position = 'absolute'; + clone.style.top = '-9999px'; + clone.style.left = '-9999px'; + clone.style.pointerEvents = 'none'; + clone.style.opacity = '1'; + clone.style.transform = 'none'; + document.body.appendChild(clone); + return { clone, offsetX, offsetY }; +}; + +const cleanupPreview = (previewNode) => { + if (previewNode && previewNode.parentNode) { + previewNode.parentNode.removeChild(previewNode); + } +}; + +export const useDeskTagInteractions = ({ + engine, + onAssignTagToDocument, + onRemoveTagFromDocument, + requestCanvasFocus, +}) => { + + const draggingTagRef = useRef(null); + const pendingDocTagDragRef = useRef(null); + const removalCursorActiveRef = useRef(false); + + const updateRemovalCursor = useCallback((active) => { + if (typeof document === 'undefined') { + return; + } + if (removalCursorActiveRef.current === active) { + return; + } + const body = document.body; + if (!body) { + return; + } + removalCursorActiveRef.current = active; + if (active) { + body.classList.add('desk-cursor-remove'); + } else { + body.classList.remove('desk-cursor-remove'); + } + }, []); + + useEffect( + () => () => { + updateRemovalCursor(false); + }, + [updateRemovalCursor], + ); + + const isTagTransfer = useCallback((event) => isTagTransferEvent(event), []); + + const handleTagDragEnd = useCallback(() => { + updateRemovalCursor(false); + engine.setTagDropTargetId(null); + }, [engine, updateRemovalCursor]); + + const finalizeTagDrag = useCallback( + (dropEffect = 'none') => { + const state = draggingTagRef.current; + if (!state) { + updateRemovalCursor(false); + return; + } + + draggingTagRef.current = null; + + const node = state.element; + const showNode = () => { + if (node instanceof HTMLElement) { + node.classList.remove('is-drag-hidden'); + } + }; + const scheduleShowNode = () => { + if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') { + window.requestAnimationFrame(showNode); + } else { + setTimeout(showNode, 0); + } + }; + + cleanupPreview(state.previewClone); + + const shouldRemove = + !state.dropHandled + && dropEffect === 'none' + && state.sourceDocId + && (state.distance || 0) >= TAG_REMOVE_DISTANCE; + + if (!shouldRemove) { + scheduleShowNode(); + updateRemovalCursor(false); + return; + } + + updateRemovalCursor(false); + engine.setPendingRemovalTag({ docId: state.sourceDocId, tagId: state.tagId }); + const removePromise = safeInvoke(onRemoveTagFromDocument, state.sourceDocId, state.tagId); + if (!removePromise || typeof removePromise.then !== 'function') { + scheduleShowNode(); + engine.setPendingRemovalTag(null); + updateRemovalCursor(false); + return; + } + void (async () => { + try { + await removePromise; + void DEBUG_DROP; + } catch (error) { + console.error('Failed to remove tag after drag', error); + scheduleShowNode(); + } finally { + engine.setPendingRemovalTag(null); + } + })(); + }, + [engine, onRemoveTagFromDocument, updateRemovalCursor], + ); + + const handleDocTagPointerDown = useCallback((event, doc, tag) => { + if (!doc || !tag) { + pendingDocTagDragRef.current = null; + return; + } + const { x: startX, y: startY } = getPointerPosition(event); + pendingDocTagDragRef.current = { + docId: doc.id, + tagId: tag.id, + startX, + startY, + }; + updateRemovalCursor(false); + }, [updateRemovalCursor]); + + const markActiveTagDropHandled = useCallback((tagId, sourceDocId = null) => { + const state = draggingTagRef.current; + if (!state) { + return; + } + if (state.tagId !== tagId) { + return; + } + if (sourceDocId && state.sourceDocId !== sourceDocId) { + return; + } + state.dropHandled = true; + }, []); + + const runTagHoverTransition = useCallback( + (event, docId, { applyTarget = false, applyPending = false, updateCursor = true } = {}) => { + if (!isTagTransfer(event)) { + return; + } + preventAll(event); + if (updateCursor) { + updateRemovalCursor(false); + } + const stringId = docId != null ? String(docId) : null; + if (applyTarget) { + engine.setTagDropTargetId(stringId); + } + if (applyPending) { + engine.setPendingTagDocId(stringId); + } + }, + [engine, isTagTransfer, updateRemovalCursor], + ); + + const handleTagDragEnterDoc = useCallback( + (event, docId) => runTagHoverTransition(event, docId, { applyTarget: true }), + [runTagHoverTransition], + ); + + const handleTagDragOverDoc = useCallback( + (event, docId) => runTagHoverTransition(event, docId, { applyTarget: true, applyPending: true }), + [runTagHoverTransition], + ); + + const handleTagDragLeaveDoc = useCallback( + (event) => runTagHoverTransition(event, null, { applyTarget: true, applyPending: true }), + [runTagHoverTransition], + ); + + const handleCanvasDragOver = useCallback( + (event) => runTagHoverTransition(event, null), + [runTagHoverTransition], + ); + + const handleCanvasDragLeave = useCallback( + (event) => runTagHoverTransition(event, null, { applyTarget: true, applyPending: true }), + [runTagHoverTransition], + ); + + const handleCanvasDrop = useCallback( + (event) => runTagHoverTransition(event, null, { applyTarget: true, applyPending: true }), + [runTagHoverTransition], + ); + + const handleTagDropOnDoc = useCallback( + (event, doc) => { + if (!doc) { + return; + } + if (!isTagTransfer(event)) { + return; + } + preventAll(event); + engine.setTagDropTargetId(null); + engine.setPendingTagDocId(null); + + const payload = parseTagTransferPayload(event); + if (!payload || !payload.id) { + return; + } + markActiveTagDropHandled(payload.id, payload.sourceDocId); + + if (payload.sourceDocId === doc.id) { + return; + } + + requestCanvasFocus?.(); + + void safeInvoke(onAssignTagToDocument, doc.id, { + id: payload.id, + label: payload.label || '', + sourceDocId: payload.sourceDocId ?? null, + }); + }, + [engine, isTagTransfer, markActiveTagDropHandled, onAssignTagToDocument, requestCanvasFocus], + ); + + const handleDocTagDragStart = useCallback( + (event, doc, tag) => { + if (!event?.dataTransfer || !doc || !tag) { + return; + } + event.dataTransfer.effectAllowed = 'move'; + writeTagTransferData(event.dataTransfer, tag, doc.id); + + const { x: pointerX, y: pointerY } = getPointerPosition(event, { fallbackToPage: false }); + const { clone, offsetX, offsetY } = createDragPreview(event.currentTarget, pointerX, pointerY) || {}; + if (clone && typeof event.dataTransfer.setDragImage === 'function') { + event.dataTransfer.setDragImage(clone, offsetX || 0, offsetY || 0); + } + + const element = event.currentTarget instanceof HTMLElement ? event.currentTarget : null; + if (element) { + element.classList.add('is-drag-hidden'); + } + + draggingTagRef.current = { + element, + previewClone: clone, + sourceDocId: doc.id, + tagId: tag.id, + initialX: pointerX, + initialY: pointerY, + distance: 0, + dropHandled: false, + }; + + updateRemovalCursor(false); + }, + [updateRemovalCursor], + ); + + const handleDocTagDrag = useCallback((event) => { + const state = draggingTagRef.current; + if (!state) { + return; + } + const { x, y } = getPointerPosition(event); + const dx = x - (state.initialX || 0); + const dy = y - (state.initialY || 0); + state.distance = Math.sqrt(dx * dx + dy * dy); + if (state.distance >= TAG_REMOVE_DISTANCE) { + updateRemovalCursor(true); + } else { + updateRemovalCursor(false); + } + }, [updateRemovalCursor]); + + const handleDocTagDragEnd = useCallback( + (event) => { + finalizeTagDrag(event?.dataTransfer?.dropEffect || 'none'); + const state = draggingTagRef.current; + if (!state) { + return; + } + const element = state.element; + if (element) { + element.classList.remove('is-drag-hidden'); + } + cleanupPreview(state.previewClone); + draggingTagRef.current = null; + }, + [finalizeTagDrag], + ); + + useEffect(() => { + return () => { + draggingTagRef.current = null; + pendingDocTagDragRef.current = null; + }; + }, []); + + return { + handleTagDragEnterDoc, + handleTagDragOverDoc, + handleTagDragLeaveDoc, + handleTagDropOnDoc, + handleDocTagPointerDown, + handleDocTagDragStart, + handleDocTagDrag, + handleDocTagDragEnd, + handleTagDragEnd, + markActiveTagDropHandled, + handleCanvasDragOver, + handleCanvasDragLeave, + handleCanvasDrop, + }; +}; + +export default useDeskTagInteractions; diff --git a/frontend/src/desktop/useDeskWorkspaceProps.js b/frontend/src/desktop/useDeskWorkspaceProps.js new file mode 100644 index 0000000..32782c5 --- /dev/null +++ b/frontend/src/desktop/useDeskWorkspaceProps.js @@ -0,0 +1,230 @@ +import { useCallback, useMemo } from 'react'; + +const useDeskWorkspaceProps = ({ + documents, + searchResults, + breadcrumbs, + currentFolderName, + documentsViewMode, + handleDocumentsViewModeChange, + handleDeskExit, + refreshCurrentFolder, + inspectDocument, + handleEntryPointerCore, + promoteSelectionOrder, + currentTenantId, + selectedDocumentIds, + selectedFolderIds, + clearDocumentSelection, + detailPanelOpen, + handleDetailPanelClose, + resolveThumbnailUrlForDoc, + handleDocumentTagDrop, + handleTagRemove, + ensureAssetUrl, + getDocumentAsset, + activeTagFilters, + handleDeleteSelection, + tags, + correspondents, + documentLookup, + tagLookupById, + handleBulkTagAddFromDetail, + handleBulkTagRemoveFromDetail, + handleBulkCorrespondentAdd, + handleBulkCorrespondentRemove, + handleBulkSelectionReanalyze, + folderOptions, + moveDocumentsToFolder, + searchIncludeDescendants, + toggleSearchIncludeDescendants, + selectedEntries, + selectionAnchorRef, + applySelection, + resolveDocumentRowKey, + showingSearchResults, + searchQuery, + activeCorrespondentFilters, + selectedFolder, + setDeskHelpOpen, + deskHelpOpen, + openDetailPanel, +}) => { + const handleDeskDocumentStackSelect = useCallback( + (docIds) => { + if (!Array.isArray(docIds) || docIds.length === 0) { + return; + } + + const rowKeys = docIds + .map((id) => resolveDocumentRowKey(id)) + .filter(Boolean); + + if (!rowKeys.length) { + return; + } + + const nextKeys = [...selectedEntries]; + rowKeys.forEach((key) => { + if (!nextKeys.includes(key)) { + nextKeys.push(key); + } + }); + + const anchor = rowKeys[0] + || selectionAnchorRef.current + || nextKeys[nextKeys.length - 1]; + + applySelection(nextKeys, { + anchor, + interactedKeys: rowKeys, + }); + }, + [applySelection, resolveDocumentRowKey, selectedEntries, selectionAnchorRef], + ); + + const handleDeskDocumentOpen = useCallback( + (docId, { useSelection = false } = {}) => { + const selectionDocIds = Array.isArray(selectedDocumentIds) + ? selectedDocumentIds + : []; + let targetIds = []; + + if ((useSelection || selectionDocIds.includes(docId)) && selectionDocIds.length) { + targetIds = selectionDocIds; + } else if (selectionDocIds.length) { + targetIds = selectionDocIds; + } else if (docId) { + targetIds = [docId]; + } + + if (!targetIds.length) { + return; + } + + openDetailPanel({ documentIds: targetIds }); + }, + [openDetailPanel, selectedDocumentIds], + ); + + const handleDeskHelpOpen = useCallback(() => { + setDeskHelpOpen(true); + }, [setDeskHelpOpen]); + + const handleDeskHelpClose = useCallback(() => { + setDeskHelpOpen(false); + }, [setDeskHelpOpen]); + + 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, + ]); + + return useMemo( + () => ({ + documents, + searchResults, + breadcrumbs, + currentFolderName, + viewMode: documentsViewMode, + onViewModeChange: handleDocumentsViewModeChange, + onExit: handleDeskExit, + onRefresh: refreshCurrentFolder, + onDocumentOpen: handleDeskDocumentOpen, + onInspectDocument: inspectDocument, + onEntryPointer: handleEntryPointerCore, + onDocumentStackSelect: handleDeskDocumentStackSelect, + onPromoteSelection: promoteSelectionOrder, + onOpenHelp: handleDeskHelpOpen, + helpOpen: deskHelpOpen, + onHelpClose: handleDeskHelpClose, + tenantId: currentTenantId, + viewId: deskViewId, + selectedDocumentIds, + selectedFolderIds, + onClearSelection: clearDocumentSelection, + detailPanelOpen, + onCloseDetailPanel: handleDetailPanelClose, + resolveThumbnailUrl: resolveThumbnailUrlForDoc, + onAssignTagToDocument: handleDocumentTagDrop, + onRemoveTagFromDocument: handleTagRemove, + ensureAssetUrl, + getDocumentAsset, + activeTagIds: activeTagFilters, + onDeleteSelection: handleDeleteSelection, + tags, + correspondents, + documentLookup, + tagLookupById, + onBulkTagAdd: handleBulkTagAddFromDetail, + onBulkTagRemove: handleBulkTagRemoveFromDetail, + onBulkCorrespondentAdd: handleBulkCorrespondentAdd, + onBulkCorrespondentRemove: handleBulkCorrespondentRemove, + onBulkReanalyze: handleBulkSelectionReanalyze, + folderOptions, + onMoveDocumentsToFolder: moveDocumentsToFolder, + searchIncludeDescendants, + onToggleSearchIncludeDescendants: toggleSearchIncludeDescendants, + }), + [ + documents, + searchResults, + breadcrumbs, + currentFolderName, + documentsViewMode, + handleDocumentsViewModeChange, + handleDeskExit, + refreshCurrentFolder, + handleDeskDocumentOpen, + inspectDocument, + handleEntryPointerCore, + handleDeskDocumentStackSelect, + promoteSelectionOrder, + handleDeskHelpOpen, + deskHelpOpen, + handleDeskHelpClose, + currentTenantId, + deskViewId, + selectedDocumentIds, + selectedFolderIds, + clearDocumentSelection, + detailPanelOpen, + handleDetailPanelClose, + resolveThumbnailUrlForDoc, + handleDocumentTagDrop, + handleTagRemove, + ensureAssetUrl, + getDocumentAsset, + activeTagFilters, + handleDeleteSelection, + tags, + correspondents, + documentLookup, + tagLookupById, + handleBulkTagAddFromDetail, + handleBulkTagRemoveFromDetail, + handleBulkCorrespondentAdd, + handleBulkCorrespondentRemove, + handleBulkSelectionReanalyze, + folderOptions, + moveDocumentsToFolder, + searchIncludeDescendants, + toggleSearchIncludeDescendants, + ], + ); +}; + +export default useDeskWorkspaceProps; diff --git a/frontend/src/desktop/useDocumentDrag.js b/frontend/src/desktop/useDocumentDrag.js index 2c2b18f..354e20c 100644 --- a/frontend/src/desktop/useDocumentDrag.js +++ b/frontend/src/desktop/useDocumentDrag.js @@ -1,271 +1,148 @@ -import { useCallback, useRef } from 'react'; -import { useDesktopContext } from './context'; -import { preventAll } from './events'; -import { clamp, formatTransform } from './math'; +import { useCallback, useEffect, useRef } from 'react'; +import { preventAll, safeInvoke } from './events'; +import { clamp } from './math'; import usePointerTap from '../ui/usePointerTap'; +import { MIN_TIMESTEP, MAX_TIMESTEP, applyDomTransform } from './workspaceEngine'; const DRAG_HYSTERESIS_PX = 4; const DRAG_HYSTERESIS_SQUARED = DRAG_HYSTERESIS_PX * DRAG_HYSTERESIS_PX; -const MIN_TIMESTEP = 1 / 120; -const MAX_TIMESTEP = 1 / 20; -const MAX_DYNAMIC_ROTATION = 4; -const MAX_ANGULAR_VELOCITY = 180; -const ANGULAR_DAMPING = 11; -const TORQUE_TO_ACCELERATION = 0.006; -const SETTLE_ANGULAR_VELOCITY = 1.2; const EDGE_COLLISION_THRESHOLD = 0.5; -const useDocumentDrag = () => { +const getEventTargetElement = (event) => { + if (typeof Element === 'undefined' || !event) { + return null; + } + const candidate = event.target || (event.nativeEvent ? event.nativeEvent.target : null); + return candidate instanceof Element ? candidate : null; +}; + +const useDocumentDrag = (options = {}) => { const { + engine, layoutRef, + dragTransformsRef, itemRefs, documentLookup, ensureDocumentSize, resolveBaseMetrics, bringToFront, setDraggingId, - syncLayoutSnapshot, canvasSize, openOverlayForDoc, recalcVisibleDocIds, settings, containerRef, - onDocumentOpen, onInspectDocument, onDocumentStackSelect, selectedDocumentIds, markLayoutDirty, - } = useDesktopContext(); + } = options; - const applyTransform = useCallback( - (docId, centerX, centerY, width, height, rotation, scale = 1) => { - const node = itemRefs.current.get(docId); - if (!node) { - return; - } - node.style.transform = formatTransform( - centerX - width / 2, - centerY - height / 2, - rotation, - scale, - ); + const { + canvasPadding = 24, + defaultCanvasWidth = 1024, + defaultCanvasHeight = 680, + debugDrag = false, + } = settings || {}; + + useEffect( + () => () => { + engine?.disposeInertiaAnimations?.(); }, - [itemRefs], - ); - - const finalizeGroupDrag = useCallback( - (dragState) => { - if (!dragState?.groupItems) { - return; - } - - dragState.groupItems.forEach((item) => { - if (!item) { - return; - } - - const entryItem = layoutRef.current.get(item.docId) || {}; - const centerX = item.currentCenterX ?? entryItem.centerX ?? dragState.originCenterX; - const centerY = item.currentCenterY ?? entryItem.centerY ?? dragState.originCenterY; - const rotation = item.displayRotation ?? entryItem.rotation ?? 0; - - layoutRef.current.set(item.docId, { - ...entryItem, - centerX, - centerY, - rotation, - }); - - applyTransform( - item.docId, - centerX, - centerY, - item.width, - item.height, - rotation, - item.docId === dragState.docKey ? dragState.dragScale || 1 : 1, - ); - }); - - markLayoutDirty?.(); - }, - [applyTransform, layoutRef, markLayoutDirty], + [engine], ); const tapHandler = usePointerTap({ delay: 220, - onSingle: ({ data, event }) => { - if (!data || !data.docId) { - return; - } - if (event && (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)) { - return; - } - if (typeof onInspectDocument === 'function') { - onInspectDocument(data.docId); - return; - } - onDocumentOpen?.(data.docId); - }, + onSingle: () => {}, onDouble: ({ data, event }) => { if (!data || !data.docId) { return; } - if (event && (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)) { + if (event?.altKey) { + openOverlayForDoc(data.docId, data.originInfo); return; } - openOverlayForDoc(data.docId, data.originInfo); + if (typeof onInspectDocument === 'function') { + onInspectDocument(data.docId, event); + } }, }); - const dragStateRef = useRef(null); - const inertiaAnimationsRef = useRef(new Map()); - const { canvasPadding, defaultCanvasWidth, defaultCanvasHeight, debugDrag } = settings; - const cancelInertiaAnimation = useCallback((docId) => { - if (typeof window === 'undefined') { - inertiaAnimationsRef.current.delete(docId); + const setDragTransform = useCallback((docKey, transform) => { + if (!docKey) { return; } - const existing = inertiaAnimationsRef.current.get(docId); - if (existing && typeof window.cancelAnimationFrame === 'function') { - window.cancelAnimationFrame(existing.frameId); + const map = dragTransformsRef?.current; + if (!map) { + return; } - inertiaAnimationsRef.current.delete(docId); - }, []); + map.set(String(docKey), transform); + }, [dragTransformsRef]); - const integrateRotation = useCallback( - (simulationState, dt, torque = 0, dampingOverride = null) => { - const { docId } = simulationState; - const entry = layoutRef.current.get(docId); - if (!entry) { - return true; - } + const clearDragTransforms = useCallback(() => { + const map = dragTransformsRef?.current; + if (!map || typeof map.clear !== 'function') { + return; + } + map.clear(); + }, [dragTransformsRef]); - const centerX = Number(entry.centerX); - const centerY = Number(entry.centerY); - if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) { - return true; - } - - const torqueAcceleration = torque * TORQUE_TO_ACCELERATION; - let angularVelocity = simulationState.angularVelocity + torqueAcceleration * dt; - angularVelocity = clamp(angularVelocity, -MAX_ANGULAR_VELOCITY, MAX_ANGULAR_VELOCITY); - - const dampingConstant = typeof dampingOverride === 'number' ? dampingOverride : ANGULAR_DAMPING; - const dampingFactor = Math.exp(-dampingConstant * dt); - angularVelocity *= dampingFactor; - - let dynamicRotation = simulationState.dynamicRotation + angularVelocity * dt; - if (dynamicRotation > MAX_DYNAMIC_ROTATION) { - dynamicRotation = MAX_DYNAMIC_ROTATION; - angularVelocity = Math.min(angularVelocity, 0); - } else if (dynamicRotation < -MAX_DYNAMIC_ROTATION) { - dynamicRotation = -MAX_DYNAMIC_ROTATION; - angularVelocity = Math.max(angularVelocity, 0); - } - - simulationState.angularVelocity = angularVelocity; - simulationState.dynamicRotation = dynamicRotation; - simulationState.rotation = simulationState.restRotation + dynamicRotation; - - const rotation = simulationState.rotation; - layoutRef.current.set(docId, { ...entry, rotation }); - - const node = itemRefs.current.get(docId); - if (node) { - node.style.transform = formatTransform( - centerX - simulationState.width / 2, - centerY - simulationState.height / 2, - rotation, - simulationState.dragScale || 1, - ); - } - - const isSettled = Math.abs(angularVelocity) < SETTLE_ANGULAR_VELOCITY; - return isSettled; - }, - [itemRefs, layoutRef], - ); - - const startInertiaAnimation = useCallback( - (docId, baseState) => { - if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') { + const commitActiveDragTransforms = useCallback((docIds = null) => { + const map = dragTransformsRef?.current; + if (!map || !map.size) { + return; + } + const keys = Array.isArray(docIds) && docIds.length + ? docIds.map((id) => (id != null ? String(id) : null)).filter(Boolean) + : Array.from(map.keys()); + keys.forEach((key) => { + const transform = map.get(key); + if (!transform) { return; } - cancelInertiaAnimation(docId); - const now = - typeof performance !== 'undefined' && typeof performance.now === 'function' - ? performance.now() - : Date.now(); - const simulationState = { - ...baseState, - docId, - dragScale: baseState.dragScale || 1, - lastTimestamp: now, - }; - - const step = (timestamp) => { - const safeTimestamp = Number.isFinite(timestamp) ? timestamp : now + 16; - const previous = simulationState.lastTimestamp; - let dt = (safeTimestamp - previous) / 1000; - if (!Number.isFinite(dt) || dt <= 0) { - dt = MIN_TIMESTEP; - } - dt = clamp(dt, MIN_TIMESTEP, MAX_TIMESTEP); - simulationState.lastTimestamp = safeTimestamp; - - const settled = integrateRotation(simulationState, dt, 0); - if (settled) { - inertiaAnimationsRef.current.delete(docId); - syncLayoutSnapshot(); - return; - } - simulationState.frameId = window.requestAnimationFrame(step); - }; - - simulationState.frameId = window.requestAnimationFrame(step); - inertiaAnimationsRef.current.set(docId, simulationState); - }, - [cancelInertiaAnimation, integrateRotation, syncLayoutSnapshot], - ); + const previous = layoutRef.current.get(key) || {}; + layoutRef.current.set(key, { + ...previous, + centerX: transform.centerX, + centerY: transform.centerY, + rotation: transform.rotation ?? previous.rotation ?? 0, + }); + }); + markLayoutDirty?.(); + }, [dragTransformsRef, layoutRef, markLayoutDirty]); const finishDrag = useCallback( - (pointerId) => { + (pointerId, { clearTransforms = true } = {}) => { const state = dragStateRef.current; - if (!state || state.pointerId !== pointerId) { - return; - } - const capturedTarget = state.capturedTarget; - if (capturedTarget && typeof capturedTarget.releasePointerCapture === 'function') { - try { - capturedTarget.releasePointerCapture(pointerId); - } catch (error) { - if (debugDrag) { - console.warn('[desk] releasePointerCapture failed', error); + if (state && state.pointerId === pointerId) { + const capturedTarget = state.capturedTarget; + if (capturedTarget && typeof capturedTarget.releasePointerCapture === 'function') { + try { + capturedTarget.releasePointerCapture(pointerId); + } catch (error) { + if (debugDrag) { + void error; + } } } } dragStateRef.current = null; - setDraggingId((current) => (current === state.docId ? null : current)); - syncLayoutSnapshot(); + setDraggingId(null); + engine?.endDrag?.(); + if (clearTransforms) { + clearDragTransforms(); + } }, - [debugDrag, setDraggingId, syncLayoutSnapshot], + [clearDragTransforms, debugDrag, engine, setDraggingId], ); const handlePointerDown = useCallback( (event, docIdInput, options = {}) => { - if (debugDrag) { - console.log( - '[desk] handlePointerDown fired for doc', - docIdInput, - 'button', - event.button, - 'pointerType', - event.pointerType, - 'pointerId', - event.pointerId, - ); + const targetElement = getEventTargetElement(event); + if (targetElement && typeof targetElement.closest === 'function' && targetElement.closest('[data-desk-tag-chip="true"]')) { + return; } preventAll(event); @@ -275,56 +152,67 @@ const useDocumentDrag = () => { return; } - cancelInertiaAnimation(docId); + engine?.cancelInertiaAnimation?.(docKey); const doc = documentLookup.get(docKey); if (!doc) { return; } - const stackDocIdsOption = Array.isArray(options?.stackDocIds) - ? options.stackDocIds + const stackDocIdsOptionRaw = options?.stackDocIds; + const stackDocIdsOption = Array.isArray(stackDocIdsOptionRaw) + ? stackDocIdsOptionRaw .map((value) => (value != null ? String(value) : null)) .filter(Boolean) : null; const stackSelectionAppliedInitial = Boolean(options?.stackSelectionApplied); + const wasSelectedAtPointerDown = Boolean(options?.wasSelected); + const pointerModifierActive = typeof options?.modifierActive === 'boolean' + ? options.modifierActive + : Boolean(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey); + const stackReplace = Boolean(options?.stackReplace); let selectionIds = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.map((id) => String(id)) : []; + if (!stackDocIdsOption && !pointerModifierActive && !wasSelectedAtPointerDown) { + selectionIds = [docKey]; + } + if (stackDocIdsOption && stackDocIdsOption.length) { - selectionIds = stackDocIdsOption; + const selectionSet = new Set(selectionIds); + stackDocIdsOption.forEach((value) => { + if (value != null) { + selectionSet.add(String(value)); + } + }); + selectionIds = Array.from(selectionSet); } const metaOrCtrl = event.metaKey || event.ctrlKey; if (!stackDocIdsOption && metaOrCtrl && !selectionIds.includes(docKey)) { selectionIds = [...selectionIds, docKey]; } - let groupDocIds = []; - if (stackDocIdsOption && stackDocIdsOption.length) { - groupDocIds = stackDocIdsOption.filter((id, index, array) => { - const unique = array.indexOf(id) === index; - return unique && documentLookup.has(id); - }); - } else if (selectionIds.includes(docKey) && selectionIds.length > 1) { - groupDocIds = selectionIds - .map((id) => String(id)) - .filter((id, index, array) => array.indexOf(id) === index && documentLookup.has(id)); + + selectionIds = selectionIds + .map((id) => String(id)) + .filter((id, index, array) => array.indexOf(id) === index && documentLookup.has(id)); + + if (!selectionIds.includes(docKey)) { + selectionIds.unshift(docKey); } - if (!groupDocIds.includes(docKey)) { - groupDocIds.unshift(docKey); + + if (!selectionIds.length) { + selectionIds = [docKey]; } - groupDocIds = groupDocIds.filter((id, index, array) => array.indexOf(id) === index); - if (!groupDocIds.length) { - groupDocIds = [docKey]; - } - const isGroupDrag = groupDocIds.length > 1; + + const isGroupDrag = selectionIds.length > 1; if (isGroupDrag) { - groupDocIds.forEach((id) => { + selectionIds.forEach((id) => { if (id !== docKey) { - cancelInertiaAnimation(id); + engine?.cancelInertiaAnimation?.(id); } }); } @@ -342,10 +230,21 @@ const useDocumentDrag = () => { const centerX = typeof entry?.centerX === 'number' ? entry.centerX : defaultCenterX; const centerY = typeof entry?.centerY === 'number' ? entry.centerY : defaultCenterY; - const modifierPressed = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey; + const modifierPressed = pointerModifierActive; if (!modifierPressed) { if (isGroupDrag) { - groupDocIds.forEach((id) => bringToFront(id)); + const layout = layoutRef.current; + const ordered = [...selectionIds] + .filter((id, index, array) => array.indexOf(id) === index) + .sort((a, b) => { + const aZ = layout.get(a)?.z ?? 0; + const bZ = layout.get(b)?.z ?? 0; + return aZ - bZ; + }); + + ordered.forEach((id) => { + bringToFront(id === docKey ? docId : id); + }); } else { bringToFront(docId); } @@ -361,7 +260,7 @@ const useDocumentDrag = () => { capturedTarget.setPointerCapture(event.pointerId); } catch (error) { if (debugDrag) { - console.warn('[desk] setPointerCapture failed', error); + void error; } } } @@ -380,8 +279,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 = selectionIds.map((id) => { const itemDoc = documentLookup.get(id); const itemSize = ensureDocumentSize(itemDoc) || sizeInfo; const itemWidth = itemSize.width || docWidth; @@ -391,10 +289,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 +299,10 @@ const useDocumentDrag = () => { height: itemHeight, currentCenterX: itemCenterX, currentCenterY: itemCenterY, - offsetX, - offsetY, + baseOffsetX, + baseOffsetY, + offsetX: baseOffsetX, + offsetY: baseOffsetY, initialRotation, displayRotation: initialRotation, targetRotation, @@ -421,11 +319,13 @@ const useDocumentDrag = () => { const hasStackSource = Array.isArray(stackDocIdsOption) && stackDocIdsOption.length > 1; dragStateRef.current = { - docId, + docId: docKey, docKey, pointerId: event.pointerId, originCenterX: centerX, originCenterY: centerY, + currentCenterX: centerX, + currentCenterY: centerY, startX: event.clientX, startY: event.clientY, rotation: entry?.rotation ?? 0, @@ -447,14 +347,34 @@ const useDocumentDrag = () => { containerRectLeft: containerLeft, containerRectTop: containerTop, isGroup: isGroupDrag, - groupDocIds, + activeDocIds: selectionIds, groupItems, groupElevated: !isGroupDrag, stackDocIds: hasStackSource ? stackDocIdsOption : null, stackSelectionApplied: stackSelectionAppliedInitial || !hasStackSource, + stackReplace, }; - setDraggingId(docId); + const state = dragStateRef.current; + + clearDragTransforms(); + state.groupItems.forEach((item) => { + if (!item?.docId) { + return; + } + setDragTransform(item.docId, { + centerX: item.currentCenterX, + centerY: item.currentCenterY, + rotation: item.displayRotation ?? item.initialRotation ?? 0, + width: item.width, + height: item.height, + scale: item.docId === state.docKey ? state.dragScale || 1 : 1, + }); + }); + + engine?.beginDrag?.(state.activeDocIds); + + setDraggingId(docKey); if (isGroupDrag) { groupItems.forEach((item) => { @@ -464,22 +384,25 @@ const useDocumentDrag = () => { const node = itemRefs.current.get(item.docId); if (node) { item.displayRotation = item.initialRotation; - node.style.transform = formatTransform( - item.currentCenterX - item.width / 2, - item.currentCenterY - item.height / 2, - item.displayRotation, - 1, - ); + const itemEntry = layoutRef.current.get(item.docId) || null; + applyDomTransform(node, { + centerX: item.currentCenterX, + centerY: item.currentCenterY, + width: item.width, + height: item.height, + rotation: item.displayRotation ?? 0, + scale: 1, + zIndex: itemEntry?.z, + }); } }); } - }, - [ + }, [ bringToFront, - canvasPadding, - cancelInertiaAnimation, - containerRef, - documentLookup, + canvasPadding, + containerRef, + documentLookup, + engine, ensureDocumentSize, layoutRef, resolveBaseMetrics, @@ -487,29 +410,19 @@ const useDocumentDrag = () => { setDraggingId, debugDrag, itemRefs, - ], -); + clearDragTransforms, + setDragTransform, + ]); const handlePointerMove = useCallback( (event) => { const state = dragStateRef.current; if (!state) { - if (debugDrag) { - console.log('[desk] handlePointerMove: no drag state for pointer', event.pointerId); + return; } - return; - } - if (state.pointerId !== event.pointerId) { - if (debugDrag) { - console.log( - '[desk] handlePointerMove: pointer mismatch expected', - state.pointerId, - 'got', - event.pointerId, - ); + if (state.pointerId !== event.pointerId) { + return; } - return; - } preventAll(event); if (state.isGroup) { @@ -531,19 +444,16 @@ const useDocumentDrag = () => { } state.moved = true; if ( - state.isGroup - && !state.stackSelectionApplied + !state.stackSelectionApplied && Array.isArray(state.stackDocIds) - && state.stackDocIds.length > 1 + && state.stackDocIds.length > 0 ) { - if (typeof onDocumentStackSelect === 'function') { - onDocumentStackSelect(state.stackDocIds); - } + safeInvoke(onDocumentStackSelect, state.stackDocIds, event, { replace: state.stackReplace }); state.stackSelectionApplied = true; } 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; @@ -551,11 +461,8 @@ const useDocumentDrag = () => { return aZ - bZ; }); - sortedGroup.forEach((id) => { - bringToFront(id); - }); - - bringToFront(state.docId); + sortedGroup.forEach((id) => bringToFront(id)); + bringToFront(state.docKey); state.groupElevated = true; } } @@ -576,22 +483,8 @@ const useDocumentDrag = () => { const centerX = clamp(desiredCenterX, minCenterX, maxCenterX); const centerY = clamp(desiredCenterY, minCenterY, maxCenterY); - const primaryEntry = layoutRef.current.get(state.docKey) || {}; - const primaryRotation = state.rotation ?? primaryEntry.rotation ?? 0; - layoutRef.current.set(state.docKey, { - ...primaryEntry, - centerX, - centerY, - }); - const primaryNode = itemRefs.current.get(state.docId); - if (primaryNode) { - primaryNode.style.transform = formatTransform( - centerX - docWidth / 2, - centerY - docHeight / 2, - primaryRotation, - state.dragScale || 1, - ); - } + state.currentCenterX = centerX; + state.currentCenterY = centerY; state.groupItems.forEach((item) => { const isPrimary = item.docId === state.docKey; @@ -599,14 +492,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; @@ -627,23 +521,20 @@ const useDocumentDrag = () => { item.displayRotation += (item.targetRotation - item.displayRotation) * rotationBlend; } - const entryItem = layoutRef.current.get(item.docId) || {}; - layoutRef.current.set(item.docId, { - ...entryItem, + const entry = layoutRef.current.get(item.docId) || null; + const payload = { centerX: item.currentCenterX, centerY: item.currentCenterY, - rotation: item.displayRotation ?? entryItem.rotation ?? 0, - }); + rotation: item.displayRotation ?? 0, + width: item.width, + height: item.height, + scale: isPrimary ? state.dragScale || 1 : 1, + zIndex: entry?.z, + }; - applyTransform( - item.docId, - item.currentCenterX, - item.currentCenterY, - item.width, - item.height, - item.displayRotation ?? entryItem.rotation ?? 0, - isPrimary ? state.dragScale || 1 : 1, - ); + setDragTransform(item.docId, payload); + const node = itemRefs.current.get(item.docId); + applyDomTransform(node, payload); }); state.lastClientX = event.clientX; @@ -655,18 +546,9 @@ const useDocumentDrag = () => { ? performance.now() : Date.now(); - recalcVisibleDocIds(); return; } if (state.locked) { - if (debugDrag) { - console.log('[desk] handlePointerMove: locked drag for doc', state.docId); - } - return; - } - - const entry = layoutRef.current.get(state.docId); - if (!entry) { return; } @@ -689,7 +571,8 @@ const useDocumentDrag = () => { const pointerCanvasX = event.clientX - containerLeft; const pointerCanvasY = event.clientY - containerTop; - const rotationDeg = entry?.rotation ?? 0; + const entry = layoutRef.current.get(state.docKey) || {}; + const rotationDeg = state.rotation ?? entry.rotation ?? 0; const rotationRad = (rotationDeg * Math.PI) / 180; const cosRot = Math.cos(rotationRad); const sinRot = Math.sin(rotationRad); @@ -698,8 +581,12 @@ const useDocumentDrag = () => { const rotatedOffsetY = state.localPointerOffsetX * sinRot + state.localPointerOffsetY * cosRot; - const previousCenterX = Number.isFinite(entry.centerX) ? entry.centerX : state.originCenterX; - const previousCenterY = Number.isFinite(entry.centerY) ? entry.centerY : state.originCenterY; + const previousCenterX = Number.isFinite(state.currentCenterX) + ? state.currentCenterX + : state.originCenterX; + const previousCenterY = Number.isFinite(state.currentCenterY) + ? state.currentCenterY + : state.originCenterY; const absCos = Math.abs(cosRot); const absSin = Math.abs(sinRot); @@ -731,7 +618,7 @@ const useDocumentDrag = () => { if (distanceSquared < DRAG_HYSTERESIS_SQUARED) { return; } - bringToFront(state.docId); + bringToFront(state.docKey); state.moved = true; } @@ -748,28 +635,23 @@ const useDocumentDrag = () => { currentCenterY = previousCenterY; } - const updated = { ...entry, centerX: currentCenterX, centerY: currentCenterY }; - layoutRef.current.set(state.docId, updated); + state.currentCenterX = currentCenterX; + state.currentCenterY = currentCenterY; - applyTransform( - state.docId, - currentCenterX, - currentCenterY, - state.width, - state.height, - rotationDeg, - state.dragScale || 1, - ); + const layoutEntry = layoutRef.current.get(state.docKey) || null; + const transformPayload = { + centerX: currentCenterX, + centerY: currentCenterY, + rotation: rotationDeg, + width: state.width, + height: state.height, + scale: state.dragScale || 1, + zIndex: layoutEntry?.z, + }; - const primaryNode = itemRefs.current.get(state.docId); - if (primaryNode) { - primaryNode.style.transform = formatTransform( - currentCenterX - state.width / 2, - currentCenterY - state.height / 2, - rotationDeg, - state.dragScale || 1, - ); - } + setDragTransform(state.docKey, transformPayload); + const primaryNode = itemRefs.current.get(state.docKey); + applyDomTransform(primaryNode, transformPayload); const offsetX = pointerCanvasX - currentCenterX; const offsetY = pointerCanvasY - currentCenterY; @@ -810,10 +692,7 @@ const useDocumentDrag = () => { state.localPointerOffsetY = updatedLocalOffsetY; } - if (debugDrag) { - console.log('[desk] handlePointerMove: moved doc', state.docId, 'to', currentCenterX, currentCenterY); - } - recalcVisibleDocIds(); + void debugDrag; }, [ bringToFront, @@ -825,10 +704,9 @@ const useDocumentDrag = () => { containerRef, layoutRef, itemRefs, - applyTransform, - recalcVisibleDocIds, debugDrag, onDocumentStackSelect, + setDragTransform, ], ); @@ -841,13 +719,15 @@ const useDocumentDrag = () => { } if (state.isGroup) { - finalizeGroupDrag(state); + engine?.finalizeGroupDrag?.(state); + commitActiveDragTransforms(state.activeDocIds); finishDrag(event.pointerId); recalcVisibleDocIds(); return; } if (state.moved) { + commitActiveDragTransforms([state.docKey]); const inertiaState = { restRotation: state.restRotation, dynamicRotation: state.dynamicRotation, @@ -857,13 +737,13 @@ const useDocumentDrag = () => { height: state.height, dragScale: state.dragScale || 1, }; - const docId = state.docId; + const docId = state.docKey; finishDrag(event.pointerId); - startInertiaAnimation(docId, inertiaState); + engine?.startInertiaAnimation?.(docId, inertiaState); return; } - const docId = state.docId; + const docId = state.docKey; const metaPressed = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey; if (!metaPressed) { bringToFront(docId); @@ -885,10 +765,10 @@ const useDocumentDrag = () => { }, [ bringToFront, + commitActiveDragTransforms, documentLookup, + engine, finishDrag, - finalizeGroupDrag, - startInertiaAnimation, recalcVisibleDocIds, tapHandler, ], @@ -899,12 +779,14 @@ const useDocumentDrag = () => { const state = dragStateRef.current; if (state && state.pointerId === event.pointerId && state.moved) { if (state.isGroup) { - finalizeGroupDrag(state); + engine?.finalizeGroupDrag?.(state); + commitActiveDragTransforms(state.activeDocIds); finishDrag(event.pointerId); recalcVisibleDocIds(); return; } + commitActiveDragTransforms([state.docKey]); const inertiaState = { restRotation: state.restRotation, dynamicRotation: state.dynamicRotation, @@ -914,14 +796,14 @@ const useDocumentDrag = () => { height: state.height, dragScale: state.dragScale || 1, }; - const docId = state.docId; + const docId = state.docKey; finishDrag(event.pointerId); - startInertiaAnimation(docId, inertiaState); + engine?.startInertiaAnimation?.(docId, inertiaState); return; } finishDrag(event.pointerId); }, - [finalizeGroupDrag, finishDrag, recalcVisibleDocIds, startInertiaAnimation], + [commitActiveDragTransforms, engine, finishDrag, recalcVisibleDocIds], ); return { diff --git a/frontend/src/desktop/workspaceEngine.js b/frontend/src/desktop/workspaceEngine.js new file mode 100644 index 0000000..3effcb7 --- /dev/null +++ b/frontend/src/desktop/workspaceEngine.js @@ -0,0 +1,1248 @@ +import { clamp, formatTransform } from './math.js'; +import { fetchLayoutRecords, upsertLayoutRecords } from './db.js'; + +export const DESK_CANVAS_PADDING = 24; +export const DESK_ROTATION_RANGE = 7; +export const DESK_DEFAULT_CANVAS_WIDTH = 1024; +export const DESK_DEFAULT_CANVAS_HEIGHT = 680; +export const DESK_CARD_MIN = 240; +export const DESK_CARD_MAX = 340; + +const DEFAULT_Z_START = 10; +export const MIN_TIMESTEP = 1 / 120; +export const MAX_TIMESTEP = 1 / 20; +export const MAX_DYNAMIC_ROTATION = 4; +export const MAX_ANGULAR_VELOCITY = 180; +export const ANGULAR_DAMPING = 11; +export const TORQUE_TO_ACCELERATION = 0.006; +export const SETTLE_ANGULAR_VELOCITY = 1.2; + +export const applyDomTransform = ( + node, + { + centerX, + centerY, + width, + height, + rotation = 0, + scale = 1, + zIndex, + } = {}, +) => { + if (!node) { + return; + } + const w = Number(width) || 0; + const h = Number(height) || 0; + const cx = Number(centerX) || 0; + const cy = Number(centerY) || 0; + 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) => { + const w = Number(width); + const h = Number(height); + + if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) { + return null; + } + + const low = Math.max(DESK_CARD_MIN / w, DESK_CARD_MIN / h); + const high = Math.min(DESK_CARD_MAX / w, DESK_CARD_MAX / h); + + const candidates = []; + const addCandidate = (scale) => { + if (Number.isFinite(scale) && scale > 0) { + candidates.push(scale); + } + }; + + addCandidate(1); + addCandidate(low); + addCandidate(high); + + const best = candidates.reduce((acc, scale) => { + const scaledWidth = w * scale; + const scaledHeight = h * scale; + const violation = Math.max( + Math.max(DESK_CARD_MIN - scaledWidth, 0), + Math.max(scaledWidth - DESK_CARD_MAX, 0), + Math.max(DESK_CARD_MIN - scaledHeight, 0), + Math.max(scaledHeight - DESK_CARD_MAX, 0), + ); + const deviation = Math.abs(scale - 1); + if (!acc || violation < acc.violation || (violation === acc.violation && deviation < acc.deviation)) { + return { scale, violation, deviation }; + } + return acc; + }, null); + + const scale = best ? best.scale : 1; + return { + width: Math.round(w * scale), + height: Math.round(h * scale), + }; +}; + +export const computeFallbackCardSize = (docId) => { + const baseSeed = seededRandom(`${docId}:fallback-size`); + const aspectSeed = seededRandom(`${docId}:fallback-aspect`); + + const width = DESK_CARD_MIN + baseSeed * (DESK_CARD_MAX - DESK_CARD_MIN); + const isPortrait = aspectSeed < 0.5; + const normalizedSeed = isPortrait ? aspectSeed / 0.5 : (aspectSeed - 0.5) / 0.5; + const aspectRange = 0.75; + const aspect = isPortrait + ? 1 + normalizedSeed * aspectRange + : 1 / (1 + normalizedSeed * aspectRange); + const height = width * aspect; + + return clampCardDimensions(width, height); +}; + +function seededRandom(input) { + const text = String(input); + let hash = 2166136261; + for (let index = 0; index < text.length; index += 1) { + hash ^= text.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0) / 4294967295; +} + +function randomRangeFromSeed(seedKey, min, max) { + const span = max - min; + if (span <= 0) return min; + const seed = seededRandom(seedKey); + return min + seed * span; +} + +function buildKey(docId, suffix) { + return `${docId}::${suffix}`; +} + +const signedDistanceToEdge = (edgeStart, edgeEnd, point) => + (edgeEnd.x - edgeStart.x) * (point.y - edgeStart.y) + - (edgeEnd.y - edgeStart.y) * (point.x - edgeStart.x); + +const iterateEdges = (polygon, callback) => { + if (!Array.isArray(polygon) || polygon.length === 0) { + return; + } + for (let index = 0; index < polygon.length; index += 1) { + const current = polygon[index]; + const next = polygon[(index + 1) % polygon.length]; + if (callback(current, next, index) === false) { + break; + } + } +}; + +const forEachVertex = (polygon, callback) => { + if (!Array.isArray(polygon) || polygon.length === 0) { + return; + } + for (let index = 0; index < polygon.length; index += 1) { + const current = polygon[index]; + const prev = polygon[(index - 1 + polygon.length) % polygon.length]; + if (callback(current, prev, index) === false) { + break; + } + } +}; + +const lineIntersection = (p1, p2, cp1, cp2) => { + const A1 = p2.y - p1.y; + const B1 = p1.x - p2.x; + const C1 = A1 * p1.x + B1 * p1.y; + + const A2 = cp2.y - cp1.y; + const B2 = cp1.x - cp2.x; + const C2 = A2 * cp1.x + B2 * cp1.y; + + const det = A1 * B2 - A2 * B1; + if (Math.abs(det) < 1e-6) { + return { x: cp1.x, y: cp1.y }; + } + return { + x: (B2 * C1 - B1 * C2) / det, + y: (A1 * C2 - A2 * C1) / det, + }; +}; + +const clipPolygon = (subject, clipper) => { + if (!Array.isArray(subject) || !subject.length) { + return []; + } + let output = subject; + iterateEdges(clipper, (cp1, cp2) => { + const input = output; + output = []; + if (!Array.isArray(input) || !input.length) { + return false; + } + forEachVertex(input, (current, prev) => { + const currentInside = signedDistanceToEdge(cp1, cp2, current) >= 0; + const prevInside = signedDistanceToEdge(cp1, cp2, prev) >= 0; + if (currentInside) { + if (!prevInside) { + output.push(lineIntersection(prev, current, cp1, cp2)); + } + output.push(current); + } else if (prevInside) { + output.push(lineIntersection(prev, current, cp1, cp2)); + } + return true; + }); + return output.length > 0; + }); + return output; +}; + +const isPointInsideConvex = (point, polygon) => { + if (!polygon?.length) { + return false; + } + let sign = 0; + let inside = true; + iterateEdges(polygon, (a, b) => { + const cross = signedDistanceToEdge(a, b, point); + if (cross === 0) { + return true; + } + const currentSign = cross > 0 ? 1 : -1; + if (sign === 0) { + sign = currentSign; + return true; + } + if (sign !== currentSign) { + inside = false; + return false; + } + return true; + }); + return inside; +}; + +const polygonCentroid = (polygon) => { + if (!polygon?.length) { + return { x: 0, y: 0 }; + } + let area = 0; + let cx = 0; + let cy = 0; + iterateEdges(polygon, (current, next) => { + const cross = current.x * next.y - next.x * current.y; + area += cross; + cx += (current.x + next.x) * cross; + cy += (current.y + next.y) * cross; + }); + if (Math.abs(area) < 1e-6) { + let sumX = 0; + let sumY = 0; + forEachVertex(polygon, (point) => { + sumX += point.x; + sumY += point.y; + }); + return { + x: sumX / polygon.length, + y: sumY / polygon.length, + }; + } + const areaFactor = 1 / (3 * area); + return { + x: cx * areaFactor, + y: cy * areaFactor, + }; +}; +const generateInitialLayout = ( + entries, + { + canvasWidth, + canvasHeight, + padding, + startZ = 0, + rotationRange = DESK_ROTATION_RANGE, + minSpacing = 48, + shelfWidth = 0, + }, +) => { + const layout = new Map(); + let currentZ = startZ; + let maxZ = startZ; + + if (!entries.length) { + return { layout, maxZ }; + } + + const shelfOffset = Math.max(shelfWidth, 0); + const spacingBuffer = Math.max(minSpacing, 0); + const placed = []; + + const resolveBounds = (width, height) => { + const halfWidth = width / 2; + const halfHeight = height / 2; + return { + minCenterX: padding + halfWidth, + maxCenterX: Math.max( + padding + halfWidth, + canvasWidth - shelfOffset - padding - halfWidth, + ), + minCenterY: padding + halfHeight, + maxCenterY: Math.max(padding + halfHeight, canvasHeight - padding - halfHeight), + }; + }; + + const evaluateCandidateSpacing = (x, y, radius) => { + if (!placed.length) { + return Number.POSITIVE_INFINITY; + } + let best = Number.POSITIVE_INFINITY; + for (let i = 0; i < placed.length; i += 1) { + const item = placed[i]; + const dx = item.x - x; + const dy = item.y - y; + const distance = Math.sqrt(dx * dx + dy * dy) - item.radius - radius - spacingBuffer; + if (distance < best) { + best = distance; + } + } + return best; + }; + + entries.forEach((entry) => { + const width = Number(entry.width) || 0; + const height = Number(entry.height) || 0; + if (!entry.id || width <= 0 || height <= 0) { + return; + } + + const { minCenterX, maxCenterX, minCenterY, maxCenterY } = resolveBounds(width, height); + + const radius = Math.sqrt(width * width + height * height) / 2; + + let bestScore = -Infinity; + let bestX = (minCenterX + maxCenterX) / 2; + let bestY = (minCenterY + maxCenterY) / 2; + const samplesPerAxis = 14; + for (let gx = 0; gx < samplesPerAxis; gx += 1) { + const fracX = (gx + 0.5) / samplesPerAxis; + for (let gy = 0; gy < samplesPerAxis; gy += 1) { + const fracY = (gy + 0.5) / samplesPerAxis; + const candidateX = minCenterX + fracX * (maxCenterX - minCenterX); + const candidateY = minCenterY + fracY * (maxCenterY - minCenterY); + const edgeSpacing = Math.min( + candidateX - minCenterX, + maxCenterX - candidateX, + candidateY - minCenterY, + maxCenterY - candidateY, + ) - spacingBuffer * 0.5; + if (edgeSpacing <= 0) { + continue; + } + const neighborSpacing = evaluateCandidateSpacing(candidateX, candidateY, radius); + const score = Math.min(edgeSpacing, neighborSpacing); + if (score > bestScore) { + bestScore = score; + bestX = candidateX; + bestY = candidateY; + } + } + } + const centerX = clamp(bestX, minCenterX, maxCenterX); + const centerY = clamp(bestY, minCenterY, maxCenterY); + + const rotation = randomRangeFromSeed( + buildKey(entry.id, 'rotation'), + -rotationRange, + rotationRange, + ); + + currentZ += 1; + layout.set(entry.id, { + centerX, + centerY, + rotation, + z: currentZ, + width, + height, + }); + maxZ = Math.max(maxZ, currentZ); + + placed.push({ x: centerX, y: centerY, radius }); + }); + + return { layout, maxZ }; +}; + +export class WorkspaceEngine { + constructor({ + allowLayoutPersistence = false, + tenantId = null, + viewId = null, + } = {}) { + this.allowLayoutPersistence = allowLayoutPersistence; + this.tenantId = tenantId; + this.viewId = viewId; + + this.layout = new Map(); + this.layoutSnapshot = new Map(); + this.persistedLayout = new Map(); + this.layoutDirty = false; + this.zCounter = DEFAULT_Z_START; + this.canvasSize = { width: 0, height: 0 }; + this.visibleDocIds = new Set(); + this.draggingId = null; + this.tagDropTargetId = null; + this.pendingTagDocId = null; + 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(); + this.ensureDocumentSize = () => null; + this.resolveBaseMetrics = () => ({ baseWidth: 0, baseHeight: 0, baseScale: 1 }); + + this.snapshotCache = this.buildSnapshot(); + this.subscribers = new Set(); + + this.loadingPersisted = false; + this.pendingPersistence = null; + this.itemRefs = { current: new Map() }; + this.inertiaAnimations = new Map(); + this.initialLoadDone = false; + } + + updateConfig({ allowLayoutPersistence, tenantId, viewId }) { + const allowChanged = + typeof allowLayoutPersistence === 'boolean' + && allowLayoutPersistence !== this.allowLayoutPersistence; + const tenantChanged = tenantId !== undefined && tenantId !== this.tenantId; + const viewChanged = viewId !== undefined && viewId !== this.viewId; + + if (!allowChanged && !tenantChanged && !viewChanged) { + if ( + this.allowLayoutPersistence + && this.tenantId + && this.viewId + && !this.initialLoadDone + && !this.loadingPersisted + ) { + this.loadPersistedLayout(); + } + return; + } + + if (allowChanged) { + this.allowLayoutPersistence = allowLayoutPersistence; + } + if (tenantChanged) { + this.tenantId = tenantId; + } + if (viewChanged) { + this.viewId = viewId; + } + + if (!this.allowLayoutPersistence) { + this.persistedLayout = new Map(); + this.layoutDirty = false; + this.emit(); + return; + } + + if (!this.tenantId || !this.viewId) { + return; + } + + if (!this.initialLoadDone) { + this.loadPersistedLayout(); + } + } + + setItems(items) { + const normalized = Array.isArray(items) ? items : []; + this.items = normalized; + const canGenerateLayoutImmediately = + !this.allowLayoutPersistence + || !this.tenantId + || !this.viewId + || this.initialLoadDone; + if (canGenerateLayoutImmediately) { + this.ensureLayoutForItems(); + } + this.recalcVisibleDocIds(); + } + + setDocumentLookup(map) { + this.documentLookup = map instanceof Map ? map : new Map(); + this.recalcVisibleDocIds(); + } + + setEnsureDocumentSize(fn) { + if (typeof fn === 'function') { + this.ensureDocumentSize = fn; + } + } + + setResolveBaseMetrics(fn) { + if (typeof fn === 'function') { + this.resolveBaseMetrics = fn; + } + } + + setItemRefs(ref) { + this.itemRefs = ref || { current: new Map() }; + } + + setCanvasSize(size) { + const width = Number(size?.width) || 0; + const height = Number(size?.height) || 0; + if (this.canvasSize.width === width && this.canvasSize.height === height) { + return; + } + this.canvasSize = { width, height }; + this.ensureLayoutForItems(); + this.recalcVisibleDocIds(); + this.emit(); + } + + setDraggingId(docId) { + const normalized = docId != null ? String(docId) : null; + if (this.draggingId === normalized) { + return; + } + this.draggingId = normalized; + this.emit(); + } + + beginDrag(docIds = []) { + this.dragInProgress = true; + if (Array.isArray(docIds)) { + this.activeDragDocIds = new Set(docIds.map((id) => (id != null ? String(id) : null)).filter(Boolean)); + } else { + this.activeDragDocIds.clear(); + } + } + + endDrag() { + this.dragInProgress = false; + this.activeDragDocIds.clear(); + this.flushPendingLayoutOps(); + } + + flushPendingLayoutOps() { + if (this.pendingSnapshotSync) { + this.syncLayoutSnapshot(); + } + if (this.pendingPersistSync) { + this.persistLayoutSnapshot(); + } + } + + setTagDropTargetId(docId) { + const normalized = docId != null ? String(docId) : null; + if (this.tagDropTargetId === normalized) { + return; + } + this.tagDropTargetId = normalized; + this.emit(); + } + + setPendingTagDocId(docId) { + const normalized = docId != null ? String(docId) : null; + if (this.pendingTagDocId === normalized) { + return; + } + this.pendingTagDocId = normalized; + this.emit(); + } + + setPendingRemovalTag(payload) { + if (payload === this.pendingRemovalTag) { + return; + } + this.pendingRemovalTag = payload; + this.emit(); + } + + markLayoutDirty() { + this.layoutDirty = true; + } + + getLayout(docId) { + if (docId == null) { + return null; + } + const key = String(docId); + return this.layout.get(key) || null; + } + + updateLayoutEntry(docId, updater) { + if (docId == null) { + return; + } + const key = String(docId); + const previous = this.layout.get(key) || null; + const next = typeof updater === 'function' ? updater(previous || {}) : updater; + if (!next) { + this.layout.delete(key); + } else { + this.layout.set(key, next); + } + this.markLayoutDirty(); + this.syncLayoutSnapshot(); + this.persistLayoutSnapshot(); + } + + bringToFront(docId) { + if (docId == null) { + return; + } + const key = String(docId); + const entry = this.layout.get(key); + if (!entry) { + return; + } + this.zCounter += 1; + 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, zIndex = null) { + const key = docId != null ? String(docId) : null; + if (!key) { + return; + } + const node = this.itemRefs?.current?.get(key); + applyDomTransform(node, { + centerX, + centerY, + width, + height, + rotation, + scale, + zIndex, + }); + } + + finalizeGroupDrag(dragState) { + if (!dragState?.groupItems) { + return; + } + + dragState.groupItems.forEach((item) => { + if (!item) { + return; + } + const key = item.docId != null ? String(item.docId) : null; + if (!key) { + return; + } + const entry = this.layout.get(key) || {}; + const centerX = item.currentCenterX ?? entry.centerX ?? dragState.originCenterX; + const centerY = item.currentCenterY ?? entry.centerY ?? dragState.originCenterY; + const rotation = item.displayRotation ?? entry.rotation ?? 0; + + const nextEntry = { + ...entry, + centerX, + centerY, + rotation, + }; + + this.layout.set(key, nextEntry); + + this.applyTransform( + key, + centerX, + centerY, + item.width, + item.height, + rotation, + key === dragState.docKey ? dragState.dragScale || 1 : 1, + nextEntry.z, + ); + }); + + this.markLayoutDirty(); + this.syncLayoutSnapshot(); + this.persistLayoutSnapshot(); + } + + cancelInertiaAnimation(docId) { + const key = docId != null ? String(docId) : null; + if (!key) { + return; + } + if (typeof window === 'undefined') { + this.inertiaAnimations.delete(key); + return; + } + const existing = this.inertiaAnimations.get(key); + if (existing && typeof window.cancelAnimationFrame === 'function') { + window.cancelAnimationFrame(existing.frameId); + } + this.inertiaAnimations.delete(key); + } + + disposeInertiaAnimations() { + if (typeof window !== 'undefined' && typeof window.cancelAnimationFrame === 'function') { + this.inertiaAnimations.forEach((animation) => { + if (animation?.frameId != null) { + window.cancelAnimationFrame(animation.frameId); + } + }); + } + this.inertiaAnimations.clear(); + } + + integrateRotation(simulationState, dt, torque = 0, dampingOverride = null) { + const key = simulationState.docId != null ? String(simulationState.docId) : null; + if (!key) { + return true; + } + const entry = this.layout.get(key); + if (!entry) { + return true; + } + + const centerX = Number(entry.centerX); + const centerY = Number(entry.centerY); + if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) { + return true; + } + + const torqueAcceleration = torque * TORQUE_TO_ACCELERATION; + let angularVelocity = simulationState.angularVelocity + torqueAcceleration * dt; + angularVelocity = clamp(angularVelocity, -MAX_ANGULAR_VELOCITY, MAX_ANGULAR_VELOCITY); + + const dampingConstant = typeof dampingOverride === 'number' ? dampingOverride : ANGULAR_DAMPING; + const dampingFactor = Math.exp(-dampingConstant * dt); + angularVelocity *= dampingFactor; + + let dynamicRotation = simulationState.dynamicRotation + angularVelocity * dt; + if (dynamicRotation > MAX_DYNAMIC_ROTATION) { + dynamicRotation = MAX_DYNAMIC_ROTATION; + angularVelocity = Math.min(angularVelocity, 0); + } else if (dynamicRotation < -MAX_DYNAMIC_ROTATION) { + dynamicRotation = -MAX_DYNAMIC_ROTATION; + angularVelocity = Math.max(angularVelocity, 0); + } + + simulationState.angularVelocity = angularVelocity; + simulationState.dynamicRotation = dynamicRotation; + simulationState.rotation = simulationState.restRotation + dynamicRotation; + + const rotation = simulationState.rotation; + const nextEntry = { ...entry, rotation }; + this.layout.set(key, nextEntry); + this.markLayoutDirty(); + + this.applyTransform( + key, + centerX, + centerY, + simulationState.width, + simulationState.height, + rotation, + simulationState.dragScale || 1, + nextEntry.z, + ); + + const isSettled = Math.abs(angularVelocity) < SETTLE_ANGULAR_VELOCITY; + return isSettled; + } + + startInertiaAnimation(docId, baseState) { + if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') { + return; + } + const key = docId != null ? String(docId) : null; + if (!key) { + return; + } + + this.cancelInertiaAnimation(key); + + const now = + typeof performance !== 'undefined' && typeof performance.now === 'function' + ? performance.now() + : Date.now(); + + const simulationState = { + ...baseState, + docId: key, + dragScale: baseState.dragScale || 1, + lastTimestamp: now, + }; + + const step = (timestamp) => { + const safeTimestamp = Number.isFinite(timestamp) ? timestamp : now + 16; + const previous = simulationState.lastTimestamp; + let dt = (safeTimestamp - previous) / 1000; + if (!Number.isFinite(dt) || dt <= 0) { + dt = MIN_TIMESTEP; + } + dt = clamp(dt, MIN_TIMESTEP, MAX_TIMESTEP); + simulationState.lastTimestamp = safeTimestamp; + + const settled = this.integrateRotation(simulationState, dt, 0); + if (settled) { + this.inertiaAnimations.delete(key); + this.syncLayoutSnapshot(); + this.persistLayoutSnapshot(); + return; + } + simulationState.frameId = window.requestAnimationFrame(step); + }; + + simulationState.frameId = window.requestAnimationFrame(step); + this.inertiaAnimations.set(key, simulationState); + } + + syncLayoutSnapshot() { + if (this.dragInProgress) { + this.pendingSnapshotSync = true; + return; + } + this.pendingSnapshotSync = false; + this.layoutSnapshot = new Map(this.layout); + this.emit(); + } + + async persistLayoutSnapshot() { + if (this.dragInProgress) { + this.pendingPersistSync = true; + return; + } + if (!this.allowLayoutPersistence || !this.tenantId || !this.viewId) { + this.pendingPersistSync = false; + return; + } + if (!this.layoutDirty && !this.pendingPersistSync) { + return; + } + this.pendingPersistSync = false; + this.layoutDirty = false; + 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) { + return; + } + const centerX = Number(entry.centerX); + const centerY = Number(entry.centerY); + if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) { + return; + } + 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 }); + }); + + this.persistedLayout = merged; + + const records = []; + merged.forEach((entry, docId) => { + if (!docId || !entry) { + return; + } + records.push({ + documentId: docId, + centerX: entry.centerX, + centerY: entry.centerY, + rotation: entry.rotation ?? 0, + zIndex: entry.z ?? 0, + }); + }); + + 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(); + } + } + + ensureLayoutForItems() { + const persistenceReady = !this.allowLayoutPersistence || !this.tenantId || !this.viewId || this.initialLoadDone; + const canvasReady = Boolean(this.canvasSize.width && this.canvasSize.height); + const sizesReady = !this.items.some((doc) => !this.ensureDocumentSize(doc)); + + if (!persistenceReady) { + return; + } + + if (!canvasReady) { + return; + } + if (!this.items.length) { + if (this.layout.size) { + this.layout = new Map(); + this.syncLayoutSnapshot(); + } + return; + } + + if (!sizesReady) { + return; + } + + const next = new Map(); + let maxZ = this.zCounter; + const canvasWidth = this.canvasSize.width || DESK_DEFAULT_CANVAS_WIDTH; + const canvasHeight = this.canvasSize.height || DESK_DEFAULT_CANVAS_HEIGHT; + const docsNeedingLayout = []; + + const currentEntries = new Map(this.layout); + + this.items.forEach((doc) => { + if (!doc?.id) { + return; + } + const docKey = String(doc.id); + const sizeInfo = this.ensureDocumentSize(doc); + if (!sizeInfo) { + return; + } + const { width: docWidth, height: docHeight } = sizeInfo; + const halfWidth = docWidth / 2; + const halfHeight = docHeight / 2; + + const minCenterX = DESK_CANVAS_PADDING + halfWidth; + const maxCenterX = Math.max(minCenterX, canvasWidth - DESK_CANVAS_PADDING - halfWidth); + const minCenterY = DESK_CANVAS_PADDING + halfHeight; + const maxCenterY = Math.max(minCenterY, canvasHeight - DESK_CANVAS_PADDING - halfHeight); + + const persisted = this.persistedLayout.get(docKey); + const currentEntry = currentEntries.get(docKey) || null; + let existing = persisted || currentEntry; + if (existing) { + const defaultCenterX = (minCenterX + maxCenterX) / 2; + const defaultCenterY = (minCenterY + maxCenterY) / 2; + const prevCenterX = typeof existing.centerX === 'number' ? existing.centerX : defaultCenterX; + const prevCenterY = typeof existing.centerY === 'number' ? existing.centerY : defaultCenterY; + const centerX = clamp(prevCenterX, minCenterX, maxCenterX); + const centerY = clamp(prevCenterY, minCenterY, maxCenterY); + const rotation = existing.rotation ?? 0; + const z = existing.z ?? maxZ; + maxZ = Math.max(maxZ, z); + next.set(docKey, { centerX, centerY, rotation, z, width: docWidth, height: docHeight }); + return; + } + + docsNeedingLayout.push({ + id: docKey, + width: docWidth, + height: docHeight, + seedKey: docKey, + }); + }); + + if (docsNeedingLayout.length) { + const { layout: generatedLayout, maxZ: updatedMaxZ } = generateInitialLayout( + docsNeedingLayout, + { + canvasWidth, + canvasHeight, + padding: DESK_CANVAS_PADDING, + startZ: maxZ, + rotationRange: DESK_ROTATION_RANGE, + minSpacing: 48, + shelfWidth: 0, + }, + ); + generatedLayout.forEach((entry, docId) => { + next.set(docId, entry); + }); + maxZ = Math.max(maxZ, updatedMaxZ); + } + + this.layout = next; + this.zCounter = Math.max(this.zCounter, maxZ); + this.syncLayoutSnapshot(); + this.persistLayoutSnapshot(); + this.recalcVisibleDocIds(); + } + + recalcVisibleDocIds() { + const ensureSize = this.ensureDocumentSize; + if (typeof ensureSize !== 'function') { + return; + } + + const layoutMap = this.layout; + const canvasWidth = this.canvasSize.width || DESK_DEFAULT_CANVAS_WIDTH; + const canvasHeight = this.canvasSize.height || DESK_DEFAULT_CANVAS_HEIGHT; + + if (!layoutMap.size || canvasWidth <= 0 || canvasHeight <= 0) { + if (this.visibleDocIds.size) { + this.visibleDocIds = new Set(); + this.emit(); + } + return; + } + + const viewport = [ + { x: 0, y: 0 }, + { x: canvasWidth, y: 0 }, + { x: canvasWidth, y: canvasHeight }, + { x: 0, y: canvasHeight }, + ]; + + const entries = []; + layoutMap.forEach((entry, docKey) => { + if (!docKey) { + return; + } + const doc = this.documentLookup.get(docKey); + if (!doc) { + return; + } + const sizeInfo = ensureSize(doc); + if (!sizeInfo) { + return; + } + const { width: cardWidth, height: cardHeight } = sizeInfo; + const rotationDeg = Number(entry?.rotation) || 0; + const rotationRad = (rotationDeg * Math.PI) / 180; + const cosRot = Math.cos(rotationRad); + const sinRot = Math.sin(rotationRad); + const halfWidth = cardWidth / 2; + const halfHeight = cardHeight / 2; + const localCorners = [ + { x: -halfWidth, y: -halfHeight }, + { x: halfWidth, y: -halfHeight }, + { x: halfWidth, y: halfHeight }, + { x: -halfWidth, y: halfHeight }, + ]; + const centerX = entry?.centerX ?? DESK_CANVAS_PADDING + cardWidth / 2; + const centerY = entry?.centerY ?? DESK_CANVAS_PADDING + cardHeight / 2; + const corners = localCorners.map(({ x, y }) => ({ + x: centerX + x * cosRot - y * sinRot, + y: centerY + x * sinRot + y * cosRot, + })); + const clipped = clipPolygon(corners, viewport); + if (!clipped.length) { + return; + } + entries.push({ + key: docKey, + z: entry?.z ?? 0, + polygon: clipped, + }); + }); + + if (!entries.length) { + if (this.visibleDocIds.size) { + this.visibleDocIds = new Set(); + this.emit(); + } + return; + } + + entries.sort((a, b) => (b.z || 0) - (a.z || 0)); + + const visiblePolygons = []; + const result = new Set(); + + entries.forEach(({ key, polygon }) => { + if (polygon.length < 3) { + return; + } + + let fullyCovered = true; + for (let i = 0; i < polygon.length; i += 1) { + const point = polygon[i]; + const inside = visiblePolygons.some((poly) => isPointInsideConvex(point, poly)); + if (!inside) { + fullyCovered = false; + break; + } + } + + if (fullyCovered) { + const centroid = polygonCentroid(polygon); + if (!visiblePolygons.some((poly) => isPointInsideConvex(centroid, poly))) { + fullyCovered = false; + } + } + + if (!fullyCovered) { + result.add(key); + visiblePolygons.push(polygon); + } + }); + + const sameSize = result.size === this.visibleDocIds.size; + if (sameSize) { + let identical = true; + result.forEach((id) => { + if (!this.visibleDocIds.has(id)) { + identical = false; + } + }); + if (identical) { + this.visibleDocIds.forEach((id) => { + if (!result.has(id)) { + identical = false; + } + }); + } + if (identical) { + return; + } + } + + this.visibleDocIds = result; + this.emit(); + } + + subscribe(listener) { + this.subscribers.add(listener); + return () => { + this.subscribers.delete(listener); + }; + } + + getSnapshot = () => this.snapshotCache; + + buildSnapshot() { + return { + layout: this.layoutSnapshot, + canvasSize: this.canvasSize, + visibleDocIds: this.visibleDocIds, + draggingId: this.draggingId, + tagDropTargetId: this.tagDropTargetId, + pendingTagDocId: this.pendingTagDocId, + pendingRemovalTag: this.pendingRemovalTag, + initialLoadDone: this.initialLoadDone, + }; + } + + emit() { + this.snapshotCache = this.buildSnapshot(); + this.subscribers.forEach((listener) => { + try { + listener(); + } catch (error) { + console.error('WorkspaceEngine listener failed', error); + } + }); + } + + async loadPersistedLayout() { + if (!this.allowLayoutPersistence || !this.tenantId || !this.viewId) { + return; + } + if (this.loadingPersisted || this.initialLoadDone) { + return; + } + this.loadingPersisted = true; + try { + const records = await fetchLayoutRecords({ tenantId: this.tenantId, viewId: this.viewId }); + 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, + }); + }); + this.persistedLayout = map; + this.layoutDirty = false; + if (records.length) { + const maxZ = records.reduce((acc, record) => Math.max(acc, Number(record.zIndex) || 0), DEFAULT_Z_START); + this.zCounter = Math.max(this.zCounter, maxZ); + } + this.layout = new Map(map); + this.layoutSnapshot = new Map(this.layout); + this.ensureLayoutForItems(); + this.initialLoadDone = true; + this.emit(); + } catch (error) { + console.warn('[desk] Failed to load persisted layout', error); + } finally { + this.loadingPersisted = false; + if (!this.initialLoadDone) { + this.initialLoadDone = true; + if (!this.layout.size) { + this.ensureLayoutForItems(); + } + this.emit(); + } + } + } +} + +export const useWorkspaceSnapshot = (engine, useSyncExternalStoreHook) => { + const useSyncExternalStore = useSyncExternalStoreHook; + if (typeof useSyncExternalStore !== 'function') { + throw new Error('useWorkspaceSnapshot requires useSyncExternalStore hook'); + } + return useSyncExternalStore( + (listener) => engine.subscribe(listener), + () => engine.getSnapshot(), + () => engine.getSnapshot(), + ); +}; + +/* istanbul ignore next */ +/* eslint-disable no-undef */ +if (typeof module !== 'undefined' && module && module.exports) { + module.exports = { + WorkspaceEngine, + DESK_CANVAS_PADDING, + DESK_ROTATION_RANGE, + DESK_DEFAULT_CANVAS_WIDTH, + DESK_DEFAULT_CANVAS_HEIGHT, + DESK_CARD_MIN, + DESK_CARD_MAX, + MIN_TIMESTEP, + MAX_TIMESTEP, + MAX_DYNAMIC_ROTATION, + MAX_ANGULAR_VELOCITY, + ANGULAR_DAMPING, + TORQUE_TO_ACCELERATION, + SETTLE_ANGULAR_VELOCITY, + clampCardDimensions, + computeFallbackCardSize, + useWorkspaceSnapshot, + }; +} +/* eslint-enable no-undef */ diff --git a/frontend/src/detail/DetailPanel.jsx b/frontend/src/detail/DetailPanel.jsx index 062b8d8..5d4acd4 100644 --- a/frontend/src/detail/DetailPanel.jsx +++ b/frontend/src/detail/DetailPanel.jsx @@ -4,26 +4,18 @@ import { ArrowLeftIcon, ArrowRightIcon, DetailPanelCollapseIcon, - AnalyzeIcon, WindowMaximizeIcon, } from '../ui/icons'; import PanelHeader from '../ui/PanelHeader'; -import { formatFileSize } from '../utils/format'; import { resolveDocumentAssetUrl, createAssetView } from '../asset_manager'; import { useAssetNavigator } from '../hooks/useAssetNavigator'; import { describeDocumentSummary } from '../documents/documentSummary'; import { createDocumentActionState } from '../documents/documentActions'; import PreviewZoomOverlay from './PreviewZoomOverlay'; -import DocumentSummarySection, { - TagSection, - CorrespondentSection, - sortCorrespondents, - buildCorrespondentOptions, -} from '../documents/DocumentSummarySection'; +import DocumentInfoPanel from '../documents/DocumentInfoPanel'; +import { sortCorrespondents, buildCorrespondentOptions } from '../documents/DocumentSummarySection'; import BreadcrumbTrail from '../ui/BreadcrumbTrail'; -const MAX_PREVIEW_STACK_ITEMS = 15; - const derivePreviewOrientation = (metadata) => { const width = Number(metadata?.width); const height = Number(metadata?.height); @@ -33,124 +25,107 @@ const derivePreviewOrientation = (metadata) => { return 'landscape'; }; -const computeStackAngle = (docId, index) => { - if (index === 0) return 0; - let hash = 0; - const source = docId || `stack-${index}`; - for (let i = 0; i < source.length; i += 1) { - hash = (hash * 31 + source.charCodeAt(i)) % 997; - } - const magnitude = Math.max(3, (hash % 13) + 3); - const sign = index % 2 === 0 ? 1 : -1; - return magnitude * sign; -}; - -const PreviewStack = ({ - items = [], - maxItems = MAX_PREVIEW_STACK_ITEMS, +const PreviewImage = ({ + item, emptyMessage = 'Preview unavailable', emptyContent = null, - onItemActivate, + onActivate, onOpenPreview, onZoomPreview, + showNav = false, + canGoPrev = false, + canGoNext = false, + onGoPrev = null, + onGoNext = null, }) => { - const limited = useMemo(() => items.slice(0, maxItems), [items, maxItems]); - const hasMultiple = limited.length > 1; - const preparedItems = useMemo( - () => - limited.map((entry, index) => ({ - entry, - angle: index === 0 ? 0 : computeStackAngle(entry.id, index), - })), - [limited], - ); - - if (!limited.length) { + if (!item) { return ( -
+
{emptyContent || {emptyMessage}}
); } + const handleActivate = (event) => { + event.stopPropagation(); + if (onZoomPreview) { + onZoomPreview(item); + } else if (onOpenPreview) { + onOpenPreview(item.id); + } else if (onActivate) { + onActivate(item.id); + } + }; + + const interceptNavPointer = (event) => { + event.preventDefault(); + event.stopPropagation(); + }; + return ( -
- {preparedItems.map(({ entry, angle }, index) => { - const transform = hasMultiple - ? `translate(-50%, -50%) rotate(${angle}deg)` - : 'translate(-50%, -50%)'; - const isFront = index === 0; - return ( -
+ {item.alt} { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + event.stopPropagation(); + handleActivate(event); + } + }} + /> + {showNav ? ( +
+
- ); - })} + + + +
+ ) : null}
); }; const DetailPanel = ({ - selectedDocuments = [], + document = null, tags = [], tagLookupById = new Map(), onTagAdd, onTagRemove, - onRegenerateThumbnails, onOpenPreview, - onBulkTagAdd, - onBulkTagRemove, - onBulkReanalyze, - onBulkCorrespondentAdd, - onBulkCorrespondentRemove, onPromoteSelection, onUpdateTitle = async () => false, onUpdateIssued = async () => false, @@ -165,13 +140,9 @@ const DetailPanel = ({ resolveFolderPath = null, onClose = () => {}, }) => { - const selectedCount = selectedDocuments.length; - const singleDoc = selectedCount === 1 ? selectedDocuments[0] : null; + const singleDoc = document || null; const singleDocId = singleDoc?.id || null; - const selectionKey = useMemo( - () => selectedDocuments.map((doc) => doc?.id ?? '').join('|'), - [selectedDocuments], - ); + const selectionKey = singleDocId || 'none'; const { downloadHref: singleDownloadHref } = useMemo( () => @@ -187,18 +158,10 @@ const DetailPanel = ({ const detailSummary = useMemo(() => describeDocumentSummary(singleDoc), [singleDoc]); - const headerTitle = useMemo(() => { - if (selectedCount === 0) { - return 'Document details'; - } - if (selectedCount === 1) { - return detailSummary.title; - } - return `${selectedCount} document${selectedCount === 1 ? '' : 's'}`; - }, [selectedCount, detailSummary]); + const headerTitle = singleDoc ? detailSummary.title : 'Document details'; const headerBreadcrumbs = useMemo(() => { - if (selectedCount !== 1 || !singleDoc || typeof resolveFolderPath !== 'function') { + if (!singleDoc || typeof resolveFolderPath !== 'function') { return null; } @@ -227,15 +190,10 @@ const DetailPanel = ({ label: detailSummary.title, }, ]; - }, [selectedCount, singleDoc, resolveFolderPath, detailSummary, onFolderNavigate]); + }, [singleDoc, resolveFolderPath, detailSummary, onFolderNavigate]); const [zoomedPreview, setZoomedPreview] = useState(null); - const bulkDocumentIds = useMemo( - () => selectedDocuments.map((doc) => doc?.id).filter(Boolean), - [selectedDocuments], - ); - useEffect(() => { setZoomedPreview(null); }, [selectionKey]); @@ -286,77 +244,22 @@ const DetailPanel = ({ [ensureAssetUrl, getDocumentAsset], ); - const stackDocuments = useMemo(() => { - if (!selectedDocuments.length) return []; - const seen = new Set(); - const ordered = []; - for (let index = selectedDocuments.length - 1; index >= 0; index -= 1) { - const doc = selectedDocuments[index]; - if (!doc?.id || seen.has(doc.id)) continue; - seen.add(doc.id); - ordered.push(doc); - if (ordered.length >= MAX_PREVIEW_STACK_ITEMS) { - break; - } - } - return ordered; - }, [selectedDocuments]); - - const stackTopDocument = stackDocuments[0] || null; - const stackTopDocId = stackTopDocument?.id || null; - const stackPreviewNavigator = useAssetNavigator({ - document: stackTopDocument, - assetType: 'preview', - ensureAssetUrl, - getAsset: getDocumentAsset, - prefetch: 3, - }); - - const singlePreviewItems = useMemo(() => { - if (!singleDoc) return []; + const singlePreviewItem = useMemo(() => { + if (!singleDoc) return null; const url = singlePreviewNavigator.currentUrl; - if (!url) { - return []; - } - const orientation = derivePreviewOrientation(singlePreviewNavigator.currentMetadata); - return [ - { + if (url) { + return { id: singleDoc.id, url, - orientation, + orientation: derivePreviewOrientation(singlePreviewNavigator.currentMetadata), alt: singleDoc.title, - }, - ]; - }, [singleDoc, singlePreviewNavigator.currentUrl, singlePreviewNavigator.currentMetadata]); - - const stackPreviews = useMemo(() => { - if (!stackDocuments.length) { - return []; + }; } - return stackDocuments - .map((doc) => { - if (!doc) return null; - if (stackTopDocument && doc.id === stackTopDocument.id) { - const url = stackPreviewNavigator.currentUrl; - if (!url) { - return null; - } - const orientation = derivePreviewOrientation(stackPreviewNavigator.currentMetadata); - return { - id: doc.id, - url, - orientation, - alt: doc.title, - }; - } - return makePreviewItem(doc, 1); - }) - .filter(Boolean); + return makePreviewItem(singleDoc, 1); }, [ - stackDocuments, - stackTopDocument, - stackPreviewNavigator.currentUrl, - stackPreviewNavigator.currentMetadata, + singleDoc, + singlePreviewNavigator.currentUrl, + singlePreviewNavigator.currentMetadata, makePreviewItem, ]); @@ -364,42 +267,6 @@ const DetailPanel = ({ const singleEffectiveCardinality = singleCardinality || (singlePreviewNavigator.currentUrl ? 1 : 0); const singleHasPreview = Boolean(singlePreviewNavigator.currentUrl); - const topDocId = stackTopDocument?.id || null; - const topCardinality = stackPreviewNavigator.cardinality; - const topEffectiveCardinality = topCardinality || (stackPreviewNavigator.currentUrl ? 1 : 0); - const topHasPreview = Boolean(stackPreviewNavigator.currentUrl); - - const bulkTagUnion = useMemo(() => { - if (!selectedDocuments.length) return []; - const tagMap = new Map(); - selectedDocuments.forEach((doc) => { - (doc.tags || []).forEach((tag) => { - if (!tag?.label) return; - const label = tag.label.trim(); - if (!label) return; - if (!tagMap.has(label)) { - const fallback = tagLookupById.get(tag.id); - tagMap.set(label, { - id: tag.id, - label, - color: tag.color ?? fallback?.color ?? null, - }); - } - }); - }); - return [...tagMap.values()].sort((a, b) => a.label.localeCompare(b.label)); - }, [selectedDocuments, tagLookupById]); - - const stackTotalSizeBytes = useMemo(() => { - if (!stackPreviews.length) return 0; - const byId = new Map(selectedDocuments.map((doc) => [doc.id, doc])); - return stackPreviews.reduce((sum, item) => { - const source = byId.get(item.id); - const bytes = source?.current_version?.size_bytes; - return sum + (typeof bytes === 'number' ? bytes : 0); - }, 0); - }, [stackPreviews, selectedDocuments]); - const correspondentOptions = useMemo( () => buildCorrespondentOptions(Array.isArray(correspondents) ? correspondents : []), [correspondents], @@ -410,77 +277,97 @@ const DetailPanel = ({ return sortCorrespondents(singleDoc.correspondents || []); }, [singleDoc]); - const bulkCorrespondents = useMemo(() => { - if (selectedDocuments.length <= 1) { - const doc = selectedDocuments[0]; - return doc ? sortCorrespondents(doc.correspondents || []) : []; - } - - const map = new Map(); - selectedDocuments.forEach((doc) => { - if (!doc?.id) return; - (doc.correspondents || []).forEach((entry) => { - if (!entry?.id || typeof entry.name !== 'string') return; - if (!map.has(entry.id)) { - map.set(entry.id, { - id: entry.id, - name: entry.name, - documentIds: new Set(), - }); - } - map.get(entry.id).documentIds.add(doc.id); - }); - }); - - return [...map.values()] - .map((entry) => ({ - id: entry.id, - name: entry.name, - documentIds: [...entry.documentIds], - count: entry.documentIds.size, - })) - .sort((a, b) => a.name.localeCompare(b.name)); - }, [selectedDocuments]); - - const handleBulkCorrespondentRemove = useCallback( - (entry) => { - if (!entry?.id) return; - if (onBulkCorrespondentRemove) { - return onBulkCorrespondentRemove({ - assignments: [ - { - correspondent_id: entry.id, - }, - ], - documentIds: entry.documentIds, - }); - } - - if (!onCorrespondentRemove) return; - const targets = entry.documentIds && entry.documentIds.length - ? entry.documentIds - : selectedDocuments - .filter((doc) => (doc.correspondents || []).some((item) => item.id === entry.id)) - .map((doc) => doc.id); - - return Promise.all( - targets.map((documentId) => - onCorrespondentRemove({ - documentId, - correspondentId: entry.id, - }), - ), - ).catch(() => {}); - }, - [onBulkCorrespondentRemove, onCorrespondentRemove, selectedDocuments], + const singleSummaryProps = useMemo( + () => ({ + tagLookupById, + tagOptions: tags, + onTagAdd: (doc, value, extras) => onTagAdd(doc, value, extras), + onTagRemove: (docId, tagId) => onTagRemove(docId, tagId), + correspondents: singleCorrespondents, + correspondentOptions, + onCorrespondentAdd, + onCorrespondentRemove, + onUpdateTitle, + onUpdateIssued, + }), + [ + tagLookupById, + tags, + onTagAdd, + onTagRemove, + singleCorrespondents, + correspondentOptions, + onCorrespondentAdd, + onCorrespondentRemove, + onUpdateTitle, + onUpdateIssued, + ], ); - const openZoomPreview = useCallback((config) => { - if (!config) return; - setZoomedPreview({ - mode: config.mode, - docId: config.docId ?? null, + const singleHasOcr = useMemo(() => { + if (!singleDoc || typeof getDocumentAsset !== 'function') { + return false; + } + return Boolean(getDocumentAsset(singleDoc, 'ocr-text')); + }, [singleDoc, getDocumentAsset]); + + const loadSingleOcrContent = useCallback(async ({ signal } = {}) => { + if (!singleDoc || !singleHasOcr || typeof getDocumentAsset !== 'function') { + return ''; + } + + const updateUrl = () => + resolveDocumentAssetUrl(singleDoc, 'ocr-text', { + ensureAssetUrl, + getAsset: getDocumentAsset, + }); + + const asset = getDocumentAsset(singleDoc, 'ocr-text'); + let url = updateUrl(); + + if (!url && singleDoc.id && asset?.id && typeof ensureAssetUrl === 'function') { + await ensureAssetUrl(singleDoc.id, asset, { start: 1, limit: 1 }); + if (signal?.aborted) { + throw new DOMException('Aborted', 'AbortError'); + } + url = updateUrl(); + } + + if (!url) { + return ''; + } + + const response = await fetch(url, { + method: 'GET', + mode: 'cors', + credentials: 'omit', + signal, }); + + if (!response.ok) { + throw new Error(`Unexpected status: ${response.status}`); + } + + return response.text(); + }, [singleDoc, singleHasOcr, getDocumentAsset, ensureAssetUrl]); + + const singleContentConfig = useMemo( + () => ({ + enabled: singleHasOcr, + id: 'content', + label: 'Content', + loadContent: loadSingleOcrContent, + loadingMessage: 'Loading OCR content…', + emptyMessage: 'No OCR content available.', + unavailableMessage: 'No OCR content available.', + errorMessage: 'Failed to load OCR content.', + }), + [singleHasOcr, loadSingleOcrContent], + ); + + const openZoomPreview = useCallback((docId) => { + if (!docId) return; + setZoomedPreview({ docId }); }, []); const closeZoomPreview = useCallback(() => { @@ -492,64 +379,32 @@ const DetailPanel = ({ if (!singleHasPreview) return; const targetId = entry?.id ?? singleDocId; if (!targetId) return; - openZoomPreview({ mode: 'single', docId: targetId }); + openZoomPreview(targetId); }, [openZoomPreview, singleHasPreview, singleDocId], ); - const handleStackZoom = useCallback( - (entry) => { - if (!stackTopDocId || entry?.id !== stackTopDocId) return; - if (!topHasPreview) return; - openZoomPreview({ mode: 'stack', docId: stackTopDocId }); - }, - [openZoomPreview, stackTopDocId, topHasPreview], - ); - const zoomDisplay = useMemo(() => { - if (!zoomedPreview) { + if ( + !zoomedPreview + || !singleDoc + || !singleDocId + || zoomedPreview.docId !== singleDocId + || !singleHasPreview + ) { return null; } - if ( - zoomedPreview.mode === 'single' && - singleDocId && - singleDoc && - singleHasPreview && - zoomedPreview.docId === singleDocId - ) { - return { - url: singlePreviewNavigator.currentUrl, - alt: singleDoc.title, - canGoPrev: - singleEffectiveCardinality > 1 && Boolean(singlePreviewNavigator.canGoPrev), - canGoNext: - singleEffectiveCardinality > 1 && Boolean(singlePreviewNavigator.canGoNext), - goPrev: singlePreviewNavigator.goPrev, - goNext: singlePreviewNavigator.goNext, - }; - } - - if ( - zoomedPreview.mode === 'stack' && - stackTopDocId && - stackTopDocument && - zoomedPreview.docId === stackTopDocId && - topHasPreview - ) { - return { - url: stackPreviewNavigator.currentUrl, - alt: stackTopDocument.title, - canGoPrev: - topEffectiveCardinality > 1 && Boolean(stackPreviewNavigator.canGoPrev), - canGoNext: - topEffectiveCardinality > 1 && Boolean(stackPreviewNavigator.canGoNext), - goPrev: stackPreviewNavigator.goPrev, - goNext: stackPreviewNavigator.goNext, - }; - } - - return null; + return { + url: singlePreviewNavigator.currentUrl, + alt: singleDoc.title, + canGoPrev: + singleEffectiveCardinality > 1 && Boolean(singlePreviewNavigator.canGoPrev), + canGoNext: + singleEffectiveCardinality > 1 && Boolean(singlePreviewNavigator.canGoNext), + goPrev: singlePreviewNavigator.goPrev, + goNext: singlePreviewNavigator.goNext, + }; }, [ zoomedPreview, singleDoc, @@ -561,15 +416,6 @@ const DetailPanel = ({ singlePreviewNavigator.goPrev, singlePreviewNavigator.goNext, singleEffectiveCardinality, - stackTopDocument, - stackTopDocId, - topHasPreview, - stackPreviewNavigator.currentUrl, - stackPreviewNavigator.canGoPrev, - stackPreviewNavigator.canGoNext, - stackPreviewNavigator.goPrev, - stackPreviewNavigator.goNext, - topEffectiveCardinality, ]); useEffect(() => { @@ -587,82 +433,41 @@ const DetailPanel = ({ cardinality: singleNavigatorCardinality, } = singlePreviewNavigator; - const { - documentId: stackNavigatorDocId, - asset: stackNavigatorAsset, - ordinal: stackNavigatorOrdinal, - canGoPrev: stackNavigatorCanGoPrev, - canGoNext: stackNavigatorCanGoNext, - cardinality: stackNavigatorCardinality, - } = stackPreviewNavigator; - useEffect(() => { if (typeof ensureAssetUrl !== 'function') { return; } + if (!singleNavigatorDocId || !singleNavigatorAsset || !Number.isFinite(singleNavigatorOrdinal)) { + return; + } - const warmNavigator = (navigator) => { - const { - documentId, - asset, - ordinal, - canGoPrev, - canGoNext, - cardinality, - } = navigator; - if (!documentId || !asset || !Number.isFinite(ordinal)) { - return; + const requests = []; + if (singleNavigatorCanGoPrev) { + const prevOrdinal = Math.max(1, singleNavigatorOrdinal - 1); + if (!singleNavigatorCardinality || prevOrdinal <= singleNavigatorCardinality) { + requests.push( + ensureAssetUrl(singleNavigatorDocId, singleNavigatorAsset, { + start: prevOrdinal, + limit: 1, + objectOrdinal: prevOrdinal, + }), + ); } - - const requests = []; - if (canGoPrev) { - const prevOrdinal = Math.max(1, ordinal - 1); - if (!cardinality || prevOrdinal <= cardinality) { - requests.push( - ensureAssetUrl(documentId, asset, { - start: prevOrdinal, - limit: 1, - objectOrdinal: prevOrdinal, - }), - ); - } - } - if (canGoNext) { - const nextOrdinal = ordinal + 1; - if (!cardinality || nextOrdinal <= cardinality) { - requests.push( - ensureAssetUrl(documentId, asset, { - start: nextOrdinal, - limit: 1, - objectOrdinal: nextOrdinal, - }), - ); - } + } + if (singleNavigatorCanGoNext) { + const nextOrdinal = singleNavigatorOrdinal + 1; + if (!singleNavigatorCardinality || nextOrdinal <= singleNavigatorCardinality) { + requests.push( + ensureAssetUrl(singleNavigatorDocId, singleNavigatorAsset, { + start: nextOrdinal, + limit: 1, + objectOrdinal: nextOrdinal, + }), + ); } + } - requests.forEach((promise) => promise?.catch?.(() => {})); - }; - - const singleWarmState = { - documentId: singleNavigatorDocId, - asset: singleNavigatorAsset, - ordinal: singleNavigatorOrdinal, - canGoPrev: singleNavigatorCanGoPrev, - canGoNext: singleNavigatorCanGoNext, - cardinality: singleNavigatorCardinality, - }; - - const stackWarmState = { - documentId: stackNavigatorDocId, - asset: stackNavigatorAsset, - ordinal: stackNavigatorOrdinal, - canGoPrev: stackNavigatorCanGoPrev, - canGoNext: stackNavigatorCanGoNext, - cardinality: stackNavigatorCardinality, - }; - - warmNavigator(singleWarmState); - warmNavigator(stackWarmState); + requests.forEach((promise) => promise?.catch?.(() => {})); }, [ ensureAssetUrl, singleNavigatorDocId, @@ -671,25 +476,18 @@ const DetailPanel = ({ singleNavigatorCanGoPrev, singleNavigatorCanGoNext, singleNavigatorCardinality, - stackNavigatorDocId, - stackNavigatorAsset, - stackNavigatorOrdinal, - stackNavigatorCanGoPrev, - stackNavigatorCanGoNext, - stackNavigatorCardinality, ]); - const renderSingle = () => { + const renderContent = () => { if (!singleDoc) { return

Select a document to view metadata, tags and actions.

; } - const effectiveCardinality = - singlePreviewNavigator.cardinality || (singlePreviewNavigator.currentUrl ? 1 : 0); - const canGoPrev = singlePreviewNavigator.canGoPrev; - const canGoNext = singlePreviewNavigator.canGoNext; + const effectiveCardinality = singleEffectiveCardinality; + const navCanGoPrev = Boolean(singlePreviewNavigator.canGoPrev); + const navCanGoNext = Boolean(singlePreviewNavigator.canGoNext); const hasPreviewImage = Boolean(singlePreviewNavigator.currentUrl); - const previewMissingAsset = !singleNavigatorAsset; + const previewMissingAsset = !singlePreviewNavigator.currentUrl; const displayContentType = singleDoc.content_type || 'this file type'; const displayFilename = singleDoc.filename || singleDoc.original_name || singleDoc.title || 'download'; @@ -713,173 +511,40 @@ const DetailPanel = ({
) : null; const emptyMessage = previewMissingAsset ? 'Preview unavailable' : 'Preview loading…'; - const interceptNavPointer = (event) => { - event.preventDefault(); - event.stopPropagation(); - }; + const showNav = hasPreviewImage && (effectiveCardinality > 1 || navCanGoPrev || navCanGoNext); return ( - <> -
- +
+ - {hasPreviewImage && (effectiveCardinality > 1 || canGoPrev || canGoNext) ? ( -
- - -
- ) : null}
- onTagAdd(doc, value, extras)} - onTagRemove={(docId, tagId) => onTagRemove(docId, tagId)} - correspondents={singleCorrespondents} - correspondentOptions={correspondentOptions} - onCorrespondentAdd={onCorrespondentAdd} - onCorrespondentRemove={onCorrespondentRemove} - onUpdateTitle={onUpdateTitle} - onUpdateIssued={onUpdateIssued} - /> - +
+ +
+
); }; - const renderBulk = () => { - const countLabel = `${selectedCount} document${selectedCount === 1 ? '' : 's'}`; - const sizeLabel = stackTotalSizeBytes ? formatFileSize(stackTotalSizeBytes) : '—'; - const headerLabel = `${countLabel}${sizeLabel ? ` (${sizeLabel})` : ''}`; - const topDocIdLocal = topDocId; - const topCardinalityLocal = topEffectiveCardinality; - const topHasPreview = Boolean(stackPreviewNavigator.currentUrl); - const topCanGoPrev = stackPreviewNavigator.canGoPrev; - const topCanGoNext = stackPreviewNavigator.canGoNext; - const interceptTopNavPointer = (event) => { - event.preventDefault(); - event.stopPropagation(); - }; - const documentIds = bulkDocumentIds; - - return ( - <> -
- - {topDocIdLocal && topHasPreview && (topCardinalityLocal > 1 || topCanGoPrev || topCanGoNext) ? ( -
- - -
- ) : null} -
-

{headerLabel}

- onBulkTagRemove?.({ label: tag.label, documentIds })} - onAdd={({ value }) => - onBulkTagAdd?.({ label: value, input: null, documentIds }) - } - addPlaceholder="Add tag to selection" - addButtonLabel="Add tag" - datalistOptions={tags} - className="bulk-tags" - /> - - onBulkCorrespondentAdd?.({ name, input: null, documentIds }) - } - addPlaceholder="Add correspondent to selection" - datalistOptions={correspondentOptions} - showCount - className="bulk-correspondents" - /> - - ); - }; - - const isBulkSelection = selectedCount > 1; - const headerLeading = [ ( , - ); - } - if (singleDoc && singleDownloadHref) { headerActions.push( { - event.stopPropagation(); - onRegenerateThumbnails(singleDoc.id); - }} - aria-label="Re-run analysis" - title="Re-run analysis" - > - - , - ); - } - return ( <> - + {singleDoc && ( + + )} ); }; diff --git a/frontend/src/detail/PreviewZoomOverlay.jsx b/frontend/src/detail/PreviewZoomOverlay.jsx index e48c0d6..384ff61 100644 --- a/frontend/src/detail/PreviewZoomOverlay.jsx +++ b/frontend/src/detail/PreviewZoomOverlay.jsx @@ -1,7 +1,6 @@ import React, { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { ArrowLeftIcon, ArrowRightIcon } from '../ui/icons'; -import usePointerTap from '../ui/usePointerTap'; const noop = () => {}; @@ -18,8 +17,6 @@ const ensureDocumentRoot = () => { return document.body; }; -const CLICK_DELAY_MS = 240; - const PreviewZoomOverlay = ({ open = false, display = null, @@ -102,6 +99,7 @@ const PreviewZoomOverlay = ({ } }, [open]); + useEffect(() => { if (!open || !isNativeScale) { return; @@ -151,6 +149,19 @@ const PreviewZoomOverlay = ({ const activeDisplay = open && display?.url ? display : displaySnapshot; + useEffect(() => { + if (!activeDisplay?.url) { + return; + } + + const scrollEl = scrollRef.current; + if (scrollEl) { + scrollEl.scrollLeft = 0; + scrollEl.scrollTop = 0; + } + focusRef.current = null; + }, [activeDisplay?.url]); + useEffect(() => { if (!renderBackdrop || !activeDisplay?.url) { return undefined; @@ -233,19 +244,9 @@ const PreviewZoomOverlay = ({ }); }; - const pointerTapHandler = usePointerTap({ - delay: CLICK_DELAY_MS, - onSingle: () => { - onClose(); - }, - onDouble: ({ clientX, clientY }) => { - toggleZoomAtPoint(clientX, clientY); - }, - }); - - const handleImagePointerDown = (event) => { + const handleImageClick = (event) => { event.stopPropagation(); - pointerTapHandler(event); + toggleZoomAtPoint(event.clientX ?? 0, event.clientY ?? 0); }; if (!renderBackdrop || !activeDisplay?.url || !portalTarget) { @@ -301,13 +302,13 @@ const PreviewZoomOverlay = ({ >
event.stopPropagation()} onKeyDown={handleKeyDown} >
event.stopPropagation()} >
diff --git a/frontend/src/detail/useDetailWorkspace.js b/frontend/src/detail/useDetailWorkspace.js new file mode 100644 index 0000000..4c74455 --- /dev/null +++ b/frontend/src/detail/useDetailWorkspace.js @@ -0,0 +1,300 @@ +import { useCallback, useEffect, useMemo } from 'react'; +import { resolveDocumentAssetUrl } from '../asset_manager'; +import { useDetailPanel } from '../app/useDetailPanel'; +import { + DEFAULT_FOLDER_NAME, + getRowId, + isDocumentRowKey, +} from '../app/appLayoutUtils'; + +const useDetailWorkspace = ({ + documents, + searchResults, + focusedDocumentId, + selectionOrder, + selectedDocumentIds, + documentLookup, + folderNodes, + ensureFolderData, + detailPanelControlRef, + detailFolderFetchRef, + previewEntries, + previewDocumentId, + activePreviewId, + openDocumentPreview, + promoteSelectionOrder, + handleDocumentTitleUpdate, + handleDocumentIssuedUpdate, + handleDocumentTagAdd, + handleTagRemove, + ensureAssetUrl, + getDocumentAsset, + ensurePreviewData, + correspondents, + handleCorrespondentAdd, + handleCorrespondentRemove, + resolveApiPath, + selectFolder, + tags, + tagLookupById, +}) => { + const selectedDocument = useMemo(() => { + if (!focusedDocumentId) { + return null; + } + const pool = searchResults ?? documents; + return pool.find((doc) => doc.id === focusedDocumentId) || null; + }, [focusedDocumentId, searchResults, documents]); + + const orderedSelectedDocuments = useMemo(() => { + const ordered = []; + const seen = new Set(); + + const pushDoc = (doc) => { + if (doc?.id && !seen.has(doc.id)) { + ordered.push(doc); + seen.add(doc.id); + } + }; + + selectionOrder.forEach((key) => { + if (!isDocumentRowKey(key)) { + return; + } + const docId = getRowId(key); + const doc = documentLookup.get(docId) || null; + pushDoc(doc); + }); + + selectedDocumentIds.forEach((docId) => { + if (seen.has(docId)) { + return; + } + const doc = documentLookup.get(docId) || null; + pushDoc(doc); + }); + + return ordered; + }, [selectionOrder, documentLookup, selectedDocumentIds]); + + const { + detailPanelOpen, + detailPanelDocument, + openDetailPanel, + closeDetailPanel, + } = useDetailPanel({ + documentLookup, + orderedSelectedDocuments, + }); + + useEffect(() => { + detailPanelControlRef.current = { + open: openDetailPanel, + close: closeDetailPanel, + }; + }, [detailPanelControlRef, openDetailPanel, closeDetailPanel]); + + useEffect(() => { + if (!orderedSelectedDocuments.length) { + return; + } + + const visited = new Set(); + + orderedSelectedDocuments.forEach((doc) => { + const folderId = doc?.folder_id; + if (!folderId) { + return; + } + + let currentId = folderId; + let guard = 0; + + while (currentId && currentId !== 'root' && guard < 32) { + guard += 1; + if (visited.has(currentId)) { + break; + } + visited.add(currentId); + + const node = folderNodes.get(currentId); + if (!node) { + if (!detailFolderFetchRef.current.has(currentId)) { + detailFolderFetchRef.current.add(currentId); + ensureFolderData(currentId, { force: false, includeDocuments: false }) + .catch((error) => { + console.warn('Failed to preload folder metadata for detail path', currentId, error); + }) + .finally(() => { + detailFolderFetchRef.current.delete(currentId); + }); + } + break; + } + + const parentId = node.parentId ?? 'root'; + if (!parentId || parentId === 'root') { + break; + } + currentId = parentId; + } + }); + }, [orderedSelectedDocuments, folderNodes, ensureFolderData, detailFolderFetchRef]); + + const resolveFolderPath = useCallback( + (folderId) => { + if (!folderId || folderId === 'root') { + return []; + } + + const segments = []; + const visited = new Set(); + let currentId = folderId; + let guard = 0; + + while (currentId && guard < 32 && !visited.has(currentId)) { + guard += 1; + visited.add(currentId); + + if (currentId === 'root') { + break; + } + + const node = folderNodes.get(currentId); + if (!node) { + segments.push({ id: currentId, name: '…' }); + break; + } + + segments.push({ id: node.id, name: node.name || 'Folder' }); + + const parentId = node.parentId ?? 'root'; + if (!parentId || parentId === 'root') { + segments.push({ id: 'root', name: DEFAULT_FOLDER_NAME }); + break; + } + + currentId = parentId; + } + + if (!segments.some((segment) => segment.id === 'root')) { + segments.push({ id: 'root', name: DEFAULT_FOLDER_NAME }); + } + + return segments.reverse(); + }, + [folderNodes], + ); + + const selectedPreviewEntry = useMemo(() => { + if (!selectedDocument) { + return null; + } + return previewEntries.get(selectedDocument.id) || null; + }, [selectedDocument, previewEntries]); + + const previewWorkspaceEntry = useMemo(() => { + if (!previewDocumentId) { + return null; + } + return previewEntries.get(previewDocumentId) || null; + }, [previewDocumentId, previewEntries]); + + const previewWorkspaceDocument = useMemo(() => { + if (!previewDocumentId) { + return null; + } + const pool = searchResults ?? documents; + return pool.find((doc) => doc.id === previewDocumentId) || null; + }, [previewDocumentId, searchResults, documents]); + + const previewActive = Boolean(previewDocumentId && previewWorkspaceDocument); + + const resolveThumbnailUrlForDoc = useCallback( + (doc) => + resolveDocumentAssetUrl(doc, 'thumbnail', { + ensureAssetUrl, + getAsset: getDocumentAsset, + }), + [ensureAssetUrl, getDocumentAsset], + ); + + const inspectDocument = useCallback( + (documentId) => { + if (!documentId) { + return; + } + openDetailPanel({ documentIds: [documentId] }); + }, + [openDetailPanel], + ); + + const handleDetailPanelClose = useCallback(() => { + closeDetailPanel(); + }, [closeDetailPanel]); + + const detailPanelProps = useMemo( + () => ({ + document: detailPanelDocument, + tags, + tagLookupById, + onTagAdd: handleDocumentTagAdd, + onTagRemove: handleTagRemove, + previewEntry: selectedPreviewEntry, + onOpenPreview: openDocumentPreview, + onPromoteSelection: promoteSelectionOrder, + activePreviewId, + onUpdateTitle: handleDocumentTitleUpdate, + onUpdateIssued: handleDocumentIssuedUpdate, + ensureAssetUrl, + getDocumentAsset, + ensurePreviewData, + correspondents, + onCorrespondentAdd: handleCorrespondentAdd, + onCorrespondentRemove: handleCorrespondentRemove, + resolveApiPath, + onFolderNavigate: selectFolder, + onClose: handleDetailPanelClose, + resolveFolderPath, + }), + [ + activePreviewId, + correspondents, + detailPanelDocument, + ensureAssetUrl, + ensurePreviewData, + getDocumentAsset, + handleCorrespondentAdd, + handleCorrespondentRemove, + handleDetailPanelClose, + handleDocumentTagAdd, + handleDocumentIssuedUpdate, + handleDocumentTitleUpdate, + handleTagRemove, + openDocumentPreview, + promoteSelectionOrder, + resolveApiPath, + resolveFolderPath, + selectFolder, + selectedPreviewEntry, + tags, + tagLookupById, + ], + ); + + return { + detailPanelProps, + detailPanelOpen, + openDetailPanel, + closeDetailPanel, + handleDetailPanelClose, + inspectDocument, + previewActive, + previewWorkspaceDocument, + previewWorkspaceEntry, + resolveThumbnailUrlForDoc, + resolveFolderPath, + }; +}; + +export default useDetailWorkspace; diff --git a/frontend/src/documents/DocumentInfoPanel.jsx b/frontend/src/documents/DocumentInfoPanel.jsx new file mode 100644 index 0000000..f1dc4f1 --- /dev/null +++ b/frontend/src/documents/DocumentInfoPanel.jsx @@ -0,0 +1,316 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import DocumentSummarySection from './DocumentSummarySection'; +import { buildDocumentMetadataItems, extractDocumentMetadataPayload } from './documentMetadata'; + +const DocumentInfoPanel = ({ + document, + summaryProps = {}, + metadataItems: metadataItemsProp, + metadataPayload: metadataPayloadProp, + metadataTabLabel = 'Metadata', + detailsTabLabel = 'Details', + contentConfig: contentConfigProp = null, + activeTab: controlledActiveTab, + onTabChange, + defaultTabId = 'details', + resetKey = null, + classNamePrefix = 'document-info', + hideTabNavWhenSingle = true, +}) => { + const base = classNamePrefix; + + const metadataItems = useMemo(() => { + if (Array.isArray(metadataItemsProp) && metadataItemsProp.length) { + return metadataItemsProp; + } + return buildDocumentMetadataItems(document); + }, [metadataItemsProp, document]); + + const metadataPayload = useMemo(() => { + if (metadataPayloadProp !== undefined) { + return metadataPayloadProp; + } + return extractDocumentMetadataPayload(document); + }, [metadataPayloadProp, document]); + + const contentConfig = contentConfigProp || null; + const contentEnabled = Boolean(contentConfig && (contentConfig.enabled ?? true)); + const showContentTab = Boolean(contentConfig && ((contentConfig.forceDisplay ?? contentEnabled))); + + const [contentState, setContentState] = useState(() => { + if (!contentConfig) { + return null; + } + if (!contentEnabled || typeof contentConfig.loadContent !== 'function') { + return { status: contentEnabled ? 'idle' : 'unavailable', data: null, error: null }; + } + return { status: 'idle', data: null, error: null }; + }); + + useEffect(() => { + if (!contentConfig || !showContentTab) { + setContentState(null); + return undefined; + } + + if (!contentEnabled || typeof contentConfig.loadContent !== 'function') { + setContentState({ status: contentEnabled ? 'idle' : 'unavailable', data: null, error: null }); + return undefined; + } + + let cancelled = false; + const controller = new AbortController(); + + setContentState({ status: 'loading', data: null, error: null }); + + Promise.resolve(contentConfig.loadContent({ signal: controller.signal })) + .then((result) => { + if (cancelled) { + return; + } + if (result && result.length) { + setContentState({ status: 'loaded', data: result, error: null }); + } else { + setContentState({ status: 'empty', data: '', error: null }); + } + }) + .catch((error) => { + if (cancelled || error?.name === 'AbortError') { + return; + } + setContentState({ + status: 'error', + data: null, + error, + }); + }); + + return () => { + cancelled = true; + controller.abort(); + contentConfig.onCancel?.(); + }; + }, [contentConfig, contentEnabled, showContentTab, document?.id, resetKey]); + + const visibleTabs = useMemo(() => { + const tabsList = []; + + tabsList.push({ + id: 'details', + label: detailsTabLabel, + render: () => ( +
+ {metadataItems.length ? ( +
+ {metadataItems.map(({ label, value }) => ( +
+
{label}
+
{value || '—'}
+
+ ))} +
+ ) : ( +

No details available.

+ )} +
+ ), + }); + + if (showContentTab && contentConfig) { + tabsList.push({ + id: contentConfig.id || 'content', + label: contentConfig.label || 'Content', + render: () => { + const messageClass = `${base}__message`; + const errorClass = `${base}__message ${base}__message--error`; + const objectClass = `${base}__object ${base}__object--ocr-text`; + + if (!contentEnabled || !contentConfig.loadContent) { + return ( +
+ {contentConfig.unavailableMessage || 'Content not available.'} +
+ ); + } + + if (!contentState) { + return ( +
+ {contentConfig.emptyMessage || 'No content available.'} +
+ ); + } + + switch (contentState.status) { + case 'loading': + return ( +
+ {contentConfig.loadingMessage || 'Loading content…'} +
+ ); + case 'error': { + const errorMessage = + contentConfig.errorMessage + || (contentState.error instanceof Error ? contentState.error.message : null) + || 'Failed to load content.'; + return
{errorMessage}
; + } + case 'empty': + return ( +
+ {contentConfig.emptyMessage || 'No content available.'} +
+ ); + case 'loaded': + return ( +
{contentState.data}
+ ); + case 'unavailable': + return ( +
+ {contentConfig.unavailableMessage || 'Content not available.'} +
+ ); + default: + return ( +
+ {contentConfig.emptyMessage || 'No content available.'} +
+ ); + } + }, + }); + } + + if (metadataPayload) { + tabsList.push({ + id: 'metadata', + label: metadataTabLabel, + render: () => ( +
+
+              {JSON.stringify(metadataPayload, null, 2)}
+            
+
+ ), + }); + } + + return tabsList; + }, [ + base, + detailsTabLabel, + metadataItems, + contentConfig, + contentEnabled, + contentState, + metadataPayload, + metadataTabLabel, + showContentTab, + ]); + + const fallbackTabId = useMemo(() => { + if (!visibleTabs.length) { + return null; + } + if (defaultTabId && visibleTabs.some((tab) => tab.id === defaultTabId)) { + return defaultTabId; + } + return visibleTabs[0].id; + }, [visibleTabs, defaultTabId]); + + const renderTabContent = (tab, context = {}) => { + if (!tab) { + return null; + } + if (typeof tab.render === 'function') { + return tab.render(context); + } + if (tab.component) { + const TabComponent = tab.component; + return ; + } + return React.isValidElement(tab.render) ? tab.render : null; + }; + + const isControlled = controlledActiveTab !== undefined && controlledActiveTab !== null; + const [uncontrolledTab, setUncontrolledTab] = useState( + isControlled ? controlledActiveTab : fallbackTabId, + ); + + useEffect(() => { + if (!isControlled) { + setUncontrolledTab(fallbackTabId); + } + }, [fallbackTabId, resetKey, isControlled]); + + useEffect(() => { + if (isControlled && controlledActiveTab && !visibleTabs.some((tab) => tab.id === controlledActiveTab)) { + const nextTab = fallbackTabId; + if (nextTab && nextTab !== controlledActiveTab) { + onTabChange?.(nextTab); + } + } + }, [isControlled, controlledActiveTab, visibleTabs, fallbackTabId, onTabChange]); + + const activeTabId = isControlled ? controlledActiveTab : uncontrolledTab; + + const handleTabSelect = (tabId) => { + if (!visibleTabs.some((tab) => tab.id === tabId)) { + return; + } + if (!isControlled) { + setUncontrolledTab(tabId); + } + if (tabId !== activeTabId) { + onTabChange?.(tabId); + } + }; + + const singleTab = visibleTabs.length === 1 ? visibleTabs[0] : null; + const shouldHideNav = hideTabNavWhenSingle && singleTab; + + return ( + <> + + {shouldHideNav ? ( +
+
+ {renderTabContent(singleTab, { document })} +
+
+ ) : ( +
+
+ {visibleTabs.map((tab) => ( + + ))} +
+
+ {visibleTabs.map((tab) => ( + tab.id === activeTabId ? ( +
+ {renderTabContent(tab, { document })} +
+ ) : null + ))} +
+
+ )} + + ); +}; + +export default DocumentInfoPanel; diff --git a/frontend/src/documents/DocumentsGrid.jsx b/frontend/src/documents/DocumentsGrid.jsx index 94fdd56..4d84c95 100644 --- a/frontend/src/documents/DocumentsGrid.jsx +++ b/frontend/src/documents/DocumentsGrid.jsx @@ -1,10 +1,11 @@ import React from 'react'; -import { FolderIcon } from '../ui/icons'; +import { FolderIcon, CheckIcon, CloseIcon } from '../ui/icons'; import DocumentThumbnailImage from './DocumentThumbnailImage'; import CorrespondentLinks from './CorrespondentLinks'; import { getTagColorStyle } from '../utils/colors'; import { resolveCorrespondents } from './correspondents'; import { writeTagTransferData } from './tagTransfer'; +import useInlineRename from './useInlineRename'; const DocumentsGrid = ({ entries, @@ -20,7 +21,7 @@ const DocumentsGrid = ({ onFolderDragStart, onFolderDragEnd, onDocumentClick, - onDocumentOpen, + onDocumentActivate, onDocumentDragStart, onDocumentDragEnd, onDocumentTagDragOver, @@ -35,7 +36,42 @@ const DocumentsGrid = ({ onCorrespondentClick, activeCorrespondentIdSet, onClearSelection, -}) => ( + onDocumentRename, + onFolderRename, +}) => { + const { + editingId: editingDocumentId, + draftValue: documentDraft, + setDraftValue: setDocumentDraft, + beginEditing: beginDocumentEditing, + cancelEditing: cancelDocumentEditing, + submitEditing: submitDocumentEditing, + savingId: savingDocumentId, + attachInputRef: attachDocumentInputRef, + } = useInlineRename(onDocumentRename, { + getCurrentValue: (doc) => doc?.title ?? '', + getEntityId: (doc) => doc?.id ?? null, + }); + + const { + editingId: editingFolderId, + draftValue: folderDraft, + setDraftValue: setFolderDraft, + beginEditing: beginFolderEditing, + cancelEditing: cancelFolderEditing, + submitEditing: submitFolderEditing, + savingId: savingFolderId, + attachInputRef: attachFolderInputRef, + } = useInlineRename(onFolderRename, { + getCurrentValue: (folder) => folder?.name ?? '', + getEntityId: (folder) => folder?.id ?? null, + }); + + const documentSelectionCount = selectedDocumentIdsSet?.size ?? 0; + const folderSelectionCount = selectedFolderIdsSet?.size ?? 0; + const totalSelectionCount = documentSelectionCount + folderSelectionCount; + + return (
0 && trimmedFolderDraft !== folder.name; + const allowInlineFolderEdit = canRenameFolder && isSelectedFolder && totalSelectionCount === 1; return (
-
- {folder.name} -
+ {isFolderEditing ? ( +
+ setFolderDraft(event.target.value)} + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + submitFolderEditing(folder); + } else if (event.key === 'Escape') { + event.preventDefault(); + cancelFolderEditing(event); + } + }} + onBlur={(event) => { + const nextFocus = event.relatedTarget; + if (!nextFocus || !event.currentTarget.parentElement?.contains(nextFocus)) { + cancelFolderEditing(); + } + }} + /> + + +
+ ) : ( +
+ { + if (!allowInlineFolderEdit) { + return; + } + event.preventDefault(); + event.stopPropagation(); + beginFolderEditing(folder); + }} + onKeyDown={(event) => { + if (!allowInlineFolderEdit) { + return; + } + if (event.key === 'Enter') { + event.preventDefault(); + event.stopPropagation(); + beginFolderEditing(folder); + } + }} + > + {folder.name} + +
+ )}
); @@ -111,6 +229,13 @@ const DocumentsGrid = ({ const cardClasses = ['document-card', 'document']; if (isSelected) cardClasses.push('selected'); if (isDraggingDoc) cardClasses.push('is-dragging'); + const isEditingDoc = editingDocumentId === doc.id; + const documentDraftValue = isEditingDoc ? documentDraft : doc.title; + const trimmedDocumentDraft = isEditingDoc ? documentDraft.trim() : ''; + const isDocumentSaving = savingDocumentId === doc.id; + const canSubmitDocument = + isEditingDoc && trimmedDocumentDraft.length > 0 && trimmedDocumentDraft !== doc.title; + const allowInlineDocumentEdit = onDocumentRename && isSelected && totalSelectionCount === 1; return (
onDocumentClick?.(doc, event)} - onDoubleClick={() => onDocumentOpen?.(doc.id)} + onDoubleClick={(event) => onDocumentActivate?.(doc, event)} draggable onDragStart={(event) => onDocumentDragStart?.(event, doc)} onDragEnd={(event) => onDocumentDragEnd?.(event)} @@ -149,7 +274,82 @@ const DocumentsGrid = ({ /> ) : null} - {doc.title} + {isEditingDoc ? ( +
+ setDocumentDraft(event.target.value)} + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + submitDocumentEditing(doc); + } else if (event.key === 'Escape') { + event.preventDefault(); + cancelDocumentEditing(event); + } + }} + onBlur={(event) => { + const nextFocus = event.relatedTarget; + if (!nextFocus || !event.currentTarget.parentElement?.contains(nextFocus)) { + cancelDocumentEditing(); + } + }} + /> + + +
+ ) : ( +
+ { + if (!allowInlineDocumentEdit) { + return; + } + event.preventDefault(); + event.stopPropagation(); + beginDocumentEditing(doc); + }} + onKeyDown={(event) => { + if (!allowInlineDocumentEdit) { + return; + } + if (event.key === 'Enter') { + event.preventDefault(); + event.stopPropagation(); + beginDocumentEditing(doc); + } + }} + > + {doc.title} + +
+ )}
{visibleTags.length > 0 && (
@@ -204,6 +404,7 @@ const DocumentsGrid = ({ ); })}
-); + ); +}; export default DocumentsGrid; diff --git a/frontend/src/documents/DocumentsList.jsx b/frontend/src/documents/DocumentsList.jsx index 3e3c49b..9811e99 100644 --- a/frontend/src/documents/DocumentsList.jsx +++ b/frontend/src/documents/DocumentsList.jsx @@ -1,15 +1,22 @@ import React from 'react'; -import { - FolderIcon, - EditIcon, - DownloadIcon, - TrashIcon, -} from '../ui/icons'; +import { FolderIcon, CheckIcon, CloseIcon } from '../ui/icons'; import { getTagColorStyle } from '../utils/colors'; import DocumentThumbnailImage from './DocumentThumbnailImage'; import CorrespondentLinks from './CorrespondentLinks'; import { resolveCorrespondents } from './correspondents'; import { writeTagTransferData } from './tagTransfer'; +import useInlineRename from './useInlineRename'; + +const formatDate = (value) => { + if (!value) { + return "—"; + } + const timestamp = Date.parse(value); + if (Number.isNaN(timestamp)) { + return "—"; + } + return new Date(timestamp).toLocaleDateString(); +}; const DocumentsList = ({ entries, @@ -20,7 +27,6 @@ const DocumentsList = ({ draggedFolderId, ensureAssetUrl, getDocumentAsset, - getDownloadHref, onFolderClick, onFolderSelect, onFolderDragOver, @@ -29,29 +35,66 @@ const DocumentsList = ({ onFolderDragStart, onFolderDragEnd, onFolderRename, - onFolderDelete, onDocumentClick, - onDocumentOpen, + onDocumentActivate, onDocumentDragStart, onDocumentDragEnd, onDocumentTagDragOver, onDocumentTagDragLeave, onDocumentTagDrop, onDocumentRename, - onDocumentDelete, tagLookupById, onTagClick, onCorrespondentClick, activeCorrespondentIdSet, scrollRef, -}) => ( + onClearSelection, +}) => { + const { + editingId: editingDocumentId, + draftValue: documentDraft, + setDraftValue: setDocumentDraft, + beginEditing: beginDocumentEditing, + cancelEditing: cancelDocumentEditing, + submitEditing: submitDocumentEditing, + savingId: savingDocumentId, + attachInputRef: attachDocumentInputRef, + } = useInlineRename(onDocumentRename, { + getCurrentValue: (doc) => doc?.title ?? '', + getEntityId: (doc) => doc?.id ?? null, + }); + + const { + editingId: editingFolderId, + draftValue: folderDraft, + setDraftValue: setFolderDraft, + beginEditing: beginFolderEditing, + cancelEditing: cancelFolderEditing, + submitEditing: submitFolderEditing, + savingId: savingFolderId, + attachInputRef: attachFolderInputRef, + } = useInlineRename(onFolderRename, { + getCurrentValue: (folder) => folder?.name ?? '', + getEntityId: (folder) => folder?.id ?? null, + }); + + + const documentSelectionCount = selectedDocumentIdsSet?.size ?? 0; + const folderSelectionCount = selectedFolderIdsSet?.size ?? 0; + const totalSelectionCount = documentSelectionCount + folderSelectionCount; + + return ( - + { + onClearSelection?.(); + }} + > - + @@ -65,6 +108,14 @@ const DocumentsList = ({ const isDraggingFolder = draggedFolderId === folder.id; const isSelectedFolder = selectedFolderIdsSet?.has(folder.id); const rowKey = `folder:${folder.id}`; + const canRenameFolder = folder.id !== 'root' && typeof onFolderRename === 'function'; + const isFolderEditing = editingFolderId === folder.id; + const folderDraftValue = isFolderEditing ? folderDraft : folder.name; + const trimmedFolderDraft = isFolderEditing ? folderDraft.trim() : ''; + const isFolderSaving = savingFolderId === folder.id; + const canSubmitFolder = + isFolderEditing && trimmedFolderDraft.length > 0 && trimmedFolderDraft !== folder.name; + const allowInlineFolderEdit = canRenameFolder && isSelectedFolder && totalSelectionCount === 1; return ( - - + + ); } @@ -155,8 +248,17 @@ const DocumentsList = ({ const rowClasses = ['document']; if (isSelected) rowClasses.push('selected'); if (isDraggingDoc) rowClasses.push('is-dragging'); - const downloadHref = getDownloadHref?.(doc) || null; const correspondents = resolveCorrespondents(doc); + const isEditingDoc = editingDocumentId === doc.id; + const documentDraftValue = isEditingDoc ? documentDraft : doc.title; + const trimmedDocumentDraft = isEditingDoc ? documentDraft.trim() : ''; + const isDocumentSaving = savingDocumentId === doc.id; + const canSubmitDocument = + isEditingDoc && trimmedDocumentDraft.length > 0 && trimmedDocumentDraft !== doc.title; + const allowInlineDocumentEdit = + onDocumentRename && isSelected && totalSelectionCount === 1; + const issuedLabel = formatDate(doc.issued_at); + const addedLabel = formatDate(doc.created_at || doc.uploaded_at); return ( onDocumentClick?.(doc, event)} - onDoubleClick={() => onDocumentOpen?.(doc.id)} + onDoubleClick={(event) => onDocumentActivate?.(doc, event)} draggable onDragStart={(event) => onDocumentDragStart?.(event, doc)} onDragEnd={(event) => onDocumentDragEnd?.(event)} @@ -194,7 +296,84 @@ const DocumentsList = ({ /> ) : null} - {doc.title} + + {isEditingDoc ? ( + + setDocumentDraft(event.target.value)} + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + submitDocumentEditing(doc); + } else if (event.key === 'Escape') { + event.preventDefault(); + cancelDocumentEditing(event); + } + }} + onBlur={(event) => { + const nextFocus = event.relatedTarget; + if (!nextFocus || !event.currentTarget.parentElement?.contains(nextFocus)) { + cancelDocumentEditing(); + } + }} + /> + + + + ) : ( + { + if (!allowInlineDocumentEdit) { + return; + } + event.preventDefault(); + event.stopPropagation(); + beginDocumentEditing(doc); + }} + onKeyDown={(event) => { + if (!allowInlineDocumentEdit) { + return; + } + if (event.key === 'Enter') { + event.preventDefault(); + event.stopPropagation(); + beginDocumentEditing(doc); + } + }} + > + {doc.title} + + )} + {(doc.tags || []).length > 0 && ( @@ -244,79 +423,14 @@ const DocumentsList = ({ )} - - + + ); })}
Name IssuedActionsAdded
- {folder.name} -
-
-
- {folder.id !== 'root' && onFolderRename && ( - - )} - + + + {isFolderEditing ? ( + + setFolderDraft(event.target.value)} + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + submitFolderEditing(folder); + } else if (event.key === 'Escape') { + event.preventDefault(); + cancelFolderEditing(event); + } + }} + onBlur={(event) => { + const nextFocus = event.relatedTarget; + if (!nextFocus || !event.currentTarget.parentElement?.contains(nextFocus)) { + cancelFolderEditing(); + } + }} + /> + + + + ) : ( + { + if (!allowInlineFolderEdit) { + return; + } + event.preventDefault(); + event.stopPropagation(); + beginFolderEditing(folder); + }} + onKeyDown={(event) => { + if (!allowInlineFolderEdit) { + return; + } + if (event.key === 'Enter') { + event.preventDefault(); + event.stopPropagation(); + beginFolderEditing(folder); + } + }} + > + {folder.name} + + )} + +
- {(() => { - const issuedAt = doc.issued_at || null; - if (!issuedAt) { - return '—'; - } - const timestamp = Date.parse(issuedAt); - if (Number.isNaN(timestamp)) { - return '—'; - } - return new Date(timestamp).toLocaleDateString(); - })()} - -
- {onDocumentRename && ( - - )} - {downloadHref ? ( - event.stopPropagation()} - onAuxClick={(event) => event.stopPropagation()} - onContextMenu={(event) => event.stopPropagation()} - > - - - ) : ( - No download - )} - -
-
{issuedLabel}{addedLabel}
-); + ); +}; export default DocumentsList; diff --git a/frontend/src/documents/DocumentsPanel.jsx b/frontend/src/documents/DocumentsPanel.jsx index e56c280..740b423 100644 --- a/frontend/src/documents/DocumentsPanel.jsx +++ b/frontend/src/documents/DocumentsPanel.jsx @@ -6,13 +6,22 @@ import { RefreshIcon, MinusVerticalIcon, InfoIcon, + FoldersIcon, + FoldersOffIcon, + SortAscendingLettersIcon, + SortDescendingLettersIcon, } from '../ui/icons'; +import QuickAddMenu from '../ui/QuickAddMenu'; import BreadcrumbTrail from '../ui/BreadcrumbTrail'; import createWorkspaceSurfaceConfig from './workspaceHeader'; import DetailPanel from '../detail/DetailPanel'; import DocumentsGrid from './DocumentsGrid'; import DocumentsList from './DocumentsList'; import { isTagTransferEvent } from './tagTransfer'; +import SelectionFloatingActions from './SelectionFloatingActions'; +import PreviewZoomOverlay from '../detail/PreviewZoomOverlay'; +import { useAssetNavigator } from '../hooks/useAssetNavigator'; +import { isPointerModifierEvent, isPrimaryPointerEvent } from './useEntryPointer'; const DEFAULT_GRID_ICON_SIZE = 144; @@ -21,6 +30,19 @@ const EntryType = { document: 'document', }; +const SORT_OPTIONS = [ + { value: 'title', label: 'Title' }, + { value: 'issued_at', label: 'Issued date' }, + { value: 'created_at', label: 'Added' }, + { value: 'updated_at', label: 'Updated date' }, +]; + +const SORT_LABEL_LOOKUP = SORT_OPTIONS.reduce((accumulator, option) => { + const next = accumulator; + next[option.value] = option.label; + return next; +}, {}); + const DocumentsPanel = ({ currentFolderName, breadcrumbs, @@ -36,27 +58,22 @@ const DocumentsPanel = ({ onFolderDragStart, onFolderDragEnd, draggedFolderId, - onFolderDelete, onFolderRename, selectedFolderIds = [], - onDocumentOpen, selectedDocumentIds = [], focusedRowKey, draggingDocumentIds = [], onDocumentDragStart, onDocumentDragEnd, - onDocumentDelete, onDocumentRename, - onRowSelection = null, - onOpenDetailPanel = null, + onEntryPointer = null, + onEntrySelection = null, + onInspectDocument = null, tagLookupById, activeCorrespondentIds = [], - onDocumentListFocus, - onDocumentListKeyDown, onFocusedRowChange, ensureAssetUrl = null, getDocumentAsset = () => null, - getDownloadHref, onTagClick, onCorrespondentClick, isSearchLoading = false, @@ -64,6 +81,7 @@ const DocumentsPanel = ({ viewMode = 'list', onViewModeChange, onClearSelection, + selectedEntries = [], showHeader = true, }) => { const showingSearchResults = searchResults !== null; @@ -120,6 +138,239 @@ const DocumentsPanel = ({ }, []); const isGridView = viewMode === 'grid'; const isDeskView = viewMode === 'desk'; + + const [previewDocId, setPreviewDocId] = useState(null); + + const previewDoc = useMemo(() => { + if (!previewDocId) { + return null; + } + return rows.find((doc) => doc?.id === previewDocId) || null; + }, [previewDocId, rows]); + + useEffect(() => { + if (previewDocId && !previewDoc) { + setPreviewDocId(null); + } + }, [previewDocId, previewDoc]); + + const previewNavigator = useAssetNavigator({ + document: previewDoc, + assetType: 'preview', + ensureAssetUrl, + getAsset: getDocumentAsset, + prefetch: 3, + }); + + const { + currentUrl: previewUrl, + canGoPrev: previewCanGoPrev, + canGoNext: previewCanGoNext, + goPrev: previewGoPrev, + goNext: previewGoNext, + } = previewNavigator; + + const previewDisplay = useMemo(() => { + if (!previewDoc || !previewUrl) { + return null; + } + return { + url: previewUrl, + alt: previewDoc.title, + canGoPrev: Boolean(previewCanGoPrev), + canGoNext: Boolean(previewCanGoNext), + goPrev: previewGoPrev, + goNext: previewGoNext, + }; + }, [previewDoc, previewUrl, previewCanGoPrev, previewCanGoNext, previewGoPrev, previewGoNext]); + + const closePreviewOverlay = useCallback(() => { + setPreviewDocId(null); + }, []); + + const handleDocumentPreviewZoom = useCallback( + (doc) => { + if (!doc || !doc.id) { + return; + } + const previewAsset = typeof getDocumentAsset === 'function' ? getDocumentAsset(doc, 'preview') : null; + if (!previewAsset) { + return; + } + setPreviewDocId(doc.id); + }, + [getDocumentAsset], + ); + + const handleDocumentActivate = useCallback( + (doc, event) => { + if (!doc) { + return; + } + if (event) { + if (typeof event.preventDefault === 'function') { + event.preventDefault(); + } + if (typeof event.stopPropagation === 'function') { + event.stopPropagation(); + } + } + if (event?.altKey) { + handleDocumentPreviewZoom(doc); + return; + } + onInspectDocument?.(doc.id, event); + }, + [handleDocumentPreviewZoom, onInspectDocument], + ); + + const selectedRowKeySet = useMemo(() => new Set(selectedEntries || []), [selectedEntries]); + const navigableRows = useMemo( + () => entries.map((entry) => ({ key: entry.key, type: entry.type, id: entry.id })), + [entries], + ); + const navigableRowKeys = useMemo(() => navigableRows.map((row) => row.key), [navigableRows]); + + const getEntryByKey = useCallback( + (rowKey) => entries.find((entry) => entry.key === rowKey) || null, + [entries], + ); + + const handlePanelFocus = useCallback(() => { + let resolvedKey = null; + + if (focusedRowKey && navigableRowKeys.includes(focusedRowKey)) { + resolvedKey = focusedRowKey; + } + + if (!resolvedKey) { + for (let index = selectedEntries.length - 1; index >= 0; index -= 1) { + const candidate = selectedEntries[index]; + if (navigableRowKeys.includes(candidate)) { + resolvedKey = candidate; + break; + } + } + } + + if (!resolvedKey && navigableRows.length) { + resolvedKey = navigableRows[0].key; + } + + if (!resolvedKey) { + return; + } + + onFocusedRowChange?.(resolvedKey); + + if (!selectedRowKeySet.has(resolvedKey) && typeof onEntrySelection === 'function') { + onEntrySelection(resolvedKey, { + shiftKey: false, + preventDefault: () => {}, + }); + } + }, [ + focusedRowKey, + navigableRowKeys, + navigableRows, + onEntrySelection, + onFocusedRowChange, + selectedEntries, + selectedRowKeySet, + ]); + + const handlePanelKeyDown = useCallback( + (event) => { + const { key, shiftKey } = event; + const triggers = ['ArrowUp', 'ArrowDown', 'Home', 'End', 'Enter', ' ', 'Space', 'Spacebar']; + if (!triggers.includes(key)) { + return; + } + + if (!navigableRows.length) { + return; + } + + event.preventDefault(); + + let activeKey = + focusedRowKey && navigableRowKeys.includes(focusedRowKey) + ? focusedRowKey + : null; + + if (!activeKey) { + if (selectedEntries.length) { + for (let index = selectedEntries.length - 1; index >= 0; index -= 1) { + const candidate = selectedEntries[index]; + if (navigableRowKeys.includes(candidate)) { + activeKey = candidate; + break; + } + } + } + + if (!activeKey) { + activeKey = key === 'ArrowUp' ? navigableRowKeys[navigableRowKeys.length - 1] : navigableRowKeys[0]; + } + } + + const currentIndex = navigableRowKeys.indexOf(activeKey); + const activeRow = currentIndex === -1 ? null : navigableRows[currentIndex]; + + if (key === 'Enter' || key === ' ' || key === 'Space' || key === 'Spacebar') { + if (activeRow) { + onEntrySelection?.(activeRow.key, event); + if (activeRow.type === EntryType.folder) { + onFolderSelect?.(activeRow.id); + } else { + const entry = getEntryByKey(activeRow.key); + if (entry?.document) { + handleDocumentPreviewZoom(entry.document); + } + } + } + return; + } + + let nextIndex = currentIndex; + if (key === 'ArrowDown') { + nextIndex = currentIndex === -1 ? 0 : Math.min(currentIndex + 1, navigableRows.length - 1); + } else if (key === 'ArrowUp') { + nextIndex = currentIndex === -1 ? navigableRows.length - 1 : Math.max(currentIndex - 1, 0); + } else if (key === 'Home') { + nextIndex = 0; + } else if (key === 'End') { + nextIndex = navigableRows.length - 1; + } + + if (nextIndex === -1 || nextIndex >= navigableRows.length) { + return; + } + + const targetRow = navigableRows[nextIndex]; + if (!targetRow) { + return; + } + + onFocusedRowChange?.(targetRow.key); + onEntrySelection?.(targetRow.key, { + shiftKey, + preventDefault: () => {}, + }); + }, + [ + focusedRowKey, + getEntryByKey, + navigableRowKeys, + navigableRows, + onEntrySelection, + onFocusedRowChange, + onFolderSelect, + selectedEntries, + handleDocumentPreviewZoom, + ], + ); + const isListView = viewMode === 'list'; const gridIconSize = DEFAULT_GRID_ICON_SIZE; const handleSetViewMode = useCallback( @@ -242,56 +493,20 @@ const DocumentsPanel = ({ [isTagDragEvent, onDocumentTagDrop], ); - const handleEntryClick = useCallback( - (entry, event) => { - if (!entry || !entry.id) { - return; - } - if (entry.type === EntryType.document && suppressDocumentClickRef.current) { - return; - } - - const rowKey = entry.type === EntryType.document ? `document:${entry.id}` : `folder:${entry.id}`; - - if (rowKey && typeof onRowSelection === 'function') { - onRowSelection(rowKey, event); - } - - if (entry.type === EntryType.document) { - const hasModifier = Boolean( - event && (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey), - ); - if (!hasModifier && typeof onOpenDetailPanel === 'function') { - onOpenDetailPanel(); - } - return; - } - - if (entry.type === EntryType.folder) { - const hasModifier = Boolean( - event && (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey), - ); - const isPrimaryClick = Boolean(event && event.type === 'click' && event.button === 0); - if (!hasModifier && isPrimaryClick && typeof onFolderSelect === 'function') { - onFolderSelect(entry.id); - } - if (scrollRef.current) { - scrollRef.current.focus({ preventScroll: true }); - } - onFocusedRowChange?.(rowKey); - } - }, - [onRowSelection, onOpenDetailPanel, onFocusedRowChange, onFolderSelect], - ); - const handleDocumentClick = useCallback( (doc, event) => { - if (!doc) { + if (!doc || suppressDocumentClickRef.current) { return; } - handleEntryClick({ type: EntryType.document, id: doc.id, document: doc }, event); + + if (typeof onEntryPointer === 'function') { + onEntryPointer( + { type: EntryType.document, id: doc.id, key: `document:${doc.id}`, document: doc }, + event, + ); + } }, - [handleEntryClick], + [onEntryPointer], ); const handleFolderClick = useCallback( @@ -299,9 +514,24 @@ const DocumentsPanel = ({ if (!folder) { return; } - handleEntryClick({ type: EntryType.folder, id: folder.id, folder }, event); + + if (typeof onEntryPointer === 'function') { + onEntryPointer( + { type: EntryType.folder, id: folder.id, key: `folder:${folder.id}`, folder }, + event, + ); + } + + if ( + !isPointerModifierEvent(event) + && isPrimaryPointerEvent(event) + && scrollRef.current + ) { + scrollRef.current.focus({ preventScroll: true }); + onFocusedRowChange?.(`folder:${folder.id}`); + } }, - [handleEntryClick], + [onEntryPointer, onFocusedRowChange], ); const handleDocumentDragStartLocal = useCallback( @@ -330,7 +560,6 @@ const DocumentsPanel = ({ const showDefaultEmptyState = !showingSearchResults && !isFilterActive && entries.length === 0; const showListSearchEmptyState = showingSearchResults && !hasDocumentEntries && !isGridView && !isSearchLoading; const showGridSearchEmptyState = isGridView && showingSearchResults && !hasDocumentEntries && !isSearchLoading; - const showSearchHint = showingSearchResults && rows.length > 0; const breadcrumbEntries = useMemo(() => (Array.isArray(breadcrumbs) ? breadcrumbs.filter(Boolean) : []), [breadcrumbs]); const trailEntries = useMemo(() => { if (!breadcrumbEntries.length) { @@ -347,7 +576,8 @@ const DocumentsPanel = ({ }, [breadcrumbEntries, currentFolderName, onFolderSelect]); return ( -
+
{showHeader ? ( @@ -424,16 +654,14 @@ const DocumentsPanel = ({ tabIndex={0} onFocus={(event) => { if (event.target === scrollRef.current) { - onDocumentListFocus?.(); + handlePanelFocus(); } }} onKeyDown={(event) => { if (event.target !== scrollRef.current) { return; } - if (onDocumentListKeyDown) { - onDocumentListKeyDown(event); - } + handlePanelKeyDown(event); }} onClick={(event) => { if (event.target === event.currentTarget) { @@ -457,7 +685,7 @@ const DocumentsPanel = ({ onFolderDragStart={onFolderDragStart} onFolderDragEnd={onFolderDragEnd} onDocumentClick={handleDocumentClick} - onDocumentOpen={onDocumentOpen} + onDocumentActivate={handleDocumentActivate} onDocumentDragStart={handleDocumentDragStartLocal} onDocumentDragEnd={handleDocumentDragEndLocal} onDocumentTagDragOver={handleDocumentTagDragOver} @@ -472,6 +700,8 @@ const DocumentsPanel = ({ onCorrespondentClick={onCorrespondentClick} activeCorrespondentIdSet={activeCorrespondentIdSet} onClearSelection={onClearSelection} + onDocumentRename={onDocumentRename} + onFolderRename={onFolderRename} /> ) : !showTableRows ? null : ( )}
- {showSearchHint && ( -
- Showing {rows.length} document{rows.length === 1 ? '' : 's'} in {currentFolderName} and subfolders. -
- )}
)} + + ); }; export default DocumentsPanel; +const SortFieldQuickMenu = ({ sortField, onChange }) => { + const currentOption = useMemo( + () => SORT_OPTIONS.find((option) => option.value === sortField) || SORT_OPTIONS[0], + [sortField], + ); + + const options = useMemo( + () => SORT_OPTIONS.map((option) => ({ id: option.value, label: option.label })), + [], + ); + + const handleSelect = useCallback( + (value, option) => { + if (typeof onChange !== 'function') { + return; + } + const nextValue = option?.id || option?.original?.id || value; + if (nextValue) { + onChange(nextValue); + } + }, + [onChange], + ); + + const label = currentOption?.label || SORT_LABEL_LOOKUP[currentOption?.value] || 'Title'; + + return ( + + {label} + + )} + triggerAriaLabel={`Sort by ${label}`} + triggerTitle={`Sort by ${label}`} + placeholder="Select sort field" + menuMinWidth={200} + align="start" + positionStrategy="absolute" + /> + ); +}; + export const createDocumentsTableHeaderActions = ({ viewMode, onViewModeChange, onRefresh, onShowDeskHelp = null, + sortField = 'title', + onSortFieldChange = null, + sortDirection = 'asc', + onSortDirectionToggle = null, + isFilterActive = false, + includeDescendants = true, + onToggleIncludeDescendants = null, }) => { const isListView = viewMode === 'list'; const isGridView = viewMode === 'grid'; const isDeskView = viewMode === 'desk'; + const sortDirectionIsDesc = sortDirection === 'desc'; + const sortDirectionTitle = sortDirectionIsDesc + ? 'Sorting Z → A. Click to switch to ascending.' + : 'Sorting A → Z. Click to switch to descending.'; + const includeDescendantsToggle = isFilterActive && typeof onToggleIncludeDescendants === 'function' + ? ( + + ) + : null; + + const sortControls = typeof onSortFieldChange === 'function' + ? ( +
+ + {typeof onSortDirectionToggle === 'function' ? ( + + ) : null} +
+ ) + : null; + return ( <> + {isDeskView && typeof onShowDeskHelp === 'function' ? ( + <> + + + + ) : null} + {includeDescendantsToggle ? ( + <> + {includeDescendantsToggle} + + + ) : null} + {sortControls ? ( + <> + {sortControls} + + + ) : null}
- {isDeskView && typeof onShowDeskHelp === 'function' ? ( - - ) : null} ); }; @@ -603,9 +952,32 @@ export const createDocumentsSurface = ({ currentFolderName, breadcrumbs, searchResults, + isFilterActive, viewMode, onViewModeChange, onRefresh, + sortField, + sortDirection, + onSortFieldChange, + onSortDirectionToggle, + selectedDocumentIds, + selectedFolderIds, + onDeleteSelection, + onClearSelection, + tags, + correspondents, + documentLookup, + tagLookupById, + onBulkTagAdd, + onBulkTagRemove, + onBulkCorrespondentAdd, + onBulkCorrespondentRemove, + onBulkReanalyze, + folderOptions, + onMoveDocumentsToFolder, + searchIncludeDescendants, + onToggleSearchIncludeDescendants, + onInspectDocument, } = tableProps; const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName; @@ -613,12 +985,48 @@ export const createDocumentsSurface = ({ ? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}` : null; + + const documentSelectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0; + const folderSelectionCount = Array.isArray(selectedFolderIds) ? selectedFolderIds.length : 0; + const selectionCount = documentSelectionCount + folderSelectionCount; + const actions = createDocumentsTableHeaderActions({ viewMode, onViewModeChange, onRefresh, + sortField, + onSortFieldChange, + sortDirection, + onSortDirectionToggle, + isFilterActive, + includeDescendants: searchIncludeDescendants, + onToggleIncludeDescendants: onToggleSearchIncludeDescendants, }); + const floatingActions = selectionCount > 0 + ? ( + + ) + : null; + + const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null; const detail = detailOpen && detailProps ? : null; @@ -632,7 +1040,15 @@ export const createDocumentsSurface = ({ onNavigateParent, actions, breadcrumbs, - content: , + selectionLabel: null, + floatingActions, + content: ( + + ), detail, }); diff --git a/frontend/src/documents/SelectionAssignmentMenu.jsx b/frontend/src/documents/SelectionAssignmentMenu.jsx new file mode 100644 index 0000000..7f2742e --- /dev/null +++ b/frontend/src/documents/SelectionAssignmentMenu.jsx @@ -0,0 +1,258 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import useFloatingMenu from '../ui/useFloatingMenu'; +import { + PlusIcon, + CheckIcon, + CircleDashedCheckIcon, +} from '../ui/icons'; + +const STATE_ORDER = { + all: 0, + partial: 1, + none: 2, +}; + +const normalizeItems = (items) => + (Array.isArray(items) ? items : []) + .filter((item) => item && typeof item.label === 'string' && item.label.trim().length > 0) + .map((item) => ({ + id: item.id ?? item.label, + label: item.label.trim(), + state: item.state === 'all' ? 'all' : item.state === 'partial' ? 'partial' : 'none', + count: typeof item.count === 'number' ? item.count : null, + total: typeof item.total === 'number' ? item.total : null, + payload: item.payload ?? item, + })); + +const SelectionAssignmentMenu = ({ + label, + items = [], + placeholder = 'Search…', + emptyMessage = 'No entries', + createLabel = null, + onToggle, + onCreate, + disabled = false, + className, + triggerContent = null, + showStateIndicators = true, + showCounts = true, + onOpenMenu = null, + renderItemLabel = null, +}) => { + const anchorRef = useRef(null); + const inputRef = useRef(null); + const [query, setQuery] = useState(''); + const [pending, setPending] = useState(false); + + const { + isOpen, + toggle, + close, + menuRef, + menuStyle, + updatePosition, + } = useFloatingMenu({ + anchorRef, + align: 'center', + positionStrategy: 'absolute', + minWidth: 220, + }); + + useEffect(() => { + if (disabled && isOpen) { + close(); + } + }, [disabled, isOpen, close]); + + useEffect(() => { + if (!isOpen) { + return undefined; + } + setQuery(''); + setPending(false); + const frame = requestAnimationFrame(() => { + updatePosition(); + if (inputRef.current) { + inputRef.current.focus(); + inputRef.current.select?.(); + } + }); + return () => cancelAnimationFrame(frame); + }, [isOpen, updatePosition]); + + const normalizedItems = useMemo(() => normalizeItems(items), [items]); + + const filteredItems = useMemo(() => { + const search = query.trim().toLowerCase(); + const sorted = normalizedItems.slice().sort((a, b) => { + const stateDiff = STATE_ORDER[a.state] - STATE_ORDER[b.state]; + if (stateDiff !== 0) { + return stateDiff; + } + return a.label.localeCompare(b.label); + }); + if (!search) { + return sorted; + } + return sorted.filter((item) => item.label.toLowerCase().includes(search)); + }, [normalizedItems, query]); + + const handleToggle = useCallback( + async (item) => { + if (!item || typeof onToggle !== 'function') { + return; + } + setPending(true); + try { + await onToggle(item); + setPending(false); + close(); + } catch (error) { + setPending(false); + console.error('[selection-assignment] toggle failed', error); + } + }, + [onToggle, close], + ); + + const handleCreate = useCallback( + async () => { + if (typeof onCreate !== 'function') { + return; + } + const value = query.trim(); + if (!value) { + return; + } + setPending(true); + try { + await onCreate(value); + setPending(false); + close(); + } catch (error) { + setPending(false); + console.error('[selection-assignment] creation failed', error); + } + }, + [onCreate, query, close], + ); + + const existingLabels = useMemo( + () => new Set(normalizedItems.map((item) => item.label.toLowerCase())), + [normalizedItems], + ); + + const canCreate = Boolean(onCreate); + const showCreateOption = canCreate + && query.trim().length > 0 + && !existingLabels.has(query.trim().toLowerCase()); + + const handleTriggerClick = useCallback(() => { + if (disabled) { + return; + } + if (!isOpen) { + onOpenMenu?.(); + } + toggle(); + }, [disabled, isOpen, onOpenMenu, toggle]); + + return ( +
+ + {isOpen ? ( +
+
+ setQuery(event.target.value)} + placeholder={placeholder} + aria-label={placeholder} + disabled={pending} + /> +
+
+ {filteredItems.length ? ( + filteredItems.map((item) => { + const isAll = item.state === 'all'; + const isPartial = item.state === 'partial'; + const icon = showStateIndicators + ? isAll + ?
+ {showCreateOption ? ( + + ) : null} +
+ ) : null} +
+ ); +}; + +export default SelectionAssignmentMenu; diff --git a/frontend/src/documents/SelectionFloatingActions.jsx b/frontend/src/documents/SelectionFloatingActions.jsx new file mode 100644 index 0000000..04dd260 --- /dev/null +++ b/frontend/src/documents/SelectionFloatingActions.jsx @@ -0,0 +1,509 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { + TrashIcon, + AnalyzeIcon, + IconX, + FolderOutlineIcon, + TagIcon, + CorrespondentIcon, + LoaderIcon, +} from '../ui/icons'; +import SelectionAssignmentMenu from './SelectionAssignmentMenu'; +import SelectionSummary from './SelectionSummary'; +import { api, useAppState } from '../app/appState'; + +const normalizeDocumentList = (selectedDocumentIds) => + Array.isArray(selectedDocumentIds) ? selectedDocumentIds.filter(Boolean) : []; + +const ROOT_FOLDER_LABEL = 'Documents'; + +const buildFolderTreeOptions = (tree) => { + const entries = []; + + const traverse = (nodes, parentSegments) => { + if (!Array.isArray(nodes) || nodes.length === 0) { + return; + } + nodes.forEach((node) => { + if (!node || !node.id) { + return; + } + const name = typeof node.name === 'string' && node.name.trim().length + ? node.name.trim() + : 'Folder'; + const nextSegments = parentSegments.concat([name]); + const label = nextSegments.join('/'); + entries.push({ id: node.id, label }); + if (Array.isArray(node.children) && node.children.length) { + traverse(node.children, nextSegments); + } + }); + }; + + traverse(Array.isArray(tree) ? tree : [], [ROOT_FOLDER_LABEL]); + + entries.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })); + + return [{ id: 'root', label: ROOT_FOLDER_LABEL }, ...entries]; +}; + +const buildTagAssignments = (selectedDocuments, tagLookupById, tags, total) => { + if (!total) { + return []; + } + + const map = new Map(); + + const ensureEntry = (id, label, color = null) => { + const key = id ?? label; + if (!key || !label) { + return null; + } + if (!map.has(key)) { + map.set(key, { + id, + label, + color, + count: 0, + total, + }); + } + return map.get(key); + }; + + selectedDocuments.forEach((doc) => { + (doc?.tags || []).forEach((tag) => { + const lookupColor = tag?.id && tagLookupById instanceof Map ? tagLookupById.get(tag.id)?.color : null; + const entry = ensureEntry(tag?.id, tag?.label, tag?.color ?? lookupColor ?? null); + if (entry) { + entry.count += 1; + } + }); + }); + + (tags || []).forEach((tag) => { + const lookupColor = tag?.id && tagLookupById instanceof Map ? tagLookupById.get(tag.id)?.color : null; + ensureEntry(tag?.id, tag?.label, tag?.color ?? lookupColor ?? null); + }); + + return Array.from(map.values()).map((entry) => { + const count = entry.count || 0; + const state = count === total ? 'all' : count > 0 ? 'partial' : 'none'; + return { + id: entry.id ?? entry.label, + label: entry.label, + color: entry.color ?? null, + count, + total, + state, + payload: entry, + }; + }); +}; + +const buildCorrespondentAssignments = (selectedDocuments, correspondents, total) => { + if (!total) { + return []; + } + + const map = new Map(); + + const ensureEntry = (id, name) => { + const key = id ?? name; + if (!key || !name) { + return null; + } + if (!map.has(key)) { + map.set(key, { + id, + label: name, + count: 0, + total, + }); + } + return map.get(key); + }; + + selectedDocuments.forEach((doc) => { + (doc?.correspondents || []).forEach((entry) => { + const target = ensureEntry(entry?.id, entry?.name); + if (target) { + target.count += 1; + } + }); + }); + + (correspondents || []).forEach((entry) => { + ensureEntry(entry?.id, entry?.name); + }); + + return Array.from(map.values()).map((entry) => { + const count = entry.count || 0; + const state = count === total ? 'all' : count > 0 ? 'partial' : 'none'; + return { + id: entry.id ?? entry.label, + label: entry.label, + count, + total, + state, + payload: entry, + }; + }); +}; + +const SelectionFloatingActions = ({ + selectionCount = 0, + selectedDocumentIds, + selectedFolderIds = [], + documentLookup, + tags, + tagLookupById, + correspondents, + folderOptions = [], + onBulkTagAdd, + onBulkTagRemove, + onBulkCorrespondentAdd, + onBulkCorrespondentRemove, + onBulkReanalyze, + onDeleteSelection, + onClearSelection = null, + onMoveDocumentsToFolder, +}) => { + const { token, tenant } = useAppState(); + const tenantId = tenant?.id ?? null; + + const [remoteFolderOptions, setRemoteFolderOptions] = useState(null); + const [loadingFolders, setLoadingFolders] = useState(false); + const folderTreeFetchRef = useRef(null); + + useEffect(() => { + setRemoteFolderOptions(null); + folderTreeFetchRef.current = null; + setLoadingFolders(false); + }, [tenantId, token]); + + const requestFolderTree = useCallback(async () => { + if (!token) { + setRemoteFolderOptions([]); + return []; + } + + if (Array.isArray(remoteFolderOptions)) { + return remoteFolderOptions; + } + + if (folderTreeFetchRef.current) { + return folderTreeFetchRef.current; + } + + const fetchPromise = (async () => { + setLoadingFolders(true); + try { + const { data } = await api.get('/folders/tree'); + const options = buildFolderTreeOptions(data); + setRemoteFolderOptions(options); + return options; + } catch (error) { + console.warn('[selection] Failed to load folder tree', error); + setRemoteFolderOptions([]); + return []; + } finally { + setLoadingFolders(false); + folderTreeFetchRef.current = null; + } + })(); + + folderTreeFetchRef.current = fetchPromise; + return fetchPromise; + }, [remoteFolderOptions, token]); + + const handleMoveMenuOpen = useCallback(() => { + requestFolderTree(); + }, [requestFolderTree]); + + const effectiveFolderOptions = useMemo(() => { + if (remoteFolderOptions !== null) { + return remoteFolderOptions; + } + return Array.isArray(folderOptions) ? folderOptions : []; + }, [remoteFolderOptions, folderOptions]); + + const documentIdList = useMemo( + () => normalizeDocumentList(selectedDocumentIds), + [selectedDocumentIds], + ); + + const folderIdList = useMemo( + () => normalizeDocumentList(selectedFolderIds), + [selectedFolderIds], + ); + + const documentCount = documentIdList.length; + const folderCount = folderIdList.length; + const totalCount = typeof selectionCount === 'number' + ? selectionCount + : documentCount + folderCount; + + const selectedDocuments = useMemo(() => { + if (!documentIdList.length || !(documentLookup instanceof Map)) { + return []; + } + return documentIdList + .map((id) => documentLookup.get(id)) + .filter(Boolean); + }, [documentIdList, documentLookup]); + + const selectedDocCount = selectedDocuments.length; + + const moveAssignments = useMemo(() => { + if (!Array.isArray(effectiveFolderOptions)) { + return []; + } + return effectiveFolderOptions + .map((option) => { + const id = option?.id ?? option?.value ?? option; + if (!id) { + return null; + } + const label = option?.label || option?.name || String(id); + const segments = label.split('/'); + const depth = Math.max(segments.length - 1, 0); + return { + id, + label, + state: 'none', + count: null, + total: null, + payload: { + id, + label, + segments, + depth, + }, + }; + }) + .filter(Boolean); + }, [effectiveFolderOptions]); + + const tagAssignments = useMemo( + () => buildTagAssignments(selectedDocuments, tagLookupById, tags, selectedDocCount), + [selectedDocuments, tagLookupById, tags, selectedDocCount], + ); + + const correspondentAssignments = useMemo( + () => buildCorrespondentAssignments(selectedDocuments, correspondents, selectedDocCount), + [selectedDocuments, correspondents, selectedDocCount], + ); + + const renderFolderLabel = useCallback((item) => { + const segments = item?.payload?.segments || (item?.label ? item.label.split('/') : []); + const depth = item?.payload?.depth ?? Math.max(segments.length - 1, 0); + const clampedDepth = Math.min(depth, 6); + const indentWidth = clampedDepth > 0 ? clampedDepth * 0.9 : 0; + const name = segments.length ? segments[segments.length - 1] : item?.label || 'Folder'; + const parentPath = segments.length > 1 ? segments.slice(0, -1).join(' / ') : ''; + + return ( + <> + {indentWidth ? ( +
-
-
- Loading document{documentId ? ` ${documentId}` : ''}… +
+
+
+ Loading document{documentId ? ` ${documentId}` : ''}… +
@@ -242,96 +201,18 @@ const DocumentViewerPanel = ({ return (
-
- -
-
- - {hasOcr ? ( - - ) : null} - {metadataPayload ? ( - - ) : null} -
-
- {activeTab === 'details' ? ( -
-
-
- {metadataItems.map(({ label, value }) => ( -
-
{label}
-
{value || '—'}
-
- ))} -
-
-
- ) : null} - {activeTab === 'content' && hasOcr ? ( -
- {ocrLoading ? ( -
Loading OCR content…
- ) : ocrError ? ( -
- {ocrError} -
- ) : ocrContent ? ( -
-                    {ocrContent}
-                  
- ) : ( -
No OCR content available.
- )} -
- ) : null} - {activeTab === 'metadata' && metadataPayload ? ( -
-
-
-                    {JSON.stringify(metadataPayload, null, 2)}
-                  
-
-
- ) : null} -
+
+
+
@@ -350,7 +231,6 @@ export default DocumentViewerPanel; export const createDocumentViewerHeaderActions = ({ document, actionState, - onRegenerate, }) => { if (!document || !actionState) { return null; @@ -372,15 +252,6 @@ export const createDocumentViewerHeaderActions = ({ ) : null} - ); }; @@ -394,7 +265,6 @@ export const createDocumentViewerSurface = ({ getDocumentAsset, resolveApiPath, notifyApiError, - onRegenerate, onClose, renderSidebarToggle, tagLookupById, @@ -469,7 +339,6 @@ export const createDocumentViewerSurface = ({ actions: createDocumentViewerHeaderActions({ document, actionState, - onRegenerate, }), breadcrumbs, }; diff --git a/frontend/src/settings/SettingsModal.jsx b/frontend/src/settings/SettingsModal.jsx index 3860394..589c13a 100644 --- a/frontend/src/settings/SettingsModal.jsx +++ b/frontend/src/settings/SettingsModal.jsx @@ -1,49 +1,41 @@ -import React, { useMemo, useState, useCallback, useEffect } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; import PanelHeader from '../ui/PanelHeader'; - -const SECTIONS = [ - { - id: 'passkeys', - label: 'Passkeys', - }, - { - id: 'apiTokens', - label: 'API tokens', - }, -]; +import { DEFAULT_SETTINGS_SECTIONS } from './sections'; const SettingsModal = ({ - open, + open = false, onClose, - tokens = [], - loading = false, - creating = false, - deletingId = null, - regeneratingId = null, - updatingId = null, - onRefresh, - onCreate, - onDelete, - onRegenerate, - onUpdateCapabilities, - createdToken = null, - onDismissCreatedToken, - passkeys = [], - passkeysSupported = null, - passkeysLoading = false, - registeringPasskey = false, - revokingPasskeyId = null, - onRefreshPasskeys, - onRegisterPasskey, - onRevokePasskey, + sections, + defaultSectionId, + ...sectionProps }) => { - const defaultSection = SECTIONS[0]?.id || 'passkeys'; - const [activeSection, setActiveSection] = useState(defaultSection); - const [newTokenLabel, setNewTokenLabel] = useState(''); - const [newTokenExpires, setNewTokenExpires] = useState(''); - const [newTokenCapabilities, setNewTokenCapabilities] = useState([]); - const [formError, setFormError] = useState(null); - const [newPasskeyNickname, setNewPasskeyNickname] = useState(''); + const sectionList = useMemo(() => { + if (Array.isArray(sections) && sections.length) { + return sections; + } + return DEFAULT_SETTINGS_SECTIONS; + }, [sections]); + + const firstSectionId = sectionList[0]?.id ?? null; + const resolvedDefaultSection = defaultSectionId || firstSectionId; + + const [activeSection, setActiveSection] = useState(resolvedDefaultSection); + + useEffect(() => { + if (!open) { + setActiveSection(resolvedDefaultSection); + return; + } + const hasActiveSection = sectionList.some((section) => section.id === activeSection); + if (!hasActiveSection) { + setActiveSection(resolvedDefaultSection); + } + }, [open, sectionList, resolvedDefaultSection, activeSection]); const handleBackdropClick = useCallback(() => { onClose?.(); @@ -53,508 +45,21 @@ const SettingsModal = ({ event.stopPropagation(); }, []); - useEffect(() => { - if (!open) { - setActiveSection(defaultSection); - setNewTokenLabel(''); - setNewTokenExpires(''); - setNewTokenCapabilities([]); - setFormError(null); - setNewPasskeyNickname(''); - } - }, [open, defaultSection]); - - const formatDateTime = useCallback((value) => { - if (!value) { - return '—'; - } - const timestamp = new Date(value); - if (Number.isNaN(timestamp.getTime())) { - return value; - } - return timestamp.toLocaleString(); - }, []); - - const handleRefresh = useCallback(() => { - onRefresh?.(); - }, [onRefresh]); - - const handleCopyToken = useCallback(() => { - if (!createdToken) { - return; - } - if (navigator?.clipboard?.writeText) { - navigator.clipboard.writeText(createdToken).catch(() => {}); - } - }, [createdToken]); - - const handleDismissSecret = useCallback(() => { - onDismissCreatedToken?.(); - }, [onDismissCreatedToken]); - - const handlePasskeyRefresh = useCallback(() => { - onRefreshPasskeys?.(); - }, [onRefreshPasskeys]); - - const handlePasskeyRegister = useCallback( - async (event) => { - event.preventDefault(); - const nickname = newPasskeyNickname.trim(); - const result = await onRegisterPasskey?.({ nickname }); - if (result?.ok) { - setNewPasskeyNickname(''); - } - }, - [newPasskeyNickname, onRegisterPasskey], - ); - - const handlePasskeyRevoke = useCallback( - async (passkey) => { - if (!passkey?.id) { - return; - } - const reasonInput = window.prompt('Optional reason for revoking this passkey:', ''); - const reason = reasonInput ? reasonInput.trim() : undefined; - await onRevokePasskey?.(passkey.id, reason); - }, - [onRevokePasskey], - ); - - const capabilityOptions = useMemo( - () => [ - { value: 'webdav', label: 'WebDAV access' }, - { value: 'api', label: 'REST API access' }, - ], - [], - ); - - const handleNewCapabilityChange = useCallback((capability, enabled) => { - setFormError(null); - setNewTokenCapabilities((previous) => { - if (enabled) { - if (previous.includes(capability)) { - return previous; - } - return [...previous, capability]; - } - return previous.filter((value) => value !== capability); - }); - }, []); - - const handleToggleTokenCapability = useCallback( - async (token, capability, enabled) => { - if (!token?.id || !onUpdateCapabilities) { - return; - } - - const existing = Array.isArray(token.capabilities) ? [...token.capabilities] : []; - let next; - if (enabled) { - if (existing.includes(capability)) { - return; - } - next = [...existing, capability]; - } else { - next = existing.filter((value) => value !== capability); - if (next.length === 0) { - setFormError('Tokens must have at least one capability.'); - return; - } - } - - setFormError(null); - const result = await onUpdateCapabilities(token.id, next); - if (result === false) { - setFormError('Failed to update token capabilities.'); - } - }, - [onUpdateCapabilities], - ); - - const handleCreateToken = useCallback( - async (event) => { - event.preventDefault(); - setFormError(null); - let normalizedLabel = newTokenLabel.trim(); - if (normalizedLabel.length === 0) { - normalizedLabel = undefined; - } - - let normalizedExpires; - if (newTokenExpires) { - const parsed = new Date(newTokenExpires); - if (Number.isNaN(parsed.getTime())) { - setFormError('Enter a valid expiration date.'); - return; - } - normalizedExpires = parsed.toISOString(); - } - - if (!newTokenCapabilities.length) { - setFormError('Select at least one capability.'); - return; - } - - const result = await onCreate?.({ - label: normalizedLabel, - expires_at: normalizedExpires, - capabilities: newTokenCapabilities, - }); - - if (result !== false) { - setNewTokenLabel(''); - setNewTokenExpires(''); - setNewTokenCapabilities([]); - setFormError(null); - } - }, - [newTokenExpires, newTokenLabel, newTokenCapabilities, onCreate], - ); - - const handleRegenerateToken = useCallback( - async (token) => { - if (!token?.id) { - return; - } - await onRegenerate?.(token.id); - }, - [onRegenerate], - ); - - const renderApiTokensSection = useMemo(() => { - const hasTokens = Array.isArray(tokens) && tokens.length > 0; - - return ( -
-
- -
- -

- API tokens can grant access to the REST API, WebDAV, or both. Select at least one - capability for each token. You can adjust capabilities for existing tokens at any time. -

- - {createdToken ? ( -
-

- Copy this token now; you will not be able to view it again after closing this window. -

-
{createdToken}
-
- - -
-
- ) : null} - -
-
- - setNewTokenLabel(event.target.value)} - placeholder="Personal API token" - /> -
-
- - setNewTokenExpires(event.target.value)} - /> -
-
- Capabilities -
- {capabilityOptions.map((option) => { - const checked = newTokenCapabilities.includes(option.value); - return ( - - ); - })} -
-
-
- -
-
- {formError ?

{formError}

: null} - - {loading && !hasTokens ? ( -

Loading tokens…

- ) : null} - - {!loading && !hasTokens ? ( -

No API tokens yet.

- ) : null} - - {hasTokens ? ( - - - - - - - - - - - - - {tokens.map((token) => { - const isRevoked = Boolean(token?.revoked_at); - const capabilitySet = Array.isArray(token?.capabilities) - ? token.capabilities - : []; - return ( - - - - - - - - - ); - })} - -
LabelCreatedLast usedExpiresCapabilitiesActions
{token.label || '—'}{formatDateTime(token.created_at)}{formatDateTime(token.last_used_at)}{formatDateTime(token.expires_at)} -
- {capabilityOptions.map((option) => { - const checked = capabilitySet.includes(option.value); - return ( - - ); - })} - {updatingId === token.id ? ( - Saving… - ) : null} -
-
- {isRevoked ? ( - Revoked - ) : ( - <> - - - - )} -
- ) : null} -
- ); - }, [ - tokens, - loading, - createdToken, - creating, - deletingId, - regeneratingId, - updatingId, - newTokenLabel, - newTokenExpires, - newTokenCapabilities, - formError, - capabilityOptions, - formatDateTime, - handleCopyToken, - handleCreateToken, - handleRegenerateToken, - handleRefresh, - onDelete, - handleDismissSecret, - handleNewCapabilityChange, - handleToggleTokenCapability, - ]); - - const renderPasskeysSection = useMemo(() => { - const hasPasskeys = Array.isArray(passkeys) && passkeys.length > 0; - - return ( -
-
- -
- - {passkeysSupported === false ? ( -

Passkeys are not enabled for this account.

- ) : ( - <> -
-
- - setNewPasskeyNickname(event.target.value)} - disabled={registeringPasskey} - /> -
-
- -
-
- - {passkeysLoading && !hasPasskeys ? ( -

Loading passkeys…

- ) : null} - - {!passkeysLoading && !hasPasskeys ? ( -

No passkeys registered yet.

- ) : null} - - {hasPasskeys ? ( - - - - - - - - - - - - - {passkeys.map((passkey) => { - const createdAt = passkey.created_at || passkey.createdAt; - const lastUsedAt = passkey.last_used_at || passkey.lastUsedAt; - const revokedAt = passkey.revoked_at || passkey.revokedAt; - const revokedReason = passkey.revoked_reason || passkey.revokedReason; - const revoked = Boolean(revokedAt); - const transports = Array.isArray(passkey.transports) - ? passkey.transports.filter(Boolean) - : []; - - return ( - - - - - - - - - ); - })} - -
NicknameCreatedLast usedTransportsStatusActions
{passkey.nickname || '—'}{formatDateTime(createdAt)}{formatDateTime(lastUsedAt)}{transports.length ? transports.join(', ') : '—'} - {revoked - ? revokedReason - ? `Revoked (${revokedReason})` - : 'Revoked' - : 'Active'} - - {revoked ? ( - Revoked - ) : ( - - )} -
- ) : null} - - )} -
- ); - }, [ - passkeys, - passkeysLoading, - passkeysSupported, - registeringPasskey, - revokingPasskeyId, - newPasskeyNickname, - formatDateTime, - handlePasskeyRefresh, - handlePasskeyRegister, - handlePasskeyRevoke, - ]); - if (!open) { return null; } + const activeSectionConfig = sectionList.find((section) => section.id === activeSection); + let sectionContent = null; + if (activeSectionConfig) { + if (activeSectionConfig.component) { + const SectionComponent = activeSectionConfig.component; + sectionContent = ; + } else if (typeof activeSectionConfig.render === 'function') { + sectionContent = activeSectionConfig.render(sectionProps); + } + } + return (
- {activeSection === 'passkeys' ? renderPasskeysSection : null} - {activeSection === 'apiTokens' ? renderApiTokensSection : null} - {activeSection !== 'passkeys' && activeSection !== 'apiTokens' ? ( + {sectionContent || (

Select a settings section.

- ) : null} + )}
diff --git a/frontend/src/settings/components/CapabilityDropdown.jsx b/frontend/src/settings/components/CapabilityDropdown.jsx new file mode 100644 index 0000000..30e6e14 --- /dev/null +++ b/frontend/src/settings/components/CapabilityDropdown.jsx @@ -0,0 +1,147 @@ +import React, { + useCallback, + useEffect, + useRef, + useState, +} from 'react'; +import { CheckIcon, ChevronDownIcon } from '../../ui/icons'; + +const CapabilityDropdown = ({ + id, + options = [], + selectedValues = [], + onSelect, + onDeselect, + formatLabel, + disabled = false, + loading = false, + summaryLabel = 'capabilities', +}) => { + const anchorRef = useRef(null); + const menuRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + + const close = useCallback(() => { + setIsOpen(false); + }, []); + + const toggle = useCallback(() => { + if (disabled) { + return; + } + setIsOpen((previous) => !previous); + }, [disabled]); + + useEffect(() => { + if (!isOpen) { + return undefined; + } + + const handlePointerEvent = (event) => { + if (anchorRef.current?.contains(event.target) || menuRef.current?.contains(event.target)) { + return; + } + close(); + }; + + const handleKeyDown = (event) => { + if (event.key === 'Escape') { + close(); + } + }; + + document.addEventListener('mousedown', handlePointerEvent); + document.addEventListener('touchstart', handlePointerEvent, { passive: true }); + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.removeEventListener('mousedown', handlePointerEvent); + document.removeEventListener('touchstart', handlePointerEvent); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [close, isOpen]); + + const handleOptionClick = useCallback((value) => { + if (selectedValues.includes(value)) { + onDeselect?.(value); + } else { + onSelect?.(value); + } + }, [onDeselect, onSelect, selectedValues]); + + const total = options.length; + const selectedCount = selectedValues.length; + + const summaryText = total + ? `${selectedCount}/${total} ${summaryLabel} enabled` + : selectedCount + ? `${selectedCount} ${summaryLabel} selected` + : loading + ? `Loading ${summaryLabel}…` + : `No ${summaryLabel}`; + + const buttonText = selectedCount || loading || total + ? summaryText + : `Select ${summaryLabel}`; + + const emptyMessage = loading + ? `Loading ${summaryLabel}…` + : `No ${summaryLabel} available.`; + + const isDisabled = disabled || (total === 0 && !selectedCount) || loading; + + return ( +
+ + {isOpen ? ( + + ) : null} +
+ ); +}; + +export default CapabilityDropdown; diff --git a/frontend/src/settings/sections/ApiTokensSection.jsx b/frontend/src/settings/sections/ApiTokensSection.jsx new file mode 100644 index 0000000..fd65511 --- /dev/null +++ b/frontend/src/settings/sections/ApiTokensSection.jsx @@ -0,0 +1,443 @@ +import React, { + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; + +const formatDateTime = (value) => { + if (!value) { + return '—'; + } + const timestamp = new Date(value); + if (Number.isNaN(timestamp.getTime())) { + return value; + } + return timestamp.toLocaleString(); +}; + +const ApiTokensSection = ({ + tokens = [], + loading = false, + creating = false, + deletingId = null, + regeneratingId = null, + createdToken = null, + capabilitySets = [], + capabilitySetsLoading = false, + capabilities = [], + capabilitiesLoading = false, + onRefresh, + onCreate, + onDelete, + onRegenerate, + onDismissCreatedToken, + onRefreshCapabilitySets, + onRefreshCapabilities, +}) => { + const [newTokenLabel, setNewTokenLabel] = useState(''); + const [newTokenExpires, setNewTokenExpires] = useState(''); + const [newTokenCapabilitySetId, setNewTokenCapabilitySetId] = useState(''); + const [formError, setFormError] = useState(null); + const supportsClipboardWrite = typeof navigator !== 'undefined' + && Boolean(navigator?.clipboard?.writeText); + const [canCopyToken, setCanCopyToken] = useState(supportsClipboardWrite); + const [copyFeedback, setCopyFeedback] = useState(null); + + const capabilitySetOptions = useMemo( + () => capabilitySets.map((set) => ({ + value: set.id, + label: set.label || set.slug || set.id, + capabilities: Array.isArray(set.capabilities) ? set.capabilities : [], + })), + [capabilitySets], + ); + + const capabilitySetMap = useMemo( + () => Object.fromEntries(capabilitySetOptions.map((option) => [option.value, option])), + [capabilitySetOptions], + ); + + const capabilitySelectionOptions = useMemo(() => ( + Array.isArray(capabilities) + ? capabilities.map((capability) => { + if (typeof capability !== 'string') { + return { value: capability, label: String(capability) }; + } + const [namespace, action] = capability.split(':'); + if (!namespace || !action) { + return { value: capability, label: capability }; + } + const formattedNamespace = `${namespace.charAt(0).toUpperCase()}${namespace.slice(1)}`; + const formattedAction = action.replace(/_/g, ' '); + return { + value: capability, + label: `${formattedNamespace}: ${formattedAction}`, + }; + }) + : [] + ), [capabilities]); + + const capabilityLabelMap = useMemo(() => { + const map = new Map(); + capabilitySelectionOptions.forEach(({ value, label }) => { + map.set(value, label || String(value)); + }); + return map; + }, [capabilitySelectionOptions]); + + const formatCapabilityLabel = useCallback((value) => ( + capabilityLabelMap.get(value) || String(value) + ), [capabilityLabelMap]); + + useEffect(() => { + if (!capabilitySetOptions.length) { + setNewTokenCapabilitySetId(''); + return; + } + if (!newTokenCapabilitySetId + || !capabilitySetOptions.some((option) => option.value === newTokenCapabilitySetId)) { + setNewTokenCapabilitySetId(capabilitySetOptions[0].value); + } + }, [capabilitySetOptions, newTokenCapabilitySetId]); + + const selectedTokenCapabilitySetCapabilities = useMemo( + () => capabilitySetMap[newTokenCapabilitySetId]?.capabilities || [], + [capabilitySetMap, newTokenCapabilitySetId], + ); + + const handleRefresh = useCallback(() => { + if (onRefresh) { + onRefresh(); + } + onRefreshCapabilitySets?.(); + onRefreshCapabilities?.(); + }, [onRefresh, onRefreshCapabilities, onRefreshCapabilitySets]); + + const handleCopyToken = useCallback(async () => { + if (!createdToken || !canCopyToken) { + return; + } + + const showSuccess = () => + setCopyFeedback({ type: 'success', message: 'Token copied to clipboard.' }); + const showFailure = () => + setCopyFeedback({ + type: 'error', + message: + 'Copy failed. Your browser may require HTTPS access; please select the token manually.', + }); + + try { + await navigator.clipboard.writeText(createdToken); + showSuccess(); + return; + } catch (error) { + // Some browsers expose writeText but still reject outside secure context + setCanCopyToken(false); + } + + showFailure(); + }, [createdToken, canCopyToken]); + + const handleDismissSecret = useCallback(() => { + setCopyFeedback(null); + onDismissCreatedToken?.(); + }, [onDismissCreatedToken]); + + useEffect(() => { + setCopyFeedback(null); + if (typeof navigator !== 'undefined') { + setCanCopyToken(Boolean(navigator?.clipboard?.writeText)); + } + }, [createdToken]); + + const handleNewCapabilitySetChange = useCallback((event) => { + setFormError(null); + setNewTokenCapabilitySetId(event.target.value); + }, []); + + const handleCreateToken = useCallback( + async (event) => { + event.preventDefault(); + setFormError(null); + let normalizedLabel = newTokenLabel.trim(); + if (normalizedLabel.length === 0) { + normalizedLabel = undefined; + } + + let normalizedExpires; + if (newTokenExpires) { + const parsed = new Date(newTokenExpires); + if (Number.isNaN(parsed.getTime())) { + setFormError('Enter a valid expiration date.'); + return; + } + normalizedExpires = parsed.toISOString(); + } + + if (!capabilitySetOptions.length) { + setFormError('Capability sets are still loading.'); + return; + } + + const selectedCapabilitySetId = newTokenCapabilitySetId || capabilitySetOptions[0]?.value; + if (!selectedCapabilitySetId) { + setFormError('Select a capability set.'); + return; + } + + const result = await onCreate?.({ + label: normalizedLabel, + expires_at: normalizedExpires, + capability_set_id: selectedCapabilitySetId, + }); + + if (result !== false) { + setNewTokenLabel(''); + setNewTokenExpires(''); + setNewTokenCapabilitySetId(capabilitySetOptions[0]?.value || ''); + setFormError(null); + } + }, + [ + capabilitySetOptions, + newTokenCapabilitySetId, + newTokenExpires, + newTokenLabel, + onCreate, + ], + ); + + const handleRegenerateToken = useCallback( + async (token) => { + if (!token?.id) { + return; + } + await onRegenerate?.(token.id); + }, + [onRegenerate], + ); + + return ( +
+
+ +
+ +

+ API tokens use predefined capability sets. Choose the set that matches the access you need when + creating or updating a token. +

+ + {createdToken ? ( +
+

+ Copy this token now; you will not be able to view it again after closing this window. +

+
{createdToken}
+
+ {canCopyToken ? ( + + ) : null} + +
+ {copyFeedback ? ( +

+ {copyFeedback.message} +

+ ) : null} +
+ ) : null} + +
+
+ + setNewTokenLabel(event.target.value)} + placeholder="Personal API token" + /> +
+
+ + setNewTokenExpires(event.target.value)} + /> +
+
+ + + {capabilitySetsLoading ? ( + Loading capability sets… + ) : null} + {!capabilitySetsLoading && !capabilitySetOptions.length ? ( + No capability sets available yet. + ) : null} +
+
+ {selectedTokenCapabilitySetCapabilities.length ? ( +
+ {selectedTokenCapabilitySetCapabilities.map((value) => ( + + {formatCapabilityLabel(value)} + + ))} +
+ ) : ( + No capabilities selected. + )} + {capabilitiesLoading ? ( + Loading capabilities… + ) : null} +
+
+ +
+
+ {formError ? ( +

{formError}

+ ) : null} + + {loading && !tokens.length ? ( +

Loading tokens…

+ ) : null} + + {!loading && !tokens.length ? ( +

No API tokens yet.

+ ) : null} + + {tokens.length ? ( + + + + + + + + + + + + + {tokens.map((token) => { + const isRevoked = Boolean(token?.revoked_at); + const selectedSet = token?.capability_set_id + ? capabilitySetMap[token.capability_set_id] + : null; + const capabilityList = Array.isArray(token?.capabilities) && token.capabilities.length + ? token.capabilities + : selectedSet?.capabilities || []; + const capabilitySetLabel = selectedSet?.label + || selectedSet?.slug + || token.capability_set_id + || '—'; + + return ( + + + + + + + + + ); + })} + +
LabelCreatedLast usedExpiresCapability setActions
{token.label || '—'}{formatDateTime(token.created_at)}{formatDateTime(token.last_used_at)}{formatDateTime(token.expires_at)} +
+ {capabilitySetLabel} +
+ {capabilitySetsLoading ? ( + Loading capability sets… + ) : null} + {!capabilitySetsLoading && capabilityList.length ? ( +
+ {capabilityList.map((value) => ( + + {formatCapabilityLabel(value)} + + ))} +
+ ) : null} +
+ {isRevoked ? ( + Revoked + ) : ( + <> + + + + )} +
+ ) : null} +
+ ); +}; + +export const API_TOKENS_SECTION = { + id: 'apiTokens', + label: 'API tokens', + component: ApiTokensSection, +}; + +export default ApiTokensSection; diff --git a/frontend/src/settings/sections/CapabilitySetsSection.jsx b/frontend/src/settings/sections/CapabilitySetsSection.jsx new file mode 100644 index 0000000..27ee405 --- /dev/null +++ b/frontend/src/settings/sections/CapabilitySetsSection.jsx @@ -0,0 +1,582 @@ +import React, { + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import { IconX } from '../../ui/icons'; +import CapabilityDropdown from '../components/CapabilityDropdown'; + +const CapabilitySetsSection = ({ + capabilitySets = [], + capabilitySetsLoading = false, + creatingCapabilitySet = false, + savingCapabilitySetId = null, + deletingCapabilitySetId = null, + supportsCapabilitySetLabels = false, + capabilities = [], + capabilitiesLoading = false, + onRefreshCapabilitySets, + onRefreshCapabilities, + onRefresh, + onCreateCapabilitySet, + onUpdateCapabilitySet, + onDeleteCapabilitySet, +}) => { + const [newCapabilitySetSlug, setNewCapabilitySetSlug] = useState(''); + const [newCapabilitySetLabel, setNewCapabilitySetLabel] = useState(''); + const [newCapabilitySetCapabilities, setNewCapabilitySetCapabilities] = useState([]); + const [capabilitySetFormError, setCapabilitySetFormError] = useState(null); + + const [editingCapabilitySetId, setEditingCapabilitySetId] = useState(null); + const [editCapabilitySetSlug, setEditCapabilitySetSlug] = useState(''); + const [editCapabilitySetLabel, setEditCapabilitySetLabel] = useState(''); + const [editCapabilitySetCapabilities, setEditCapabilitySetCapabilities] = useState([]); + const [capabilitySetEditError, setCapabilitySetEditError] = useState(null); + + const capabilitySelectionOptions = useMemo(() => ( + Array.isArray(capabilities) + ? capabilities.map((capability) => { + if (typeof capability !== 'string') { + return { value: capability, label: String(capability) }; + } + const [namespace, action] = capability.split(':'); + if (!namespace || !action) { + return { value: capability, label: capability }; + } + const formattedNamespace = `${namespace.charAt(0).toUpperCase()}${namespace.slice(1)}`; + const formattedAction = action.replace(/_/g, ' '); + return { + value: capability, + label: `${formattedNamespace}: ${formattedAction}`, + }; + }) + : [] + ), [capabilities]); + + const capabilityLabelMap = useMemo(() => { + const map = new Map(); + capabilitySelectionOptions.forEach(({ value, label }) => { + map.set(value, label || String(value)); + }); + return map; + }, [capabilitySelectionOptions]); + + const capabilityOrder = useMemo(() => { + const order = new Map(); + capabilitySelectionOptions.forEach((option, index) => { + order.set(option.value, index); + }); + return order; + }, [capabilitySelectionOptions]); + + const sortCapabilityValues = useCallback((values) => { + if (!Array.isArray(values)) { + return []; + } + return [...values].sort((a, b) => { + const indexA = capabilityOrder.has(a) ? capabilityOrder.get(a) : Number.MAX_SAFE_INTEGER; + const indexB = capabilityOrder.has(b) ? capabilityOrder.get(b) : Number.MAX_SAFE_INTEGER; + if (indexA === indexB) { + return String(a).localeCompare(String(b)); + } + return indexA - indexB; + }); + }, [capabilityOrder]); + + const formatCapabilityLabel = useCallback((value) => ( + capabilityLabelMap.get(value) || String(value) + ), [capabilityLabelMap]); + + const capabilitySetOptions = useMemo( + () => capabilitySets.map((set) => ({ + value: set.id, + label: set.label || set.slug || set.id, + capabilities: Array.isArray(set.capabilities) ? set.capabilities : [], + isSystem: Boolean(set?.is_system), + version: typeof set?.cap_version === 'number' ? set.cap_version : null, + })), + [capabilitySets], + ); + + const hasCapabilitySets = capabilitySetOptions.length > 0; + const columnCount = supportsCapabilitySetLabels ? 6 : 5; + + const handleCapabilitySetsRefresh = useCallback(() => { + if (onRefreshCapabilitySets) { + onRefreshCapabilitySets(); + } else { + onRefresh?.(); + } + onRefreshCapabilities?.(); + }, [onRefresh, onRefreshCapabilities, onRefreshCapabilitySets]); + + const handleAddCapabilityToNewSet = useCallback((option) => { + const value = option?.value ?? option?.id ?? option; + if (!value) { + return; + } + setCapabilitySetFormError(null); + setNewCapabilitySetCapabilities((previous) => { + if (previous.includes(value)) { + return previous; + } + return sortCapabilityValues([...previous, value]); + }); + }, [sortCapabilityValues]); + + const handleRemoveCapabilityFromNewSet = useCallback((value) => { + setCapabilitySetFormError(null); + setNewCapabilitySetCapabilities((previous) => previous.filter((item) => item !== value)); + }, []); + + const handleCreateCapabilitySetSubmit = useCallback( + async (event) => { + event.preventDefault(); + setCapabilitySetFormError(null); + + if (!Array.isArray(newCapabilitySetCapabilities) || newCapabilitySetCapabilities.length === 0) { + setCapabilitySetFormError('Select at least one capability.'); + return; + } + + if (!capabilitySelectionOptions.length) { + setCapabilitySetFormError('Capabilities are still loading.'); + return; + } + + const payload = { + slug: newCapabilitySetSlug, + label: newCapabilitySetLabel, + capabilities: sortCapabilityValues(newCapabilitySetCapabilities), + }; + + const result = await onCreateCapabilitySet?.(payload); + if (result === false) { + setCapabilitySetFormError('Failed to create capability set.'); + return; + } + + setNewCapabilitySetSlug(''); + setNewCapabilitySetLabel(''); + setNewCapabilitySetCapabilities([]); + setCapabilitySetFormError(null); + }, + [ + capabilitySelectionOptions, + newCapabilitySetCapabilities, + newCapabilitySetLabel, + newCapabilitySetSlug, + onCreateCapabilitySet, + sortCapabilityValues, + ], + ); + + const handleStartEditCapabilitySet = useCallback((capabilitySet) => { + if (!capabilitySet) { + return; + } + setCapabilitySetEditError(null); + setEditingCapabilitySetId(capabilitySet.id); + setEditCapabilitySetSlug(capabilitySet.slug || ''); + setEditCapabilitySetLabel(capabilitySet.label || ''); + setEditCapabilitySetCapabilities( + sortCapabilityValues(Array.isArray(capabilitySet.capabilities) ? capabilitySet.capabilities : []), + ); + }, [sortCapabilityValues]); + + const handleCancelEditCapabilitySet = useCallback(() => { + setEditingCapabilitySetId(null); + setEditCapabilitySetSlug(''); + setEditCapabilitySetLabel(''); + setEditCapabilitySetCapabilities([]); + setCapabilitySetEditError(null); + }, []); + + const handleAddCapabilityToEditSet = useCallback((option) => { + const value = option?.value ?? option?.id ?? option; + if (!value) { + return; + } + setCapabilitySetEditError(null); + setEditCapabilitySetCapabilities((previous) => { + if (previous.includes(value)) { + return previous; + } + return sortCapabilityValues([...previous, value]); + }); + }, [sortCapabilityValues]); + + const handleRemoveCapabilityFromEditSet = useCallback((value) => { + setCapabilitySetEditError(null); + setEditCapabilitySetCapabilities((previous) => previous.filter((item) => item !== value)); + }, []); + + const handleUpdateCapabilitySetSubmit = useCallback( + async (event) => { + event.preventDefault(); + if (!editingCapabilitySetId) { + return; + } + if (!Array.isArray(editCapabilitySetCapabilities) || editCapabilitySetCapabilities.length === 0) { + setCapabilitySetEditError('Select at least one capability.'); + return; + } + + const payload = { + slug: editCapabilitySetSlug, + label: editCapabilitySetLabel, + capabilities: sortCapabilityValues(editCapabilitySetCapabilities), + }; + + const result = await onUpdateCapabilitySet?.(editingCapabilitySetId, payload); + if (result === false) { + setCapabilitySetEditError('Failed to update capability set.'); + return; + } + + handleCancelEditCapabilitySet(); + }, + [ + editCapabilitySetCapabilities, + editCapabilitySetLabel, + editCapabilitySetSlug, + editingCapabilitySetId, + handleCancelEditCapabilitySet, + onUpdateCapabilitySet, + sortCapabilityValues, + ], + ); + + const handleDeleteCapabilitySet = useCallback( + async (capabilitySet) => { + if (!capabilitySet?.id) { + return; + } + const displayName = capabilitySet.slug || capabilitySet.label || capabilitySet.id; + const confirmed = window.confirm(`Delete capability set "${displayName}"?`); + if (!confirmed) { + return; + } + const result = await onDeleteCapabilitySet?.(capabilitySet.id); + if (result === false) { + setCapabilitySetEditError('Failed to delete capability set.'); + } + }, + [onDeleteCapabilitySet], + ); + + useEffect(() => { + if (!editingCapabilitySetId) { + return; + } + const exists = capabilitySets.some((set) => set.id === editingCapabilitySetId); + if (!exists) { + handleCancelEditCapabilitySet(); + } + }, [capabilitySets, editingCapabilitySetId, handleCancelEditCapabilitySet]); + + return ( +
+
+ +
+ +

+ Capability sets bundle permissions that you can assign to API tokens and user memberships. +

+ +
+
+ + setNewCapabilitySetSlug(event.target.value)} + placeholder="e.g. api_readonly" + disabled={creatingCapabilitySet || capabilitySetsLoading} + /> + Leave blank to generate a slug automatically. +
+ {supportsCapabilitySetLabels ? ( +
+ + setNewCapabilitySetLabel(event.target.value)} + placeholder="Friendly name (optional)" + disabled={creatingCapabilitySet || capabilitySetsLoading} + /> +
+ ) : null} +
+ +
+ + {newCapabilitySetCapabilities.length ? ( +
+ {newCapabilitySetCapabilities.map((value) => ( + + {formatCapabilityLabel(value)} + + + ))} +
+ ) : ( + No capabilities selected. + )} + {capabilitiesLoading ? ( + Loading capabilities… + ) : null} + {!capabilitiesLoading && !capabilitySelectionOptions.length ? ( + No capabilities available. + ) : null} +
+
+
+ +
+
+ {capabilitySetFormError ? ( +

{capabilitySetFormError}

+ ) : null} + {capabilitySetsLoading && !hasCapabilitySets ? ( +

Loading capability sets…

+ ) : null} + + {!capabilitySetsLoading && !hasCapabilitySets ? ( +

No capability sets yet.

+ ) : null} + + {hasCapabilitySets ? ( + + + + + {supportsCapabilitySetLabels ? : null} + + + + + + + + {capabilitySets.map((set) => { + const isSystem = Boolean(set?.is_system); + const isEditing = editingCapabilitySetId === set.id; + const capabilityList = sortCapabilityValues( + Array.isArray(set?.capabilities) ? set.capabilities : [], + ); + const saving = savingCapabilitySetId === set.id; + const deleting = deletingCapabilitySetId === set.id; + + return ( + + + + {supportsCapabilitySetLabels ? ( + + ) : null} + + + + + + {isEditing ? ( + + + + ) : null} + + ); + })} + +
SlugLabelCapabilitiesSystemVersionActions
{set.slug || '—'}{set.label || '—'} + {capabilityList.length + ? capabilityList.map((value) => formatCapabilityLabel(value)).join(', ') + : '—'} + {isSystem ? 'Yes' : 'No'}{typeof set.cap_version === 'number' ? set.cap_version : '—'} + {isSystem ? ( + System set + ) : ( + <> + + + + )} +
+
+
+ + setEditCapabilitySetSlug(event.target.value)} + disabled={saving || capabilitySetsLoading} + /> +
+ {supportsCapabilitySetLabels ? ( +
+ + setEditCapabilitySetLabel(event.target.value)} + disabled={saving || capabilitySetsLoading} + /> +
+ ) : null} +
+ +
+ + {editCapabilitySetCapabilities.length ? ( +
+ {editCapabilitySetCapabilities.map((value) => ( + + {formatCapabilityLabel(value)} + + + ))} +
+ ) : ( + + No capabilities selected. + + )} + {capabilitiesLoading ? ( + Loading capabilities… + ) : null} + {!capabilitiesLoading && !capabilitySelectionOptions.length ? ( + No capabilities available. + ) : null} +
+
+
+ + +
+
+ {capabilitySetEditError ? ( +

{capabilitySetEditError}

+ ) : null} +
+ ) : null} +
+ ); +}; + +export const CAPABILITY_SETS_SECTION = { + id: 'capabilitySets', + label: 'Capability sets', + component: CapabilitySetsSection, +}; + +export default CapabilitySetsSection; diff --git a/frontend/src/settings/sections/PasskeysSection.jsx b/frontend/src/settings/sections/PasskeysSection.jsx new file mode 100644 index 0000000..143254c --- /dev/null +++ b/frontend/src/settings/sections/PasskeysSection.jsx @@ -0,0 +1,172 @@ +import React, { + useCallback, + useMemo, + useState, +} from 'react'; + +const formatDateTime = (value) => { + if (!value) { + return '—'; + } + const timestamp = new Date(value); + if (Number.isNaN(timestamp.getTime())) { + return value; + } + return timestamp.toLocaleString(); +}; + +const PasskeysSection = ({ + passkeys = [], + passkeysSupported = null, + passkeysLoading = false, + registeringPasskey = false, + revokingPasskeyId = null, + onRefreshPasskeys, + onRegisterPasskey, + onRevokePasskey, +}) => { + const [newPasskeyNickname, setNewPasskeyNickname] = useState(''); + + const hasPasskeys = useMemo(() => Array.isArray(passkeys) && passkeys.length > 0, [passkeys]); + + const handlePasskeyRefresh = useCallback(() => { + onRefreshPasskeys?.(); + }, [onRefreshPasskeys]); + + const handlePasskeyRegister = useCallback( + async (event) => { + event.preventDefault(); + const nickname = newPasskeyNickname.trim(); + const result = await onRegisterPasskey?.({ nickname }); + if (result?.ok) { + setNewPasskeyNickname(''); + } + }, + [newPasskeyNickname, onRegisterPasskey], + ); + + const handlePasskeyRevoke = useCallback( + async (passkey) => { + if (!passkey?.id) { + return; + } + const reasonInput = window.prompt('Optional reason for revoking this passkey:', ''); + const reason = reasonInput ? reasonInput.trim() : undefined; + await onRevokePasskey?.(passkey.id, reason); + }, + [onRevokePasskey], + ); + + return ( +
+
+ +
+ + {passkeysSupported === false ? ( +

Passkeys are not enabled for this account.

+ ) : ( + <> +
+
+ + setNewPasskeyNickname(event.target.value)} + disabled={registeringPasskey} + /> +
+
+ +
+
+ + {passkeysLoading && !hasPasskeys ? ( +

Loading passkeys…

+ ) : null} + + {!passkeysLoading && !hasPasskeys ? ( +

No passkeys registered yet.

+ ) : null} + + {hasPasskeys ? ( + + + + + + + + + + + + + {passkeys.map((passkey) => { + const createdAt = passkey.created_at || passkey.createdAt; + const lastUsedAt = passkey.last_used_at || passkey.lastUsedAt; + const revokedAt = passkey.revoked_at || passkey.revokedAt; + const revokedReason = passkey.revoked_reason || passkey.revokedReason; + const revoked = Boolean(revokedAt); + const transports = Array.isArray(passkey.transports) + ? passkey.transports.filter(Boolean) + : []; + + return ( + + + + + + + + + ); + })} + +
NicknameCreatedLast usedTransportsStatusActions
{passkey.nickname || '—'}{formatDateTime(createdAt)}{formatDateTime(lastUsedAt)}{transports.length ? transports.join(', ') : '—'} + {revoked + ? revokedReason + ? `Revoked (${revokedReason})` + : 'Revoked' + : 'Active'} + + {revoked ? ( + Revoked + ) : ( + + )} +
+ ) : null} + + )} +
+ ); +}; + +export const PASSKEYS_SECTION = { + id: 'passkeys', + label: 'Passkeys', + component: PasskeysSection, +}; + +export default PasskeysSection; diff --git a/frontend/src/settings/sections/index.js b/frontend/src/settings/sections/index.js new file mode 100644 index 0000000..f0bad0d --- /dev/null +++ b/frontend/src/settings/sections/index.js @@ -0,0 +1,15 @@ +import PasskeysSection, { PASSKEYS_SECTION } from './PasskeysSection'; +import ApiTokensSection, { API_TOKENS_SECTION } from './ApiTokensSection'; +import CapabilitySetsSection, { CAPABILITY_SETS_SECTION } from './CapabilitySetsSection'; + +export const DEFAULT_SETTINGS_SECTIONS = [ + PASSKEYS_SECTION, + API_TOKENS_SECTION, + CAPABILITY_SETS_SECTION, +]; + +export { + PasskeysSection, + ApiTokensSection, + CapabilitySetsSection, +}; diff --git a/frontend/src/settings/useApiTokens.js b/frontend/src/settings/useApiTokens.js index 0b95506..c9d62f2 100644 --- a/frontend/src/settings/useApiTokens.js +++ b/frontend/src/settings/useApiTokens.js @@ -6,9 +6,7 @@ const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }) => { const [creating, setCreating] = useState(false); const [deletingId, setDeletingId] = useState(null); const [regeneratingId, setRegeneratingId] = useState(null); - const [updatingId, setUpdatingId] = useState(null); const [createdSecret, setCreatedSecret] = useState(null); - const refresh = useCallback(async () => { if (!token) { return; @@ -25,10 +23,13 @@ const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }) => { }, [api, notifyApiError, token]); const create = useCallback( - async ({ label, expires_at, capabilities } = {}) => { + async ({ label, expires_at, capability_set_id } = {}) => { if (creating) { return false; } + if (!capability_set_id) { + return false; + } setCreating(true); try { const payload = {}; @@ -38,9 +39,7 @@ const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }) => { if (expires_at) { payload.expires_at = expires_at; } - if (Array.isArray(capabilities) && capabilities.length > 0) { - payload.capabilities = capabilities; - } + payload.capability_set_id = capability_set_id; const { data } = await api.post('/profile/api-tokens', payload); if (data?.token_info) { @@ -132,46 +131,6 @@ const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }) => { [api, notifyApiError, refresh, setStatusMessage], ); - const updateCapabilities = useCallback( - async (tokenId, capabilities) => { - if (!tokenId) { - return false; - } - setUpdatingId(tokenId); - try { - const { data } = await api.patch(`/profile/api-tokens/${tokenId}`, { - capabilities, - }); - if (data) { - setTokens((previous) => { - let found = false; - const next = previous.map((entry) => { - if (entry.id === data.id) { - found = true; - return data; - } - return entry; - }); - if (!found) { - return [data, ...previous]; - } - return next; - }); - } else { - await refresh(); - } - setStatusMessage?.('API token updated.', 'success'); - return true; - } catch (error) { - notifyApiError?.(error, 'Failed to update API token.'); - return false; - } finally { - setUpdatingId(null); - } - }, - [api, notifyApiError, refresh, setStatusMessage], - ); - const dismissSecret = useCallback(() => { setCreatedSecret(null); }, []); @@ -182,13 +141,11 @@ const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }) => { creating, deletingId, regeneratingId, - updatingId, createdSecret, refresh, create, revoke, regenerate, - updateCapabilities, dismissSecret, }; }; diff --git a/frontend/src/settings/useCapabilities.js b/frontend/src/settings/useCapabilities.js new file mode 100644 index 0000000..548747c --- /dev/null +++ b/frontend/src/settings/useCapabilities.js @@ -0,0 +1,43 @@ +import { useCallback, useEffect, useState } from 'react'; + +const useCapabilities = ({ api, notifyApiError, token }) => { + const [capabilities, setCapabilities] = useState([]); + const [capabilitiesLoading, setCapabilitiesLoading] = useState(false); + + const refreshCapabilities = useCallback(async () => { + if (!token) { + setCapabilities([]); + return; + } + setCapabilitiesLoading(true); + try { + const { data } = await api.get('/capabilities'); + if (Array.isArray(data)) { + setCapabilities(data); + } else { + setCapabilities([]); + } + } catch (error) { + notifyApiError?.(error, 'Failed to load capabilities.'); + setCapabilities([]); + } finally { + setCapabilitiesLoading(false); + } + }, [api, notifyApiError, token]); + + useEffect(() => { + if (token) { + refreshCapabilities(); + } else { + setCapabilities([]); + } + }, [refreshCapabilities, token]); + + return { + capabilities, + capabilitiesLoading, + refreshCapabilities, + }; +}; + +export default useCapabilities; diff --git a/frontend/src/settings/useCapabilitySets.js b/frontend/src/settings/useCapabilitySets.js new file mode 100644 index 0000000..240fb99 --- /dev/null +++ b/frontend/src/settings/useCapabilitySets.js @@ -0,0 +1,203 @@ +import { useCallback, useEffect, useState } from 'react'; + +const useCapabilitySets = ({ api, notifyApiError, setStatusMessage, token }) => { + const [capabilitySets, setCapabilitySets] = useState([]); + const [capabilitySetsLoading, setCapabilitySetsLoading] = useState(false); + const [creatingCapabilitySet, setCreatingCapabilitySet] = useState(false); + const [savingCapabilitySetId, setSavingCapabilitySetId] = useState(null); + const [deletingCapabilitySetId, setDeletingCapabilitySetId] = useState(null); + const [supportsCapabilitySetLabels, setSupportsCapabilitySetLabels] = useState(false); + + const applyCapabilitySets = useCallback((updater) => { + setCapabilitySets((previous) => { + const base = Array.isArray(previous) ? [...previous] : []; + const next = typeof updater === 'function' + ? updater(base) + : (Array.isArray(updater) ? [...updater] : base); + const supportsLabels = next.some((item) => Object.prototype.hasOwnProperty.call(item || {}, 'label')); + setSupportsCapabilitySetLabels(supportsLabels); + return next; + }); + }, []); + + const refreshCapabilitySets = useCallback(async () => { + if (!token) { + applyCapabilitySets([]); + return; + } + setCapabilitySetsLoading(true); + try { + const { data } = await api.get('/capability-sets'); + applyCapabilitySets(Array.isArray(data) ? data : []); + } catch (error) { + notifyApiError?.(error, 'Failed to load capability sets.'); + } finally { + setCapabilitySetsLoading(false); + } + }, [api, applyCapabilitySets, notifyApiError, token]); + + useEffect(() => { + if (token) { + refreshCapabilitySets(); + } else { + applyCapabilitySets([]); + } + }, [applyCapabilitySets, refreshCapabilitySets, token]); + + const createCapabilitySet = useCallback( + async ({ slug, label, capabilities } = {}) => { + if (creatingCapabilitySet) { + return false; + } + if (!Array.isArray(capabilities) || capabilities.length === 0) { + setStatusMessage?.('Select at least one capability.', 'error'); + return false; + } + setCreatingCapabilitySet(true); + try { + const payload = { + capabilities, + }; + const trimmedSlug = slug?.trim(); + if (trimmedSlug) { + payload.slug = trimmedSlug; + } + const trimmedLabel = label?.trim(); + if (trimmedLabel && supportsCapabilitySetLabels) { + payload.label = trimmedLabel; + } + + const { data } = await api.post('/capability-sets', payload); + if (data) { + applyCapabilitySets((previous) => { + const next = previous.filter((entry) => entry?.id !== data.id); + next.push(data); + next.sort((a, b) => a.slug.localeCompare(b.slug)); + return next; + }); + } else { + await refreshCapabilitySets(); + } + setStatusMessage?.('Capability set created.', 'success'); + return data; + } catch (error) { + notifyApiError?.(error, 'Failed to create capability set.'); + return false; + } finally { + setCreatingCapabilitySet(false); + } + }, + [ + api, + applyCapabilitySets, + creatingCapabilitySet, + notifyApiError, + refreshCapabilitySets, + setStatusMessage, + supportsCapabilitySetLabels, + ], + ); + + const updateCapabilitySet = useCallback( + async (capabilitySetId, { slug, label, capabilities } = {}) => { + if (!capabilitySetId) { + return false; + } + setSavingCapabilitySetId(capabilitySetId); + try { + const payload = {}; + if (slug !== undefined) { + const trimmed = slug?.trim(); + if (trimmed) { + payload.slug = trimmed; + } else if (slug === '') { + payload.slug = ''; + } + } + if (label !== undefined && supportsCapabilitySetLabels) { + const trimmed = label?.trim(); + if (trimmed) { + payload.label = trimmed; + } else if (label === '') { + payload.label = ''; + } + } + if (Array.isArray(capabilities)) { + payload.capabilities = capabilities; + } + + const { data } = await api.patch(`/capability-sets/${capabilitySetId}`, payload); + if (data) { + applyCapabilitySets((previous) => { + let found = false; + const next = previous.map((entry) => { + if (entry?.id === data.id) { + found = true; + return data; + } + return entry; + }); + if (!found) { + next.push(data); + } + next.sort((a, b) => a.slug.localeCompare(b.slug)); + return next; + }); + } else { + await refreshCapabilitySets(); + } + setStatusMessage?.('Capability set updated.', 'success'); + return true; + } catch (error) { + notifyApiError?.(error, 'Failed to update capability set.'); + return false; + } finally { + setSavingCapabilitySetId(null); + } + }, + [ + api, + applyCapabilitySets, + notifyApiError, + refreshCapabilitySets, + setStatusMessage, + supportsCapabilitySetLabels, + ], + ); + + const deleteCapabilitySet = useCallback( + async (capabilitySetId) => { + if (!capabilitySetId) { + return false; + } + setDeletingCapabilitySetId(capabilitySetId); + try { + await api.delete(`/capability-sets/${capabilitySetId}`); + applyCapabilitySets((previous) => previous.filter((entry) => entry?.id !== capabilitySetId)); + setStatusMessage?.('Capability set deleted.', 'success'); + return true; + } catch (error) { + notifyApiError?.(error, 'Failed to delete capability set.'); + return false; + } finally { + setDeletingCapabilitySetId(null); + } + }, + [api, applyCapabilitySets, notifyApiError, setStatusMessage], + ); + + return { + capabilitySets, + capabilitySetsLoading, + creatingCapabilitySet, + savingCapabilitySetId, + deletingCapabilitySetId, + supportsCapabilitySetLabels, + refreshCapabilitySets, + createCapabilitySet, + updateCapabilitySet, + deleteCapabilitySet, + }; +}; + +export default useCapabilitySets; diff --git a/frontend/src/sidebar/Sidebar.jsx b/frontend/src/sidebar/Sidebar.jsx index 62037bc..ac576af 100644 --- a/frontend/src/sidebar/Sidebar.jsx +++ b/frontend/src/sidebar/Sidebar.jsx @@ -165,6 +165,7 @@ const Sidebar = ({ onCreateFolder, creatingFolder = false, tags = [], + untaggedFilterId = null, activeTagIds = [], onToggleTagFilter, correspondents = [], @@ -215,6 +216,7 @@ const Sidebar = ({ ); const handleToggleTag = onToggleTagFilter || (() => {}); const activeTagSet = new Set(activeTagIds); + const untaggedActive = untaggedFilterId ? activeTagSet.has(untaggedFilterId) : false; const handleManageTags = onManageTags || (() => {}); const handleManageCorrespondents = onManageCorrespondents || (() => {}); const handleCreateTag = useCallback(async () => { @@ -619,6 +621,20 @@ const Sidebar = ({ }`} role="list" > + {untaggedFilterId ? ( + + ) : null} {tags.map((tag) => { const isActive = activeTagSet.has(tag.id); const style = getTagColorStyle(tag.color); diff --git a/frontend/src/sidebar/useSidebarProps.js b/frontend/src/sidebar/useSidebarProps.js new file mode 100644 index 0000000..dd38f75 --- /dev/null +++ b/frontend/src/sidebar/useSidebarProps.js @@ -0,0 +1,117 @@ +import { useMemo } from 'react'; +import { TAG_FILTER_UNTAGGED } from '../app/appLayoutUtils'; + +const useSidebarProps = ({ + folderNodes, + folderClickHandlers, + handleFolderDelete, + handleFolderRename, + selectedFolder, + handleFolderDragStart, + handleFolderDragEnd, + draggedFolderId, + handlePromptCreateFolder, + creatingFolder, + tags, + activeTagFilters, + toggleTagFilter, + handleTagCreate, + correspondents, + activeCorrespondentFilters, + toggleCorrespondentFilter, + handleCorrespondentCreate, + appStatus, + loading, + previewActive, + searchQuery, + handleSearchChange, + handleSearchSubmit, + clearFilters, + isFilterActive, + handleLogout, + status, + tenantName, + tenantOptions, + currentTenantId, + handleTenantSelect, + openSettings, +}) => + useMemo( + () => ({ + folderNodes, + onToggle: folderClickHandlers.onToggle, + onSelect: folderClickHandlers.onSelect, + onDrop: folderClickHandlers.onDrop, + onDragOver: folderClickHandlers.onDragOver, + onDragLeave: folderClickHandlers.onDragLeave, + onDeleteFolder: handleFolderDelete, + onRenameFolder: handleFolderRename, + selectedFolder, + onFolderDragStart: handleFolderDragStart, + onFolderDragEnd: handleFolderDragEnd, + draggedFolderId, + onCreateFolder: handlePromptCreateFolder, + creatingFolder, + tags, + untaggedFilterId: TAG_FILTER_UNTAGGED, + activeTagIds: activeTagFilters, + onToggleTagFilter: toggleTagFilter, + onCreateTag: (label) => handleTagCreate({ label }), + correspondents, + activeCorrespondentIds: activeCorrespondentFilters, + onToggleCorrespondentFilter: toggleCorrespondentFilter, + onCreateCorrespondent: (name) => handleCorrespondentCreate({ name }), + appStatus, + loading, + previewActive, + searchQuery, + onSearchChange: handleSearchChange, + onSearchSubmit: handleSearchSubmit, + onSearchClear: clearFilters, + isFilterActive, + onLogout: handleLogout, + status, + tenantName, + tenants: tenantOptions, + activeTenantId: currentTenantId, + onSelectTenant: handleTenantSelect, + onOpenSettings: openSettings, + }), + [ + activeCorrespondentFilters, + activeTagFilters, + appStatus, + clearFilters, + correspondents, + creatingFolder, + currentTenantId, + folderClickHandlers, + folderNodes, + handleCorrespondentCreate, + handleFolderDragEnd, + handleFolderDragStart, + handleFolderDelete, + handleFolderRename, + handleLogout, + handlePromptCreateFolder, + handleSearchChange, + handleSearchSubmit, + handleTagCreate, + handleTenantSelect, + isFilterActive, + loading, + openSettings, + previewActive, + searchQuery, + draggedFolderId, + selectedFolder, + status, + tags, + tenantName, + tenantOptions, + toggleCorrespondentFilter, + toggleTagFilter, + ], + ); + +export default useSidebarProps; diff --git a/frontend/src/styles.css b/frontend/src/styles.css index a72ccce..5687227 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -266,6 +266,10 @@ body { overflow: hidden; } +body.has-main-content { + background: var(--surface); +} + a { color: var(--accent); text-decoration: underline; @@ -323,7 +327,6 @@ a.button-link { a.button-link[aria-disabled='true'] { opacity: 0.55; - pointer-events: none; } a.button-link:hover:not([aria-disabled='true']) { @@ -393,7 +396,7 @@ button.danger:hover:not([disabled]) { .panel-header button, .panel-header a.icon-button { display: inline-flex; - align-items: flex-start; + align-items: center; justify-content: flex-start; border: none; background: transparent; @@ -413,6 +416,13 @@ button.danger:hover:not([disabled]) { color: var(--fg); } +.panel-header .icon-button.active:hover:not([disabled]), +.panel-header button.active:hover:not([disabled]), +.panel-header a.icon-button.active:hover { + background: var(--accent-soft); + color: var(--fg); +} + .panel-header .icon-button.ghost, .panel-header button.icon-button.ghost { color: var(--muted); @@ -434,7 +444,7 @@ button.danger:hover:not([disabled]) { align-items: center; justify-content: center; padding: 2rem; - z-index: 3000; + z-index: 2000000; cursor: zoom-out; opacity: 0; pointer-events: none; @@ -453,7 +463,9 @@ button.danger:hover:not([disabled]) { justify-content: center; max-width: 95vw; max-height: 95vh; + z-index: 3000000; } + .preview-zoom__image { max-width: 95vw; max-height: 95vh; @@ -555,6 +567,16 @@ button.danger:hover:not([disabled]) { justify-content: center; } +@keyframes icon-spin { + to { + transform: rotate(360deg); + } +} + +.icon--spin { + animation: icon-spin 0.9s linear infinite; +} + .icon--flip-y { transform: scaleX(-1); } @@ -616,6 +638,80 @@ button.danger:hover:not([disabled]) { height: 1.4rem; } +.documents-actions__sort-group { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.documents-sort { + display: inline-flex; + align-items: center; + position: relative; +} + +.documents-sort__trigger { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-size: 0.85rem; + white-space: nowrap; + padding: 0.25rem 0.5rem; + min-height: 2.1rem; +} + +.documents-sort__label { + display: inline-flex; + align-items: center; + line-height: 1.1; +} + +.documents-sort__trigger-content { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.documents-sort__quickmenu .menu__item, +.documents-sort__quickmenu .menu__item.active { + font-weight: 400; +} + +.documents-toolbar__toggle { + border: 1px solid var(--border); + border-radius: 4px; + padding: 0.3rem; + background: transparent; + color: var(--muted); + transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; +} + +.documents-toolbar__toggle:hover:not([disabled]) { + color: var(--fg); + border-color: var(--border); +} + +.documents-toolbar__toggle[aria-pressed='true'] { + border-color: var(--accent); + color: var(--accent); + background: var(--surface-subtle); +} + +.documents-sort__direction { + padding: 0.3rem 0.45rem; +} + +.documents-sort__direction[aria-pressed='true'] { + border-color: var(--border); + color: var(--muted); + background: transparent; +} + +.documents-sort__direction svg { + width: 1.1rem; + height: 1.1rem; +} + .app-shell { height: 100%; display: flex; @@ -659,6 +755,10 @@ button.danger:hover:not([disabled]) { gap: 0.75rem; } +.main-content__header-wrapper { + position: relative; +} + .main-content__body { position: relative; flex: 1 1 auto; @@ -764,7 +864,7 @@ button.danger:hover:not([disabled]) { flex: 1; display: grid; grid-template-columns: minmax(0, 30em) minmax(0, 1fr); - gap: 1.5rem; + gap: 1rem; min-height: 0; padding: 1rem 1rem; } @@ -873,7 +973,14 @@ button.danger:hover:not([disabled]) { gap: 0.5rem; min-height: 0; flex: 1; - overflow: hidden; +} + +.document-viewer__details-pane { + display: flex; + flex-direction: column; + min-height: 0; + overflow: auto; + flex: 1; } .document-viewer__tabs-wrapper { display: flex; @@ -887,7 +994,6 @@ button.danger:hover:not([disabled]) { flex-direction: column; flex: 1; min-height: 0; - overflow: auto; padding-top: 1rem; } @@ -1002,6 +1108,11 @@ button.danger:hover:not([disabled]) { display: flex; } +.document-viewer__tabpanes--single { + flex: 1; + min-height: 0; +} + .document-viewer__tabpanel { flex: 1; min-height: 0; @@ -1018,7 +1129,6 @@ button.danger:hover:not([disabled]) { width: 100%; height: 100%; margin: 0; - overflow: auto; padding: 1rem 0; font-size: 1rem; white-space: pre-wrap; @@ -1519,6 +1629,7 @@ button.danger:hover:not([disabled]) { width: 20em; max-width: 20em; flex: 0 0 20em; + background: var(--bg); } .sidebar__body { @@ -1924,6 +2035,16 @@ button.danger:hover:not([disabled]) { box-shadow: 0 0 0 1.5px var(--sidebar-active-pill-border); } +.sidebar-tag-pill--untagged { + border: 1px dashed var(--border); + background: var(--surface-subtle); + color: var(--muted); +} + +.sidebar-tag-pill--untagged.active { + color: var(--fg); +} + .sidebar-tag-cloud--has-active .sidebar-tag-pill:not(.active) { opacity: 0.45; } @@ -1989,6 +2110,7 @@ button.danger:hover:not([disabled]) { display: flex; align-items: center; justify-content: space-between; + position: relative; } .documents-panel .panel-section__header .header-actions { @@ -2019,6 +2141,100 @@ button.danger:hover:not([disabled]) { gap: 0.5rem; } +.panel-floating { + position: absolute; + top: calc(50% + 0.25rem); + left: 50%; + transform: translate(-50%, -50%); + background: color-mix(in oklch, var(--surface) 100%, transparent); + border: 1px solid color-mix(in oklch, var(--border) 95%, transparent); + padding: 0.45rem 0.85rem; + border-radius: 1rem; + font-size: 0.95rem; + font-weight: 400; + color: var(--fg); + box-shadow: 0 2px 6px color-mix(in oklch, var(--shadow-soft) 60%, transparent); + pointer-events: auto; + display: flex; + align-items: center; + justify-content: center; + flex-wrap: nowrap; + gap: 0.75rem; + z-index: 2000000; +} + +.panel-floating__label { + white-space: nowrap; + pointer-events: none; + font-size: 0.95rem; + color: var(--fg); +} + +.selection-summary { + display: inline-flex; + align-items: center; + gap: 0.4rem; +} + +.selection-summary__token { + display: inline-flex; + align-items: center; + gap: 0.3rem; +} + +.selection-summary__count { + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.selection-summary__icon { + width: 1rem; + height: 1rem; +} + +.selection-summary--text { + font-weight: 600; +} + +.selection-summary__separator { + opacity: 0.45; +} + +.panel-floating-actions { + display: inline-flex; + align-items: center; + gap: 0.4rem; + flex-wrap: nowrap; + pointer-events: auto; +} + +.panel-floating-actions .quick-add { + pointer-events: auto; +} + +.panel-floating-actions .quick-add__trigger { + pointer-events: auto; +} + +.panel-floating-actions .quick-add__trigger[disabled] { + opacity: 0.45; + cursor: not-allowed; +} + +.panel-floating-actions__button { + display: inline-flex; + align-items: center; + gap: 0; + pointer-events: auto; + font-size: 1.35rem; +} + +.panel-floating-actions__button .icon-inline { + display: inline-flex; + width: 1.35rem; + height: 1.35rem; +} + .documents-panel .documents-scroll { overflow-y: auto; background: transparent; @@ -2168,6 +2384,43 @@ button.danger:hover:not([disabled]) { width: 100%; } +.doc-title-edit { + display: inline-flex; + align-items: center; + gap: 0.35rem; + flex-wrap: nowrap; +} + +.doc-title-edit input[type='text'] { + padding: 0.3rem 0.55rem; + border-radius: 4px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--fg); + min-width: 8rem; +} + +.doc-title-edit input[type='text']:focus-visible { + outline: 2px solid var(--selection-ring); + outline-offset: 1px; +} + +.doc-title-edit .icon-button { + flex-shrink: 0; +} + +.documents-panel .doc-name__primary { + display: inline-flex; + align-items: center; + gap: 0.35rem; + flex-wrap: wrap; +} + +.documents-panel .doc-name__primary-text { + overflow-wrap: anywhere; +} + + .doc-entry { display: flex; align-items: center; @@ -2294,16 +2547,35 @@ button.danger:hover:not([disabled]) { } .document-card__title { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.35rem; color: var(--fg); +} + +.document-card__title-row { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + flex-wrap: wrap; +} + +.document-card__title-badge { padding: 0.2rem 0.7rem; border-radius: 1rem; max-width: 100%; word-break: break-word; font-size: var(--documents-grid-title-size); + color: inherit; } -.document-card.selected .document-card__title, -.folder-card.selected .folder-card__name { +.document-card .doc-correspondent-link { + font-size: var(--documents-grid-title-size); +} + +.document-card.selected .document-card__title-badge { background-color: var(--accent); color: var(--on-accent); } @@ -2346,6 +2618,14 @@ button.danger:hover:not([disabled]) { padding-top: 0.35rem; } +.folder-card__label-row { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + flex-wrap: wrap; +} + .folder-card__name { color: var(--fg); overflow: hidden; @@ -2356,6 +2636,18 @@ button.danger:hover:not([disabled]) { border-radius: 1rem; } +.folder-card__edit { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; +} + +.folder-card.selected .folder-card__name { + background-color: var(--accent); + color: var(--on-accent); +} + .doc-name__title { max-width: 100%; @@ -2539,12 +2831,12 @@ button.danger:hover:not([disabled]) { min-height: 100vh; height: 100%; height: 100%; - background: var(--surface); + background: var(--bg); box-shadow: 0 0 24px var(--shadow-soft); border-left: 1px solid var(--border); display: flex; flex-direction: column; - z-index: 10000000; + z-index: 1000000; } .panel-header { @@ -2595,7 +2887,7 @@ button.danger:hover:not([disabled]) { flex-direction: column; overflow-y: auto; min-height: 0; - padding: 1.25rem; + padding: 0; } .detail-section__header { @@ -2622,6 +2914,11 @@ button.danger:hover:not([disabled]) { white-space: nowrap; } +.documents-sort__trigger.quick-add__trigger { + padding: 0.25rem 0.5rem; + min-height: 2.1rem; +} + .quick-add__chip { display: inline-flex; align-items: center; @@ -2692,9 +2989,168 @@ button.danger:hover:not([disabled]) { background: var(--surface-subtle); } +.selection-assignment { + display: inline-flex; + position: relative; +} + +.selection-assignment__menu { + font-size: 0.95rem; + font-weight: 400; + padding: 0.4rem 0; + max-width: min(22rem, 90vw); +} + +.selection-assignment__header { + padding: 0.4rem 0.75rem 0.3rem; + border-bottom: 1px solid var(--border-subtle); +} + +.selection-assignment__header input { + width: 100%; + padding: 0.35rem 0.6rem; + border: 1px solid var(--border-subtle); + border-radius: 0.5rem; + background: var(--surface-subtle); + color: var(--fg); + font-size: 0.95rem; +} + +.selection-assignment__header input:focus-visible { + outline: none; + border-color: var(--selection-border); + box-shadow: 0 0 0 2px color-mix(in oklch, var(--selection) 25%, transparent); +} + +.selection-assignment__list { + max-height: 240px; + overflow-y: auto; +} + +.selection-assignment__item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + font-weight: 400; +} + +.selection-assignment__label { + flex: 1 1 auto; + display: inline-flex; + align-items: center; + gap: 0.25rem; +} + +.selection-assignment__spinner { + margin-left: 0.4rem; +} + +.selection-assignment__label--nowrap { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.selection-assignment__indent { + display: inline-block; + flex: 0 0 auto; +} + +.selection-assignment__folder-label { + display: inline-flex; + flex-direction: column; + gap: 0.15rem; + min-width: 0; + max-width: 16rem; +} + +.selection-assignment__folder-name { + font-weight: 500; + color: var(--fg); + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.selection-assignment__folder-path { + font-size: 0.8rem; + color: var(--muted); + white-space: normal; + word-break: break-word; +} + +.selection-assignment__slash { + color: var(--muted); + margin: 0 0.25rem; +} + +.selection-assignment__segment { + display: inline-block; +} + +.selection-assignment__item--all .selection-assignment__icon { + color: var(--success); +} + +.selection-assignment__item--partial .selection-assignment__icon { + color: var(--warning); +} + +.selection-assignment__icon { + width: 1rem; + height: 1rem; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.selection-assignment__icon--empty { + border: 1px solid var(--border-subtle); + border-radius: 999px; + opacity: 0.6; +} + +.selection-assignment__label { + flex: 1 1 auto; + min-width: 0; + text-align: left; + font-weight: 400; +} + +.selection-assignment__count { + font-size: 0.8rem; + color: var(--muted); +} + +.selection-assignment__empty { + padding: 0.75rem; +} + +.selection-assignment__create { + border-top: 1px solid var(--border-subtle); + display: flex; + align-items: center; + gap: 0.5rem; +} + .preview-pane { margin-top: 0.4rem; + background: transparent; + display: flex; + flex-direction: column; + gap: 1.25rem; + min-height: 0; + flex: 1; + overflow: auto; + position: relative; + padding: 1.25rem; +} + +.preview-pane__media { border-radius: 0; background: transparent; min-height: 220px; @@ -2705,6 +3161,44 @@ button.danger:hover:not([disabled]) { position: relative; } +.preview-image { + width: 100%; + max-width: 360px; + max-height: 100%; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + pointer-events: auto; + position: relative; +} + +.preview-image__content { + display: block; + width: 100%; + height: auto; + max-width: 100%; + max-height: 100%; + object-fit: contain; + background: transparent; + cursor: pointer; + transition: + outline-color 120ms ease, + box-shadow 120ms ease, + filter 120ms ease, + background-color 120ms ease; + outline: 2px solid transparent; + outline-offset: -2px; +} + +.preview-image__content:hover, +.preview-image__content:focus-visible { + outline-color: var(--accent-focus); + box-shadow: + inset 0 0 0 999px var(--accent-elevated), + 0 6px 18px var(--accent-elevated-strong); +} + .thumbnail-preview { display: flex; flex-direction: column; @@ -2942,33 +3436,6 @@ button.danger:hover:not([disabled]) { height: 80%; } -.preview-stack__image { - display: block; - width: auto; - height: auto; - max-width: 100%; - max-height: 100%; - object-fit: contain; - background: transparent; - cursor: pointer; - transition: - outline-color 120ms ease, - box-shadow 120ms ease, - filter 120ms ease, - background-color 120ms ease; - outline: 2px solid transparent; - outline-offset: -2px; - pointer-events: auto; -} - -.preview-stack__image:hover, -.preview-stack__image:focus-visible { - outline-color: var(--accent-focus); - box-shadow: - inset 0 0 0 999px var(--accent-elevated), - 0 6px 18px var(--accent-elevated-strong); -} - .preview-pane__unsupported { width: 100%; max-width: 320px; @@ -3062,7 +3529,7 @@ button.danger:hover:not([disabled]) { transform: scale(calc(1 / var(--preview-nav-scale, 1))); } -.preview-pane--stack:hover .preview-pane__nav--overlay, +.preview-pane__media:hover .preview-pane__nav--overlay, .desk-item__card:hover .preview-pane__nav--overlay { opacity: 1; } @@ -3291,7 +3758,7 @@ form.inline { } .settings-modal__sidebar button.active { - background: var(--surface-soft); + background: var(--accent-soft); font-weight: 600; } @@ -3334,6 +3801,11 @@ form.inline { min-width: 14rem; } +.settings-form__field--full { + flex: 1 1 100%; + min-width: 100%; +} + fieldset.settings-form__field { border: 1px solid var(--border-muted, var(--border)); border-radius: 0.5rem; @@ -3363,6 +3835,129 @@ fieldset.settings-form__field legend { align-items: flex-start; } +.settings-capability-picker { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.capability-dropdown { + position: relative; + width: 100%; +} + +.capability-dropdown__trigger { + width: 100%; + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border: 1px solid var(--border); + border-radius: 0.45rem; + background: var(--surface-soft); + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + transition: border-color 0.15s ease, background 0.15s ease; +} + +.capability-dropdown__trigger:hover:not(:disabled), +.capability-dropdown__trigger:focus-visible { + border-color: var(--accent); + background: var(--surface); + outline: none; +} + +.capability-dropdown__trigger:disabled { + cursor: not-allowed; + color: var(--muted); + background: var(--surface-muted, var(--surface-soft)); +} + +.capability-dropdown__chevron { + flex-shrink: 0; + opacity: 0.8; +} + +.capability-dropdown__menu { + margin-top: 0.35rem; + max-height: 18rem; + overflow-y: auto; + padding: 0.25rem 0; + width: 100%; +} + +.capability-dropdown__option { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.45rem 0.75rem; +} + +.capability-dropdown__option-icon { + width: 1.1rem; + display: flex; + align-items: center; + justify-content: center; + color: var(--accent); +} + +.capability-dropdown__option:not(.is-selected) .capability-dropdown__option-icon { + color: transparent; +} + +.capability-dropdown__option-label { + flex: 1; + text-align: left; +} + +.capability-dropdown__empty { + padding: 0.6rem 0.8rem; + color: var(--muted); +} + +.settings-capability-picker__chips { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.25rem; +} + +.settings-capability-picker__chips--inline { + margin-top: 0.35rem; +} + +.settings-capabilities-summary { + display: inline-block; + margin-top: 0.4rem; + color: var(--muted); + font-size: 0.9rem; +} + +.settings-capability-picker__placeholder { + color: var(--muted); + font-size: 0.9rem; +} + +.settings-capability-list { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin-top: 0.35rem; +} + +.settings-capability-list--compact { + margin-top: 0.2rem; + gap: 0.25rem; +} + +.settings-capability-list__item { + display: inline-flex; + align-items: center; +} + .settings-choice { display: inline-flex; align-items: center; diff --git a/frontend/src/ui/QuickAddMenu.jsx b/frontend/src/ui/QuickAddMenu.jsx index f8b4dab..f239735 100644 --- a/frontend/src/ui/QuickAddMenu.jsx +++ b/frontend/src/ui/QuickAddMenu.jsx @@ -40,6 +40,9 @@ const QuickAddMenu = ({ menuMinWidth = 220, triggerClassName = 'icon-button quick-add__trigger', triggerContent = null, + disabled = false, + align = 'start', + positionStrategy = 'fixed', }) => { const anchorRef = useRef(null); const inputRef = useRef(null); @@ -57,8 +60,16 @@ const QuickAddMenu = ({ anchorRef, minWidth: menuMinWidth, matchAnchorWidth: false, + align, + positionStrategy, }); + useEffect(() => { + if (disabled && isOpen) { + close(); + } + }, [disabled, isOpen, close]); + useEffect(() => { if (!isOpen) { return undefined; @@ -145,6 +156,7 @@ const QuickAddMenu = ({ onClick={toggle} aria-label={triggerAriaLabel} title={triggerTitle} + disabled={disabled} > {triggerContent ?? } diff --git a/frontend/src/ui/icons.js b/frontend/src/ui/icons.js index cbaeb72..6b4cb25 100644 --- a/frontend/src/ui/icons.js +++ b/frontend/src/ui/icons.js @@ -14,6 +14,9 @@ import { IconWindowMaximize, IconTextScan2, IconFolderPlus, + IconFolder, + IconFolders, + IconFoldersOff, IconRefresh, IconRestore, IconMinusVertical, @@ -30,6 +33,12 @@ import { IconLayoutSidebarLeftExpand, IconLayoutSidebarRightCollapse, IconInfoCircle, + IconCircleDashedCheck, + IconFile, + IconLoader, + IconSortAscendingLetters, + IconSortDescendingLetters, + IconFileInfo, } from '@tabler/icons-react'; import FolderSvg from '../assets/folder.svg'; @@ -168,6 +177,15 @@ export const InfoIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => /> ); +export const FileInfoIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( + +); + export const DetailPanelCollapseIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( ); +export const FoldersIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( + +); + +export const FoldersOffIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( + +); + export const RefreshIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( ( + +); + +export const SortDescendingLettersIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( + +); + export const IconX = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( /> ); +export const CircleDashedCheckIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( + +); + +export const FileIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( + +); + +export const FolderOutlineIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( + +); + export const AnalyzeIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( ); +export const LoaderIcon = ({ className, size = '1em', stroke = 1.8, ...rest }) => ( + +); + export const WindowMaximizeIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( { return null; } const style = { - position: 'fixed', + position: metrics.strategy === 'absolute' ? 'absolute' : 'fixed', top: metrics.top, left: metrics.left, minWidth: metrics.minWidth, @@ -45,6 +45,7 @@ const useFloatingMenu = ({ align = 'start', viewportMargin = DEFAULT_VIEWPORT_MARGIN, onOpenChange, + positionStrategy = 'fixed', } = {}) => { const menuRef = useRef(null); const [menuMetrics, setMenuMetrics] = useState(null); @@ -63,22 +64,64 @@ const useFloatingMenu = ({ const rect = anchor.getBoundingClientRect(); const desiredWidth = computeWidth(rect.width, minWidth, matchAnchorWidth); + const menu = menuRef.current; + const measuredWidth = menu?.offsetWidth ?? desiredWidth; + const widthForAlignment = matchAnchorWidth ? desiredWidth : Math.max(desiredWidth, measuredWidth); + + if (positionStrategy === 'absolute') { + const anchor = anchorRef?.current; + if (!anchor) { + return false; + } + const offsetParent = (menu && menu.offsetParent) || anchor.offsetParent || anchor.parentElement; + if (!offsetParent) { + // Fall back to fixed positioning if we cannot resolve a relative parent. + setMenuMetrics({ + strategy: 'fixed', + top: rect.bottom + offset, + left: rect.left, + minWidth: desiredWidth, + width: matchAnchorWidth ? desiredWidth : undefined, + }); + return true; + } + + let left; + if (align === 'end') { + left = anchor.offsetLeft + anchor.offsetWidth - widthForAlignment; + } else if (align === 'center') { + left = anchor.offsetLeft + anchor.offsetWidth / 2 - widthForAlignment / 2; + } else { + left = anchor.offsetLeft; + } + + const top = anchor.offsetTop + anchor.offsetHeight + offset; + + setMenuMetrics({ + strategy: 'absolute', + top, + left, + minWidth: desiredWidth, + width: matchAnchorWidth ? desiredWidth : undefined, + }); + return true; + } + const viewportWidth = resolveViewportWidth(); const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 0; const safeMargin = viewportMargin ?? DEFAULT_VIEWPORT_MARGIN; - const menu = menuRef.current; const menuHeight = menu?.offsetHeight ?? 0; let left; if (align === 'end') { - left = rect.right - desiredWidth; + left = rect.right - widthForAlignment; } else if (align === 'center') { - left = rect.left + rect.width / 2 - desiredWidth / 2; + left = rect.left + rect.width / 2 - widthForAlignment / 2; } else { left = rect.left; } - const maxLeft = viewportWidth > 0 ? viewportWidth - desiredWidth - safeMargin : left; + const maxLeft = viewportWidth > 0 ? viewportWidth - widthForAlignment - safeMargin : left; const clampedLeft = viewportWidth > 0 ? clamp(left, safeMargin, Math.max(maxLeft, safeMargin)) : left; let top = rect.bottom + offset; @@ -91,6 +134,7 @@ const useFloatingMenu = ({ } setMenuMetrics({ + strategy: 'fixed', top, left: clampedLeft, minWidth: desiredWidth, @@ -98,7 +142,15 @@ const useFloatingMenu = ({ }); return true; - }, [anchorRef, align, matchAnchorWidth, minWidth, offset, viewportMargin]); + }, [ + anchorRef, + align, + matchAnchorWidth, + minWidth, + offset, + positionStrategy, + viewportMargin, + ]); const close = useCallback(() => { setIsOpen((prev) => { 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; diff --git a/frontend/tests/workspaceEngine.test.js b/frontend/tests/workspaceEngine.test.js new file mode 100644 index 0000000..5f5d317 --- /dev/null +++ b/frontend/tests/workspaceEngine.test.js @@ -0,0 +1,70 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + WorkspaceEngine, + DESK_CANVAS_PADDING, +} = require('../src/desktop/workspaceEngine.js'); + +const makeEngine = () => { + const engine = new WorkspaceEngine(); + engine.setEnsureDocumentSize(() => ({ width: 200, height: 200 })); + engine.setCanvasSize({ width: 1200, height: 800 }); + return engine; +}; + +test('syncLayoutSnapshot clones from layout map', () => { + const engine = makeEngine(); + engine.setItems([{ id: 'doc-1' }]); + engine.ensureLayoutForItems(); + engine.syncLayoutSnapshot(); + + const firstSnapshot = engine.getSnapshot(); + assert(firstSnapshot.layout instanceof Map); + assert(firstSnapshot.layout.get('doc-1')); + + engine.layout.set('doc-1', { + centerX: DESK_CANVAS_PADDING + 150, + centerY: DESK_CANVAS_PADDING + 150, + rotation: 0, + width: 200, + height: 200, + }); + engine.syncLayoutSnapshot(); + + const secondSnapshot = engine.getSnapshot(); + assert.notStrictEqual(secondSnapshot.layout, engine.layout); + assert.equal(secondSnapshot.layout.get('doc-1').centerX, engine.layout.get('doc-1').centerX); +}); + +test('recalcVisibleDocIds respects viewport bounds', () => { + const engine = makeEngine(); + engine.items = [{ id: 'visible' }, { id: 'hidden' }]; + engine.setDocumentLookup(new Map([ + ['visible', { id: 'visible' }], + ['hidden', { id: 'hidden' }], + ])); + + engine.layout = new Map([ + ['visible', { + centerX: DESK_CANVAS_PADDING + 150, + centerY: DESK_CANVAS_PADDING + 150, + rotation: 0, + width: 200, + height: 200, + }], + ['hidden', { + centerX: -500, + centerY: -500, + rotation: 0, + width: 200, + height: 200, + }], + ]); + engine.layoutSnapshot = new Map(engine.layout); + + engine.recalcVisibleDocIds(); + + const snapshot = engine.getSnapshot(); + assert(snapshot.visibleDocIds.has('visible')); + assert(!snapshot.visibleDocIds.has('hidden')); +});