diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 7c25133..cfe83a1 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1959,6 +1959,7 @@ checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7" dependencies = [ "bytemuck", "byteorder-lite", + "image-webp", "moxcms", "num-traits", "png", @@ -1966,6 +1967,16 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + [[package]] name = "indexmap" version = "2.11.4" @@ -2695,6 +2706,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.32.0" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 4faeca0..366455e 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -36,7 +36,7 @@ sha2 = "0.10" hex = "0.4" bytes = "1.5" async-trait = "0.1" -image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] } pdfium-render = "0.8" mime_guess = "2.0" tempfile = "3.10" diff --git a/backend/src/workers/thumbnails.rs b/backend/src/workers/thumbnails.rs index f1237a2..621bd46 100644 --- a/backend/src/workers/thumbnails.rs +++ b/backend/src/workers/thumbnails.rs @@ -274,7 +274,7 @@ impl JobHandler for GenerateThumbnailsJob { .put_object( &s3_key, image.image_bytes.clone(), - Some("image/png".into()), + Some("image/webp".into()), None, ) .await @@ -315,7 +315,7 @@ impl JobHandler for GenerateThumbnailsJob { .put_object( &s3_key, image.image_bytes.clone(), - Some("image/png".into()), + Some("image/webp".into()), None, ) .await @@ -609,7 +609,7 @@ fn encode_dynamic_image(image: image::DynamicImage) -> Result { const items = useMemo(() => (searchResults ? searchResults : documents), [documents, searchResults]); @@ -1715,6 +1723,7 @@ const syncLayoutSnapshot = useCallback(() => { onDocumentOpen, onInspectDocument, onDocumentPointerSelect, + onDocumentStackSelect, ensureAssetUrl, getDocumentAsset, handleNavigatorSnapshot, @@ -1779,12 +1788,14 @@ const syncLayoutSnapshot = useCallback(() => { documentLookup, selectedDocumentIds, onClearSelection, + onDocumentStackSelect, ], ); return ( + ); }; @@ -1823,15 +1834,245 @@ const DesktopWorkspaceView = () => { overlayOriginRect, overlayOriginTransform, onDocumentPointerSelect, + onDocumentStackSelect, selectedDocumentIds, onClearSelection, + 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; + if (!(key === ' ' || key === 'Space' || key === 'Spacebar')) { + return; + } + + if (!selectedDocumentIds || selectedDocumentIds.length === 0) { + 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; + } + } + + event.preventDefault(); + onClearSelection(); + }; + + window.addEventListener('keydown', handleKeyDown, true); + return () => window.removeEventListener('keydown', handleKeyDown, true); + }, [onClearSelection, selectedDocumentIds]); + return ( <>
{ }} onPointerDown={(event) => { const alreadySelected = selectedDocumentIds.includes(doc.id); + const metaOrCtrlOnly = + (event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey; + + let stackDocIds = null; if ( - typeof onDocumentPointerSelect === 'function' + metaOrCtrlOnly + && selectedDocumentIds.length === 0 + && typeof onDocumentStackSelect === 'function' + ) { + const hits = resolveStackDocIds(event, doc.id); + if (Array.isArray(hits) && hits.length > 0) { + stackDocIds = hits; + onDocumentStackSelect(hits, event); + } + } + + const stackHandled = Array.isArray(stackDocIds) && stackDocIds.length > 0; + + if ( + !stackHandled + && typeof onDocumentPointerSelect === 'function' && (!alreadySelected || event.metaKey || event.ctrlKey @@ -1941,7 +2201,7 @@ const DesktopWorkspaceView = () => { ) { onDocumentPointerSelect(doc.id, event); } - handlePointerDown(event, doc.id); + handlePointerDown(event, doc.id, { stackDocIds }); }} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} @@ -2021,6 +2281,110 @@ const DesktopWorkspaceView = () => { 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, @@ -2049,6 +2413,7 @@ export const createDesktopSurface = ({ viewMode: viewMode || 'desk', onViewModeChange, onRefresh, + onShowDeskHelp: viewMode === 'desk' ? workspaceProps?.onOpenHelp : null, }); const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null; diff --git a/frontend/src/app/AppLayout.jsx b/frontend/src/app/AppLayout.jsx index 01c0994..974e981 100644 --- a/frontend/src/app/AppLayout.jsx +++ b/frontend/src/app/AppLayout.jsx @@ -198,6 +198,7 @@ const AppLayout = () => { const stored = window.localStorage.getItem('papercrate_view_mode'); return stored === 'grid' || stored === 'desk' ? stored : 'list'; }); + const [deskHelpOpen, setDeskHelpOpen] = useState(false); const lastNonDeskViewRef = useRef(documentsViewMode === 'desk' ? 'list' : documentsViewMode); useEffect(() => { @@ -205,6 +206,12 @@ const AppLayout = () => { lastNonDeskViewRef.current = documentsViewMode; } }, [documentsViewMode]); + + useEffect(() => { + if (documentsViewMode !== 'desk' && deskHelpOpen) { + setDeskHelpOpen(false); + } + }, [documentsViewMode, deskHelpOpen]); const initialRowSelection = []; const tokenRef = useRef(token); const refreshPromiseRef = useRef(null); @@ -4819,6 +4826,36 @@ const AppLayout = () => { [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, @@ -4832,6 +4869,10 @@ const AppLayout = () => { onDocumentOpen: openDocumentPreview, onInspectDocument: handleDeskInspectDocument, onDocumentPointerSelect: handleDeskDocumentPointerSelect, + onDocumentStackSelect: handleDeskDocumentStackSelect, + onOpenHelp: handleDeskHelpOpen, + helpOpen: deskHelpOpen, + onHelpClose: handleDeskHelpClose, selectedDocumentIds, onClearSelection: clearDocumentSelection, resolveThumbnailUrl: resolveThumbnailUrlForDoc, @@ -4853,6 +4894,10 @@ const AppLayout = () => { openDocumentPreview, handleDeskInspectDocument, handleDeskDocumentPointerSelect, + handleDeskDocumentStackSelect, + handleDeskHelpOpen, + handleDeskHelpClose, + deskHelpOpen, selectedDocumentIds, clearDocumentSelection, resolveThumbnailUrlForDoc, diff --git a/frontend/src/desktop/useDocumentDrag.js b/frontend/src/desktop/useDocumentDrag.js index 587a133..bd2bd6b 100644 --- a/frontend/src/desktop/useDocumentDrag.js +++ b/frontend/src/desktop/useDocumentDrag.js @@ -97,18 +97,21 @@ const useDocumentDrag = () => { if (event && (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)) { return; } - openOverlayForDoc(data.docId, data.originInfo); - }, - onDouble: ({ data }) => { - if (!data || !data.docId) { - return; - } if (typeof onInspectDocument === 'function') { onInspectDocument(data.docId); return; } onDocumentOpen?.(data.docId); }, + onDouble: ({ data, event }) => { + if (!data || !data.docId) { + return; + } + if (event && (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)) { + return; + } + openOverlayForDoc(data.docId, data.originInfo); + }, }); const dragStateRef = useRef(null); @@ -247,7 +250,7 @@ const useDocumentDrag = () => { ); const handlePointerDown = useCallback( - (event, docIdInput) => { + (event, docIdInput, options = {}) => { if (debugDrag) { console.log( '[desk] handlePointerDown fired for doc', @@ -275,15 +278,31 @@ const useDocumentDrag = () => { return; } + const stackDocIdsOption = Array.isArray(options?.stackDocIds) + ? options.stackDocIds + .map((value) => (value != null ? String(value) : null)) + .filter(Boolean) + : null; + let selectionIds = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.map((id) => String(id)) : []; + + if (stackDocIdsOption && stackDocIdsOption.length) { + selectionIds = stackDocIdsOption; + } + const metaOrCtrl = event.metaKey || event.ctrlKey; - if (metaOrCtrl && !selectionIds.includes(docKey)) { + if (!stackDocIdsOption && metaOrCtrl && !selectionIds.includes(docKey)) { selectionIds = [...selectionIds, docKey]; } let groupDocIds = []; - if (selectionIds.includes(docKey) && selectionIds.length > 1) { + 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)); diff --git a/frontend/src/documents/DocumentsPanel.jsx b/frontend/src/documents/DocumentsPanel.jsx index b813300..e56c280 100644 --- a/frontend/src/documents/DocumentsPanel.jsx +++ b/frontend/src/documents/DocumentsPanel.jsx @@ -5,6 +5,7 @@ import { IconFileStack, RefreshIcon, MinusVerticalIcon, + InfoIcon, } from '../ui/icons'; import BreadcrumbTrail from '../ui/BreadcrumbTrail'; import createWorkspaceSurfaceConfig from './workspaceHeader'; @@ -526,6 +527,7 @@ export const createDocumentsTableHeaderActions = ({ viewMode, onViewModeChange, onRefresh, + onShowDeskHelp = null, }) => { const isListView = viewMode === 'list'; const isGridView = viewMode === 'grid'; @@ -574,6 +576,17 @@ export const createDocumentsTableHeaderActions = ({ > + {isDeskView && typeof onShowDeskHelp === 'function' ? ( + + ) : null} ); }; diff --git a/frontend/src/sidebar/SidebarContext.js b/frontend/src/sidebar/SidebarContext.js index a609db3..cb25e96 100644 --- a/frontend/src/sidebar/SidebarContext.js +++ b/frontend/src/sidebar/SidebarContext.js @@ -7,6 +7,8 @@ import React, { useEffect, } from 'react'; +const SIDEBAR_COLLAPSE_STORAGE_KEY = 'papercrate_sidebar_collapsed'; + const SidebarContext = createContext(null); const THEME_STORAGE_KEY = 'papercrate_theme_settings'; @@ -71,8 +73,26 @@ const loadInitialThemeSettings = () => { return { neutralHue: neutralHueValue, mode: modeValue }; }; +const loadInitialCollapsedState = (defaultValue) => { + if (typeof window === 'undefined') { + return Boolean(defaultValue); + } + try { + const stored = window.sessionStorage.getItem(SIDEBAR_COLLAPSE_STORAGE_KEY); + if (stored === '1' || stored === 'true') { + return true; + } + if (stored === '0' || stored === 'false') { + return false; + } + } catch (error) { + console.warn('[sidebar] failed to read collapse state', error); + } + return Boolean(defaultValue); +}; + export const SidebarProvider = ({ initialCollapsed = false, children }) => { - const [collapsed, setCollapsed] = useState(initialCollapsed); + const [collapsed, setCollapsedState] = useState(() => loadInitialCollapsedState(initialCollapsed)); const initialTheme = useMemo(() => loadInitialThemeSettings(), []); const [neutralHue, setNeutralHueState] = useState(initialTheme.neutralHue); const [themeMode, setThemeModeState] = useState(initialTheme.mode); @@ -141,6 +161,25 @@ export const SidebarProvider = ({ initialCollapsed = false, children }) => { setThemeModeState(THEME_MODES[nextIndex]); }, [themeMode]); + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + try { + window.sessionStorage.setItem(SIDEBAR_COLLAPSE_STORAGE_KEY, collapsed ? '1' : '0'); + } catch (error) { + console.warn('[sidebar] failed to persist collapse state', error); + } + }, [collapsed]); + + const setCollapsed = useCallback((value) => { + if (typeof value === 'function') { + setCollapsedState((prev) => Boolean(value(prev))); + return; + } + setCollapsedState(Boolean(value)); + }, []); + const contextValue = useMemo( () => ({ collapsed, @@ -154,7 +193,16 @@ export const SidebarProvider = ({ initialCollapsed = false, children }) => { themeModes: THEME_MODES, defaultNeutralHue: DEFAULT_NEUTRAL_HUE, }), - [collapsed, neutralHue, setNeutralHue, resetNeutralHue, themeMode, setThemeMode, cycleThemeMode], + [ + collapsed, + setCollapsed, + neutralHue, + setNeutralHue, + resetNeutralHue, + themeMode, + setThemeMode, + cycleThemeMode, + ], ); return {children}; diff --git a/frontend/src/ui/icons.js b/frontend/src/ui/icons.js index eb3ea48..cbaeb72 100644 --- a/frontend/src/ui/icons.js +++ b/frontend/src/ui/icons.js @@ -29,6 +29,7 @@ import { IconLayoutSidebarLeftCollapse, IconLayoutSidebarLeftExpand, IconLayoutSidebarRightCollapse, + IconInfoCircle, } from '@tabler/icons-react'; import FolderSvg from '../assets/folder.svg'; @@ -158,6 +159,15 @@ export const SidebarExpandIcon = ({ className, size = '1em', stroke = 1.6, ...re /> ); +export const InfoIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( + +); + export const DetailPanelCollapseIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (