diff --git a/frontend/src/DesktopWorkspace.jsx b/frontend/src/DesktopWorkspace.jsx index 4b9bfd0..22452e4 100644 --- a/frontend/src/DesktopWorkspace.jsx +++ b/frontend/src/DesktopWorkspace.jsx @@ -34,6 +34,84 @@ 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 readTransferData = (dataTransfer, mimeTypes) => { if (!dataTransfer) { return null; @@ -866,84 +944,6 @@ const DesktopWorkspace = ({ }, [resolvePreviewDimensions], ); -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 recalcVisibleDocIds = useCallback(() => { const layoutMap = layoutRef.current; const canvasWidth = canvasSize.width || DEFAULT_CANVAS_WIDTH; @@ -1069,7 +1069,6 @@ const recalcVisibleDocIds = useCallback(() => { canvasSize.height, ensureDocumentSize, documentLookup, - docSizeVersion, ]); const syncLayoutSnapshot = useCallback(() => { @@ -1080,7 +1079,8 @@ const syncLayoutSnapshot = useCallback(() => { const container = containerRef.current; if (!container) return () => {}; - if (process.env.NODE_ENV !== 'production') { + const nodeEnv = typeof globalThis !== 'undefined' ? globalThis.process?.env?.NODE_ENV : undefined; + if (nodeEnv !== 'production') { console.log('[skeuo] canvas element', container); } @@ -1207,7 +1207,7 @@ const syncLayoutSnapshot = useCallback(() => { useEffect(() => { recalcVisibleDocIds(); - }, [recalcVisibleDocIds, items.length, canvasSize.width, canvasSize.height]); + }, [recalcVisibleDocIds, items.length, canvasSize.width, canvasSize.height, docSizeVersion]); useEffect(() => { if (draggingId && !items.some((doc) => doc.id === draggingId)) { @@ -1849,16 +1849,12 @@ const DesktopWorkspaceView = () => { closeOverlay, overlayOriginRect, overlayOriginTransform, - docSizeVersion, } = useDesktopContext(); const { handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel } = useDocumentDrag(); - const allSizesReady = useMemo( - () => items.every((doc) => ensureDocumentSize(doc)), - [items, ensureDocumentSize, docSizeVersion], - ); + const allSizesReady = items.every((doc) => ensureDocumentSize(doc)); return ( <> diff --git a/frontend/src/desktop/useDocumentDrag.js b/frontend/src/desktop/useDocumentDrag.js index f1077e7..3a90dd4 100644 --- a/frontend/src/desktop/useDocumentDrag.js +++ b/frontend/src/desktop/useDocumentDrag.js @@ -196,6 +196,7 @@ const useDocumentDrag = () => { recalcVisibleDocIds(); }, [ + bringToFront, canvasPadding, canvasSize.height, canvasSize.width, diff --git a/frontend/src/detail/DetailPanel.jsx b/frontend/src/detail/DetailPanel.jsx index 62adf0f..bee89fd 100644 --- a/frontend/src/detail/DetailPanel.jsx +++ b/frontend/src/detail/DetailPanel.jsx @@ -828,11 +828,13 @@ const DetailPanel = ({ warmNavigator(stackPreviewNavigator); }, [ ensureAssetUrl, + singlePreviewNavigator, singlePreviewNavigator.documentId, singlePreviewNavigator.asset, singlePreviewNavigator.ordinal, singlePreviewNavigator.canGoPrev, singlePreviewNavigator.canGoNext, + stackPreviewNavigator, stackPreviewNavigator.documentId, stackPreviewNavigator.asset, stackPreviewNavigator.ordinal, diff --git a/frontend/src/documents/DocumentsTable.jsx b/frontend/src/documents/DocumentsTable.jsx index a82d973..7729e75 100644 --- a/frontend/src/documents/DocumentsTable.jsx +++ b/frontend/src/documents/DocumentsTable.jsx @@ -172,7 +172,7 @@ const DocumentThumbnailImage = ({ } else { delete node.dataset.thumbnailAspect; } - }, [aspectRatio]); + }, [aspectRatio, visibilityRef]); return (
diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index 58ea483..7cd88f0 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -291,6 +291,90 @@ const resolveFolderRowKey = (folderId) => 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.created_at === rhs.created_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, @@ -591,89 +675,6 @@ const AppLayout = () => { return getAssetFromVersion(doc.current_version || null, type); }, []); - 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.created_at === rhs.created_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 bootstrapInitializedRef = useRef(false); const selectionInitializedRef = useRef(false); const dragCounterRef = useRef(0); @@ -1557,7 +1558,7 @@ const AppLayout = () => { return enriched; }, - [api, assetManager, folderContents], + [assetManager, folderContents], ); const isInvalidFolderDrop = useCallback( @@ -1681,7 +1682,15 @@ const AppLayout = () => { } } }, - [api, folderNodes, ensureFolderData, selectedFolder, setSelectedFolder, setFolderNodes, notifyApiError], + [ + folderNodes, + ensureFolderData, + selectedFolder, + setSelectedFolder, + setFolderNodes, + notifyApiError, + setStatusMessage, + ], ); const refreshTags = useCallback(async () => { @@ -1698,7 +1707,7 @@ const AppLayout = () => { } notifyApiError(error, 'Unable to load tags.'); } - }, [api, notifyApiError]); + }, [notifyApiError]); const refreshCorrespondents = useCallback(async () => { const requestTenantId = tenantIdRef.current; @@ -1714,7 +1723,7 @@ const AppLayout = () => { } notifyApiError(error, 'Unable to load correspondents.'); } - }, [api, notifyApiError]); + }, [notifyApiError]); const refreshWebdavTokens = useCallback(async () => { if (!token) { @@ -1729,7 +1738,7 @@ const AppLayout = () => { } finally { setWebdavTokensLoading(false); } - }, [api, notifyApiError, token]); + }, [notifyApiError, token]); const createWebdavToken = useCallback( async ({ label, expires_at } = {}) => { @@ -1766,7 +1775,7 @@ const AppLayout = () => { setCreatingWebdavToken(false); } }, - [api, creatingWebdavToken, notifyApiError, refreshWebdavTokens, setStatusMessage], + [creatingWebdavToken, notifyApiError, refreshWebdavTokens, setStatusMessage], ); const deleteWebdavToken = useCallback( @@ -1787,7 +1796,7 @@ const AppLayout = () => { setDeletingWebdavTokenId(null); } }, - [api, refreshWebdavTokens, notifyApiError, setStatusMessage], + [refreshWebdavTokens, notifyApiError, setStatusMessage], ); const dismissCreatedWebdavToken = useCallback(() => { @@ -1836,7 +1845,7 @@ const AppLayout = () => { throw new Error(message); } }, - [api, refreshTags, notifyApiError, setStatusMessage], + [refreshTags, notifyApiError, setStatusMessage], ); const handleTagCreate = useCallback( @@ -1852,7 +1861,7 @@ const AppLayout = () => { throw new Error(message); } }, - [api, refreshTags, notifyApiError, setStatusMessage, tagManager], + [refreshTags, notifyApiError, setStatusMessage, tagManager], ); const handleCorrespondentUpdate = useCallback( @@ -1885,7 +1894,7 @@ const AppLayout = () => { throw new Error(message); } }, - [api, refreshCorrespondents, notifyApiError, setStatusMessage], + [refreshCorrespondents, notifyApiError, setStatusMessage], ); const handleCorrespondentCreate = useCallback( @@ -1905,7 +1914,7 @@ const AppLayout = () => { throw new Error(message); } }, - [api, refreshCorrespondents, notifyApiError, setStatusMessage], + [refreshCorrespondents, notifyApiError, setStatusMessage], ); const handleCorrespondentDelete = useCallback( @@ -1939,57 +1948,57 @@ const AppLayout = () => { throw new Error(message); } }, - [api, refreshCorrespondents, notifyApiError, setStatusMessage, mapDocumentCaches], + [refreshCorrespondents, notifyApiError, setStatusMessage, mapDocumentCaches], ); - async function handleDocumentCorrespondentAttach( - { 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(); + const handleDocumentCorrespondentAttach = useCallback( + async ({ documentId, correspondentId }, { notify = true, refresh = true } = {}) => { + if (!documentId || !correspondentId) { + throw new Error('Missing document or correspondent.'); } - if (notify) { - setStatusMessage('Correspondent assigned.', 'success'); + 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); } - return true; - } catch (error) { - const message = error.response?.data?.error || 'Failed to assign correspondent.'; - notifyApiError(error, message); - throw new Error(message); - } - } + }, + [notifyApiError, refreshCurrentFolder, setStatusMessage], + ); - async function handleCorrespondentRemove( - { 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(); + const handleCorrespondentRemove = useCallback( + async ({ documentId, correspondentId }, { notify = true, refresh = true } = {}) => { + if (!documentId || !correspondentId) { + throw new Error('Missing document or correspondent.'); } - if (notify) { - setStatusMessage('Correspondent removed.', 'success'); + 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); } - 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 }) => { @@ -2070,7 +2079,6 @@ const AppLayout = () => { } }, [ - api, refreshTags, notifyApiError, setStatusMessage, @@ -2367,7 +2375,6 @@ const AppLayout = () => { [ correspondentLookupByName, handleCorrespondentCreate, - api, refreshCurrentFolder, resolveTargetDocumentIds, setStatusMessage, @@ -2414,7 +2421,7 @@ const AppLayout = () => { setStatusMessage('No correspondents changed.', 'info'); } }, - [api, refreshCurrentFolder, resolveTargetDocumentIds, setStatusMessage], + [refreshCurrentFolder, resolveTargetDocumentIds, setStatusMessage], ); useEffect(() => { @@ -2528,7 +2535,6 @@ const AppLayout = () => { [ resolveTargetDocumentIds, tags, - api, refreshTags, refreshCurrentFolder, notifyApiError, @@ -2629,7 +2635,7 @@ const AppLayout = () => { setLoading(false); } }, - [resolveTargetDocumentIds, api, notifyApiError, setStatusMessage], + [resolveTargetDocumentIds, notifyApiError, setStatusMessage], ); const uploadFile = useCallback( @@ -2727,12 +2733,7 @@ const AppLayout = () => { throw error; } }, - [ - assetManager, - setDocuments, - setSearchResults, - notifyApiError, - ], + [assetManager, setDocuments, setSearchResults, notifyApiError], ); const dragPreviewRef = useRef(null); @@ -3082,7 +3083,7 @@ const AppLayout = () => { previewInflightRef.current.set(documentId, request); return request; }, - [previewEntries, notifyApiError, resolveApiPath], + [previewEntries, notifyApiError], ); const extractFilesFromDataTransfer = useCallback(async (dataTransfer) => { @@ -3327,7 +3328,6 @@ const AppLayout = () => { } }, [ - api, ensureFolderData, refreshCurrentFolder, selectedFolder, @@ -3402,7 +3402,6 @@ const AppLayout = () => { [ searchResults, documents, - api, assetManager, setDocuments, selectedFolder, @@ -3665,7 +3664,7 @@ const AppLayout = () => { return false; } }, - [api, refreshTags, notifyApiError, setStatusMessage, applyTagRemovalToCaches], + [refreshTags, notifyApiError, setStatusMessage, applyTagRemovalToCaches], ); const handleTagAdd = useCallback( @@ -3734,7 +3733,6 @@ const AppLayout = () => { } }, [ - api, refreshCurrentFolder, notifyApiError, setStatusMessage, @@ -3899,7 +3897,7 @@ const AppLayout = () => { setLoading(false); } }, - [api, token, notifyApiError, setStatusMessage], + [token, notifyApiError, setStatusMessage], ); const handleFolderCreate = useCallback( @@ -4379,8 +4377,180 @@ const AppLayout = () => { } }, [appDispatch, setStatusMessage]); - const folderClickHandlers = { - onToggle: async (folderId) => { + 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 ( + // eslint-disable-next-line no-empty + 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 ( + // eslint-disable-next-line no-empty + 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 ( + // eslint-disable-next-line no-empty + error + ) {} + + if (!docIds.length) { + try { + const single = event.dataTransfer.getData('application/x-papercrate-doc'); + if (single) { + docIds = [single]; + } + } catch ( + // eslint-disable-next-line no-empty + 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 node = folderNodes.get(folderId); const nextExpanded = !(node?.expanded ?? false); if (nextExpanded) { @@ -4600,7 +4770,6 @@ const AppLayout = () => { } }, [ - api, token, documentLookup, setStatusMessage, @@ -5016,93 +5185,173 @@ const AppLayout = () => { ], ); - const documentsTableProps = { - currentFolderName, - breadcrumbs, - onRefresh: refreshCurrentFolder, - onShowSkeuoWorkspace: showSkeuoWorkspace, - onRequestCreateFolder: openCreateFolderModal, - creatingFolder, - subfolders: currentSubfolders, - documents, - searchResults, - isFilterActive, - onFolderSelect: selectFolder, - onFolderDrop: folderClickHandlers.onDrop, - onFolderDragOver: folderClickHandlers.onDragOver, - onFolderDragLeave: folderClickHandlers.onDragLeave, - onFolderDragStart: handleFolderDragStart, - onFolderDragEnd: handleFolderDragEnd, - draggedFolderId, - onFolderDelete: handleFolderDelete, - onFolderRowClick: handleFolderRowClick, - onFolderRename: handleFolderRename, - onDocumentRowClick: handleDocumentRowClick, - onDocumentOpen: openDocumentPreview, - onDocumentDelete: handleDocumentDelete, - onDocumentRename: handleDocumentTitleUpdate, - selectedDocumentIds, - selectedFolderIds, - focusedDocumentId, - 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, - }; + const documentsTableProps = useMemo( + () => ({ + currentFolderName, + breadcrumbs, + onRefresh: refreshCurrentFolder, + onShowSkeuoWorkspace: showSkeuoWorkspace, + onRequestCreateFolder: openCreateFolderModal, + creatingFolder, + subfolders: currentSubfolders, + documents, + searchResults, + isFilterActive, + onFolderSelect: selectFolder, + onFolderDrop: folderClickHandlers.onDrop, + onFolderDragOver: folderClickHandlers.onDragOver, + onFolderDragLeave: folderClickHandlers.onDragLeave, + onFolderDragStart: handleFolderDragStart, + onFolderDragEnd: handleFolderDragEnd, + draggedFolderId, + onFolderDelete: handleFolderDelete, + onFolderRowClick: handleFolderRowClick, + onFolderRename: handleFolderRename, + onDocumentRowClick: handleDocumentRowClick, + onDocumentOpen: openDocumentPreview, + onDocumentDelete: handleDocumentDelete, + onDocumentRename: handleDocumentTitleUpdate, + selectedDocumentIds, + selectedFolderIds, + focusedDocumentId, + 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, + }), + [ + activeCorrespondentFilters, + breadcrumbs, + clearDocumentSelection, + currentFolderName, + currentSubfolders, + documents, + documentsViewMode, + draggedDocumentIds, + draggedFolderId, + focusedDocumentId, + focusedRowKey, + folderClickHandlers, + handleDocumentDelete, + handleDocumentDragEnd, + handleDocumentDragStart, + handleDocumentListFocus, + handleDocumentListKeyDown, + handleDocumentRowClick, + handleDocumentTagDrop, + handleDocumentTitleUpdate, + handleDocumentsViewModeChange, + handleFolderDelete, + handleFolderDragEnd, + handleFolderDragStart, + handleFolderRename, + handleFolderRowClick, + isFilterActive, + creatingFolder, + openCreateFolderModal, + openDocumentPreview, + refreshCurrentFolder, + searchLoading, + searchResults, + selectFolder, + selectedDocumentIds, + selectedFolderIds, + setFocusedRowKey, + showSkeuoWorkspace, + tagLookupById, + toggleCorrespondentFilter, + toggleTagFilter, + ensureAssetUrl, + getDocumentAsset, + ], + ); - const sidebarProps = { - 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, - tags, - activeTagIds: activeTagFilters, - onToggleTagFilter: toggleTagFilter, - correspondents, - activeCorrespondentIds: activeCorrespondentFilters, - onToggleCorrespondentFilter: toggleCorrespondentFilter, - appStatus, - loading, - previewActive, - searchQuery, - onSearchChange: handleSearchChange, - onSearchSubmit: handleSearchSubmit, - onSearchClear: clearFilters, - isFilterActive, - onLogout: handleLogout, - status, - tenantSlug, - tenants: tenantOptions, - activeTenantId: currentTenantId, - onSelectTenant: handleTenantSelect, - onOpenSettings: openSettingsModal, - }; + 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, + tags, + activeTagIds: activeTagFilters, + onToggleTagFilter: toggleTagFilter, + correspondents, + activeCorrespondentIds: activeCorrespondentFilters, + onToggleCorrespondentFilter: toggleCorrespondentFilter, + appStatus, + loading, + previewActive, + searchQuery, + onSearchChange: handleSearchChange, + onSearchSubmit: handleSearchSubmit, + onSearchClear: clearFilters, + isFilterActive, + onLogout: handleLogout, + status, + tenantSlug, + tenants: tenantOptions, + activeTenantId: currentTenantId, + onSelectTenant: handleTenantSelect, + onOpenSettings: openSettingsModal, + }), + [ + activeCorrespondentFilters, + activeTagFilters, + appStatus, + clearFilters, + correspondents, + currentTenantId, + folderClickHandlers, + folderNodes, + handleFolderDelete, + handleFolderDragEnd, + handleFolderDragStart, + handleFolderRename, + handleLogout, + handleSearchChange, + handleSearchSubmit, + handleTenantSelect, + loading, + openSettingsModal, + previewActive, + searchQuery, + draggedFolderId, + isFilterActive, + selectedFolder, + status, + tags, + tenantOptions, + tenantSlug, + toggleCorrespondentFilter, + toggleTagFilter, + ], + ); const handleDetailPanelClose = useCallback(() => { setDetailPanelOpen(false); @@ -5111,34 +5360,63 @@ const AppLayout = () => { setDetailPanelDocs([]); }, [clearDocumentSelection]); - const detailPanelProps = { - 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, - ensureAssetUrl, - getDocumentAsset, - ensurePreviewData, - correspondents, - onCorrespondentAdd: handleCorrespondentAdd, - onCorrespondentRemove: handleCorrespondentRemove, - resolveApiPath, - onFolderNavigate: selectFolder, - onClose: handleDetailPanelClose, - resolveFolderPath, - }; + 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, + 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, + handleTagAdd, + handleTagRemove, + handleThumbnailRegeneration, + openDocumentPreview, + promoteSelectionOrder, + resolveFolderPath, + selectFolder, + selectedPreviewEntry, + tags, + tagLookupById, + ], + ); const skeuoWorkspaceProps = useMemo( () => ({ @@ -5210,7 +5488,6 @@ const AppLayout = () => { ensureAssetUrl, getDocumentAsset, notifyApiError, - resolveApiPath, openTagsModal, openCorrespondentsModal, openSettingsModal, @@ -5253,7 +5530,6 @@ const AppLayout = () => { ensureAssetUrl, getDocumentAsset, notifyApiError, - resolveApiPath, openTagsModal, openCorrespondentsModal, openSettingsModal, diff --git a/frontend/src/preview/PreviewWorkspace.jsx b/frontend/src/preview/PreviewWorkspace.jsx index 7bf6803..f946f6a 100644 --- a/frontend/src/preview/PreviewWorkspace.jsx +++ b/frontend/src/preview/PreviewWorkspace.jsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React from 'react'; import { formatFileSize } from '../utils/format'; import { DownloadIcon, TextScanIcon, AnalyzeIcon, CloseIcon } from '../ui/icons'; import { openOcrTextInNewTab } from '../utils/ocr'; @@ -24,7 +24,7 @@ const PreviewWorkspace = ({ const tags = Array.isArray(document.tags) ? document.tags : []; const correspondents = Array.isArray(document.correspondents) ? document.correspondents : []; - const metadataSummary = useMemo(() => { + const metadataSummary = (() => { const rows = []; if (mime) rows.push(['Type', mime]); if (sizeLabel) rows.push(['Size', sizeLabel]); @@ -45,7 +45,7 @@ const PreviewWorkspace = ({ ]); } return rows; - }, [mime, sizeLabel, issuedAt, createdAt, updatedAt, folderName, tags, correspondents]); + })(); return (