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 (