From 70b9443e6602f0ef48feda17a42da34a18d4aec0 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Tue, 11 Nov 2025 00:48:07 +0100 Subject: [PATCH] outline --- frontend/src/documents/DocumentsList.jsx | 168 +++++++++--------- .../src/documents/panel/DocumentsPanel.jsx | 10 -- frontend/src/documents/useEntryPointer.js | 41 ++--- .../documents/useDocumentDragHandlers.js | 38 +++- .../hooks/documents/useDocumentsWorkspace.js | 21 +-- frontend/src/hooks/documents/useFolderTree.js | 5 +- .../src/styles/documents/drag-preview.css | 76 ++++++++ frontend/src/styles/documents/listing.css | 24 ++- frontend/src/styles/documents/viewer.css | 6 + frontend/src/styles/index.css | 1 + frontend/src/styles/sidebar/sidebar.css | 5 + 11 files changed, 258 insertions(+), 137 deletions(-) create mode 100644 frontend/src/styles/documents/drag-preview.css diff --git a/frontend/src/documents/DocumentsList.jsx b/frontend/src/documents/DocumentsList.jsx index 714b2d8..d17fdce 100644 --- a/frontend/src/documents/DocumentsList.jsx +++ b/frontend/src/documents/DocumentsList.jsx @@ -139,92 +139,96 @@ const DocumentsList = ({ - -
- - - {isFolderEditing ? ( - - setFolderDraft(event.target.value)} - onClick={(event) => event.stopPropagation()} - onKeyDown={(event) => { - if (event.key === 'Enter') { + +
+
+
+ + + {isFolderEditing ? ( + + setFolderDraft(event.target.value)} + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + submitFolderEditing(folder); + } else if (event.key === 'Escape') { + event.preventDefault(); + cancelFolderEditing(event); + } + }} + onBlur={(event) => { + const nextFocus = event.relatedTarget; + if (!nextFocus || !event.currentTarget.parentElement?.contains(nextFocus)) { + cancelFolderEditing(); + } + }} + /> + + + + ) : ( + { + if (!allowInlineFolderEdit) { + return; + } event.preventDefault(); - submitFolderEditing(folder); - } else if (event.key === 'Escape') { - event.preventDefault(); - cancelFolderEditing(event); - } - }} - onBlur={(event) => { - const nextFocus = event.relatedTarget; - if (!nextFocus || !event.currentTarget.parentElement?.contains(nextFocus)) { - cancelFolderEditing(); - } - }} - /> - - + event.stopPropagation(); + beginFolderEditing(folder); + }} + onKeyDown={(event) => { + if (!allowInlineFolderEdit) { + return; + } + if (event.key === 'Enter') { + event.preventDefault(); + event.stopPropagation(); + beginFolderEditing(folder); + } + }} + > + {folder.name} + + )} - ) : ( - { - if (!allowInlineFolderEdit) { - return; - } - event.preventDefault(); - event.stopPropagation(); - beginFolderEditing(folder); - }} - onKeyDown={(event) => { - if (!allowInlineFolderEdit) { - return; - } - if (event.key === 'Enter') { - event.preventDefault(); - event.stopPropagation(); - beginFolderEditing(folder); - } - }} - > - {folder.name} - - )} - - + +
+
+
+
- — - — ); } diff --git a/frontend/src/documents/panel/DocumentsPanel.jsx b/frontend/src/documents/panel/DocumentsPanel.jsx index a5d307b..5692736 100644 --- a/frontend/src/documents/panel/DocumentsPanel.jsx +++ b/frontend/src/documents/panel/DocumentsPanel.jsx @@ -196,7 +196,6 @@ const DocumentsPanel = ({ [handleDocumentPreviewZoom, onInspectDocument], ); - const selectedRowKeySet = useMemo(() => new Set(selectedEntries || []), [selectedEntries]); const navigableRows = useMemo( () => entries.map((entry) => ({ key: entry.key, type: entry.type, id: entry.id })), [entries], @@ -234,21 +233,12 @@ const DocumentsPanel = ({ } onFocusedRowChange?.(resolvedKey); - - if (!selectedRowKeySet.has(resolvedKey) && typeof onEntrySelection === 'function') { - onEntrySelection(resolvedKey, { - shiftKey: false, - preventDefault: () => {}, - }); - } }, [ focusedRowKey, navigableRowKeys, navigableRows, - onEntrySelection, onFocusedRowChange, selectedEntries, - selectedRowKeySet, ]); const handlePanelKeyDown = useCallback( diff --git a/frontend/src/documents/useEntryPointer.js b/frontend/src/documents/useEntryPointer.js index 826645f..4d5cb13 100644 --- a/frontend/src/documents/useEntryPointer.js +++ b/frontend/src/documents/useEntryPointer.js @@ -14,11 +14,15 @@ export const isPrimaryPointerEvent = (event) => { return type === 'click' || type === 'pointerdown' || type === 'pointerup'; }; +const EntryType = Object.freeze({ + document: 'document', + folder: 'folder', +}); + export const useEntryPointer = ({ resolveDocumentRowKey, resolveFolderRowKey, - onSelectDocument, - onSelectFolder, + onSelectEntry, onInspectDocument, }) => useCallback( @@ -28,41 +32,34 @@ export const useEntryPointer = ({ } const { type, id } = entry; - if (type !== 'document' && type !== 'folder') { + if (type !== EntryType.document && type !== EntryType.folder) { return; } const rowKey = entry.key - || (type === 'document' ? resolveDocumentRowKey?.(id) : resolveFolderRowKey?.(id)); + || (type === EntryType.document ? resolveDocumentRowKey?.(id) : resolveFolderRowKey?.(id)); if (!rowKey) { return; } const modifierClick = isPointerModifierEvent(event); const primaryClick = isPrimaryPointerEvent(event); - const metadata = { modifierClick, primaryClick, rowKey }; + const metadata = { modifierClick, primaryClick, rowKey, type, id }; - if (type === 'document') { - if (typeof onSelectDocument === 'function') { - onSelectDocument(id, event, metadata); - } - if (!modifierClick && primaryClick && typeof onInspectDocument === 'function') { - onInspectDocument(id, metadata); - } - return; + if (typeof onSelectEntry === 'function') { + onSelectEntry(entry, event, metadata); } - if (typeof onSelectFolder === 'function') { - onSelectFolder(id, event, metadata); + if ( + type === EntryType.document + && !modifierClick + && primaryClick + && typeof onInspectDocument === 'function' + ) { + onInspectDocument(id, metadata); } }, - [ - resolveDocumentRowKey, - resolveFolderRowKey, - onSelectDocument, - onSelectFolder, - onInspectDocument, - ], + [resolveDocumentRowKey, resolveFolderRowKey, onSelectEntry, onInspectDocument], ); export default useEntryPointer; diff --git a/frontend/src/hooks/documents/useDocumentDragHandlers.js b/frontend/src/hooks/documents/useDocumentDragHandlers.js index 9662e89..eaec276 100644 --- a/frontend/src/hooks/documents/useDocumentDragHandlers.js +++ b/frontend/src/hooks/documents/useDocumentDragHandlers.js @@ -29,6 +29,10 @@ const useDocumentDragHandlers = ({ ({ documents = [], folders = [] } = {}) => { destroyDragPreview(); + if (typeof document === 'undefined') { + return null; + } + const docEntries = (documents || []).filter(Boolean); const folderEntries = (folders || []).filter(Boolean); const totalCount = docEntries.length + folderEntries.length; @@ -54,6 +58,8 @@ const useDocumentDragHandlers = ({ const wrapper = document.createElement('div'); wrapper.className = 'document-drag-preview'; wrapper.style.setProperty('--drag-preview-size', `${canvasSize}px`); + wrapper.style.width = `${canvasSize}px`; + wrapper.style.height = `${canvasSize}px`; visibleItems.forEach((item, index) => { const layer = document.createElement('div'); @@ -101,15 +107,23 @@ const useDocumentDragHandlers = ({ layer.textContent = doc?.title || 'Document'; } } else { - const folderId = item.payload; - const rowEl = folderId ? document.getElementById(`folder-row-${folderId}`) : null; - const iconEl = rowEl?.querySelector('.thumb-icon'); + const payload = item.payload; + const folderId = typeof payload === 'string' ? payload : payload?.id; + const rowEl = folderId + ? document.getElementById(`folder-row-${folderId}`) + || document.getElementById(`folder-card-${folderId}`) + : null; + const iconEl = rowEl?.querySelector('.thumb-icon, .folder-card__icon'); layer.style.width = `${size}px`; layer.style.height = `${size}px`; + layer.classList.add('document-drag-preview__item--folder'); let content = null; if (iconEl instanceof HTMLElement) { - content = iconEl.cloneNode(true); + const cloneSource = iconEl.classList.contains('folder-card__icon') + ? iconEl.querySelector('svg') || iconEl + : iconEl; + content = cloneSource.cloneNode(true); content.classList.add('document-drag-preview__folder-thumb'); const svg = content.querySelector('svg'); if (svg) { @@ -276,11 +290,19 @@ const useDocumentDragHandlers = ({ console.warn('[documents] Failed to populate folder drag payload', error); } - const previewDocs = effectiveDocumentSelection - .map((id) => documentLookup.get(id) || null) - .filter(Boolean); - createDragPreview({ documents: previewDocs, folders: uniqueFolders }); + const previewNode = createDragPreview({ + documents: effectiveDocumentSelection + .map((id) => documentLookup.get(id) || null) + .filter(Boolean), + folders: uniqueFolders, + }); event.currentTarget.classList.add('dragging'); + + if (previewNode) { + const width = previewNode.offsetWidth || 96; + const height = previewNode.offsetHeight || 96; + event.dataTransfer.setDragImage(previewNode, width / 2, height / 2); + } }, [ selectedFolderIds, diff --git a/frontend/src/hooks/documents/useDocumentsWorkspace.js b/frontend/src/hooks/documents/useDocumentsWorkspace.js index f79dc09..f17a52c 100644 --- a/frontend/src/hooks/documents/useDocumentsWorkspace.js +++ b/frontend/src/hooks/documents/useDocumentsWorkspace.js @@ -50,6 +50,11 @@ import useDocumentDragHandlers from './useDocumentDragHandlers'; import useDocumentMutations from './useDocumentMutations'; import useDetailWorkspace from '../../detail/useDetailWorkspace'; +const EntryType = Object.freeze({ + document: 'document', + folder: 'folder', +}); + const noop = () => {}; const useDocumentsWorkspace = ({ @@ -1185,19 +1190,15 @@ const useDocumentsWorkspace = ({ const handleEntryPointerCore = useEntryPointerCore({ resolveDocumentRowKey, resolveFolderRowKey, - onSelectDocument: (documentId, event, { rowKey }) => { - const key = rowKey || resolveDocumentRowKey(documentId); + onSelectEntry: (entry, event, { rowKey, modifierClick, primaryClick }) => { + const { type, id } = entry; + const key = rowKey + || (type === EntryType.document ? resolveDocumentRowKey(id) : resolveFolderRowKey(id)); if (key) { handleEntrySelection(key, event); } - }, - onSelectFolder: (folderId, event, { modifierClick, primaryClick, rowKey }) => { - const key = rowKey || resolveFolderRowKey(folderId); - if (key) { - handleEntrySelection(key, event); - } - if (!modifierClick && primaryClick) { - selectFolder(folderId); + if (type === EntryType.folder && !modifierClick && primaryClick) { + selectFolder(id); } }, }); diff --git a/frontend/src/hooks/documents/useFolderTree.js b/frontend/src/hooks/documents/useFolderTree.js index ce5f369..b0f7675 100644 --- a/frontend/src/hooks/documents/useFolderTree.js +++ b/frontend/src/hooks/documents/useFolderTree.js @@ -85,9 +85,8 @@ const useFolderTree = ({ })(); setFocusedDocumentId(nextFocus); - selectionAnchorRef.current = nextDocKeys.length - ? nextDocKeys[nextDocKeys.length - 1] - : null; + const nextAnchor = mergedSelection.length ? mergedSelection[mergedSelection.length - 1] : null; + selectionAnchorRef.current = nextAnchor; selectionOrderRef.current = mergedSelection; setSelectionOrder(mergedSelection); }, diff --git a/frontend/src/styles/documents/drag-preview.css b/frontend/src/styles/documents/drag-preview.css new file mode 100644 index 0000000..ada971f --- /dev/null +++ b/frontend/src/styles/documents/drag-preview.css @@ -0,0 +1,76 @@ +.document-drag-preview { + position: fixed; + pointer-events: none; + top: -9999px; + left: -9999px; + width: var(--drag-preview-size, 96px); + height: var(--drag-preview-size, 96px); + z-index: 9999; +} + +.document-drag-preview__item { + position: absolute; + top: 50%; + left: 50%; + width: 64px; + height: 64px; + border-radius: 6px; + box-shadow: 0 6px 12px var(--shadow-pop); + overflow: hidden; + background-color: var(--overlay-dim); + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-weight: 600; + font-size: 0.8rem; + text-transform: uppercase; + transform: translate(-50%, -50%) rotate(var(--rotation-deg, 0deg)); + transform-origin: center; +} + +.document-drag-preview__item--folder { + background: transparent; + box-shadow: none; + border-radius: 0; +} + +.document-drag-preview__item--image { + background-color: #000; + background-repeat: no-repeat; + background-size: contain; + background-position: center; +} + +.document-drag-preview__item .document-thumbnail, +.document-drag-preview__item img { + width: 100%; + height: 100%; + object-fit: cover; + pointer-events: none; +} + +.document-drag-preview__item .thumb-placeholder, +.document-drag-preview__item .thumb-placeholder * { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; +} + +.document-drag-preview__folder-thumb { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; +} + +.document-drag-preview__folder-thumb svg { + width: 48px; + height: 48px; + color: var(--accent-strong, var(--accent)); +} diff --git a/frontend/src/styles/documents/listing.css b/frontend/src/styles/documents/listing.css index 751336e..9741eb5 100644 --- a/frontend/src/styles/documents/listing.css +++ b/frontend/src/styles/documents/listing.css @@ -13,8 +13,28 @@ min-height: 0; } -.folder-row.is-drop-target { - outline: 2px dashed var(--accent); + + +.documents-panel tr.folder.is-drop-target { + outline: 2px dashed var(--accent-strong, var(--accent)); + outline-offset: -2px; +} + +.folder-row__columns { + display: grid; + grid-template-columns: minmax(0, 1fr) 7rem 7rem; + gap: 0.5rem; + align-items: center; +} + +.folder-row__meta { + font-variant-numeric: tabular-nums; + color: var(--muted); + text-align: left; +} + +.documents-grid .folder-card.is-drop-target { + outline: 2px dashed var(--accent-strong, var(--accent)); outline-offset: 2px; } diff --git a/frontend/src/styles/documents/viewer.css b/frontend/src/styles/documents/viewer.css index c73de23..17e2e21 100644 --- a/frontend/src/styles/documents/viewer.css +++ b/frontend/src/styles/documents/viewer.css @@ -63,6 +63,12 @@ pointer-events: none; } +.document-drag-preview__item--folder { + background: transparent; + box-shadow: none; + border-radius: 0; +} + .document-drag-preview__folder-thumb { width: 100%; height: 100%; diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css index 884e2fd..e313188 100644 --- a/frontend/src/styles/index.css +++ b/frontend/src/styles/index.css @@ -6,6 +6,7 @@ @import './documents/controls.css'; @import './layout/structure.css'; @import './documents/viewer.css'; +@import './documents/drag-preview.css'; @import './documents/tags-correspondents.css'; @import './documents/panel-sections.css'; @import './sidebar/sidebar.css'; diff --git a/frontend/src/styles/sidebar/sidebar.css b/frontend/src/styles/sidebar/sidebar.css index fdb64a4..a1a8479 100644 --- a/frontend/src/styles/sidebar/sidebar.css +++ b/frontend/src/styles/sidebar/sidebar.css @@ -23,6 +23,11 @@ -webkit-user-select: none; } +.sidebar .folder-row.is-drop-target { + outline: 2px dashed var(--accent-strong, var(--accent)); + outline-offset: 2px; +} + .folder-row .name-wrap { display: inline-flex; align-items: center;