diff --git a/frontend/src/documents/DocumentsTable.jsx b/frontend/src/documents/DocumentsTable.jsx index 12660d1..1806e2c 100644 --- a/frontend/src/documents/DocumentsTable.jsx +++ b/frontend/src/documents/DocumentsTable.jsx @@ -430,6 +430,7 @@ const DocumentsTable = ({ className={cardClasses.join(' ')} role="listitem" id={`document-card-${doc.id}`} + data-doc-id={doc.id} onClick={(event) => onDocumentRowClick(doc.id, event)} onDoubleClick={() => onDocumentOpen(doc.id)} draggable @@ -646,6 +647,7 @@ const DocumentsTable = ({ key={doc.id} className={rowClasses.join(' ')} id={`document-row-${doc.id}`} + data-doc-id={doc.id} onClick={(event) => onDocumentRowClick(doc.id, event)} onDoubleClick={() => onDocumentOpen(doc.id)} draggable diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index 4f64585..e90fbce 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -3300,13 +3300,38 @@ const AppLayout = () => { ); const handleDocumentTagAttach = useCallback( - async ({ documentId, tagId }) => { + async ({ documentId, tagId, tag: tagData = null }) => { if (!documentId || !tagId) { return false; } + const resolveTagForCache = () => { + const lookupTag = tagLookupById.get(tagId); + const source = lookupTag || tagData; + if (!source) { + return { id: tagId, label: 'Tag', color: null }; + } + return { + id: source.id ?? tagId, + label: source.label || source.name || 'Tag', + color: Object.prototype.hasOwnProperty.call(source, 'color') + ? source.color + : null, + }; + }; + try { await api.post(`/documents/${documentId}/tags`, { tag_ids: [tagId] }); + updateDocumentCaches(documentId, (doc) => { + if (!doc) { + return doc; + } + const currentTags = Array.isArray(doc.tags) ? doc.tags : []; + if (currentTags.some((existing) => existing?.id === tagId)) { + return doc; + } + return { ...doc, tags: [...currentTags, resolveTagForCache()] }; + }); setStatusMessage('Tag assigned.', 'success'); await refreshCurrentFolder(); return true; @@ -3316,7 +3341,14 @@ const AppLayout = () => { return false; } }, - [api, refreshCurrentFolder, notifyApiError, setStatusMessage], + [ + api, + refreshCurrentFolder, + notifyApiError, + setStatusMessage, + updateDocumentCaches, + tagLookupById, + ], ); const handleDocumentTagDrop = useCallback( @@ -3329,7 +3361,7 @@ const AppLayout = () => { return; } - const attached = await handleDocumentTagAttach({ documentId, tagId: tag.id }); + const attached = await handleDocumentTagAttach({ documentId, tagId: tag.id, tag }); if (!attached) { return; } @@ -3794,14 +3826,14 @@ const AppLayout = () => { return TAG_MIME_TYPES.some((type) => Array.from(types).includes(type)); }; - const isDocumentRowTarget = (target) => - target instanceof Element ? Boolean(target.closest('tr.document')) : false; + const isDocumentDropTarget = (target) => + target instanceof Element ? Boolean(target.closest('[data-doc-id]')) : false; const handleTagDragOver = (event) => { if (!isTagTransfer(event)) { return; } - if (isDocumentRowTarget(event.target)) { + if (isDocumentDropTarget(event.target)) { setTagRemovalCursor(false); return; } @@ -3816,7 +3848,7 @@ const AppLayout = () => { } const related = event.relatedTarget; if (related instanceof Element && host.contains(related)) { - if (isDocumentRowTarget(related)) { + if (isDocumentDropTarget(related)) { setTagRemovalCursor(false); } return; @@ -3829,7 +3861,7 @@ const AppLayout = () => { return; } setTagRemovalCursor(false); - if (isDocumentRowTarget(event.target) || event.defaultPrevented) { + if (isDocumentDropTarget(event.target) || event.defaultPrevented) { return; } event.preventDefault(); @@ -4415,13 +4447,11 @@ const AppLayout = () => { onRefresh: refreshCurrentFolder, onDocumentOpen: openDocumentPreview, resolveThumbnailUrl: resolveThumbnailUrlForDoc, - availableTags: tags, - onCreateTag: handleTagCreate, onAssignTagToDocument: handleDocumentTagAttach, onRemoveTagFromDocument: handleTagRemove, ensureAssetUrl, getDocumentAsset, - prepareTagPayload: buildTagPayload, + activeTagIds: activeTagFilters, }), [ documents, @@ -4432,13 +4462,11 @@ const AppLayout = () => { refreshCurrentFolder, openDocumentPreview, resolveThumbnailUrlForDoc, - tags, - handleTagCreate, handleDocumentTagAttach, handleTagRemove, ensureAssetUrl, getDocumentAsset, - buildTagPayload, + activeTagFilters, ], ); @@ -4685,9 +4713,9 @@ const DocumentsRoute = () => { if (workspaceMode === 'skeuo') { return ( -
+ -
+ ); } diff --git a/frontend/src/skeuomorphic_ws.css b/frontend/src/skeuomorphic_ws.css index c3c9ca1..3285bf4 100644 --- a/frontend/src/skeuomorphic_ws.css +++ b/frontend/src/skeuomorphic_ws.css @@ -10,6 +10,7 @@ flex: 1; display: flex; flex-direction: column; + grid-column: 2 / -1; min-height: 0; position: relative; background-color: var(--surface-subtle); @@ -192,68 +193,6 @@ opacity: 0.4; } -.skeuo-tag-shelf { - position: absolute; - top: 2.25rem; - right: 1rem; - display: flex; - flex-direction: column; - gap: 0.6rem; - align-items: flex-end; - max-height: calc(100% - 4rem); - overflow: auto; - padding: 0; - z-index: 100000; -} - -.skeuo-tag-shelf .skeuo-tag { - cursor: grab; -} - -.skeuo-tag-shelf .skeuo-tag:active { - cursor: grabbing; -} - -.skeuo-tag-shelf .skeuo-tag.is-inactive { - opacity: 0.25; -} - -.skeuo-tag-shelf .skeuo-tag.is-active { - opacity: 1; - box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.08); -} - -.skeuo-tag-add { - width: 2.4rem; - height: 2.4rem; - border-radius: 50%; - border: 1px solid rgba(0, 0, 0, 0.15); - background: rgba(255, 255, 255, 0.8); - color: var(--fg); - font-size: 1.4rem; - line-height: 1; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - margin-top: 0.4rem; - transition: background 0.2s ease, transform 0.2s ease; -} - -.skeuo-tag-add:hover { - background: rgba(255, 255, 255, 1); - transform: translateY(-1px); -} - -.skeuo-tag-add:active { - transform: translateY(0); -} - -.skeuo-tag-add:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; -} - body.skeuo-cursor-remove, body.skeuo-cursor-remove * { cursor: not-allowed !important; diff --git a/frontend/src/skeuomorphic_ws.jsx b/frontend/src/skeuomorphic_ws.jsx index bebfe5a..c94120f 100644 --- a/frontend/src/skeuomorphic_ws.jsx +++ b/frontend/src/skeuomorphic_ws.jsx @@ -7,7 +7,7 @@ import React, { useState, } from 'react'; import { resolveDocumentAssetUrl } from './asset_manager'; -import { generateRandomTagColor, getReadableTextColor } from './utils/colors'; +import { getReadableTextColor } from './utils/colors'; import './skeuomorphic_ws.css'; const ITEM_WIDTH = 220; @@ -31,7 +31,6 @@ const ZOOM_MIN_SCALE = 1.05; const ZOOM_MAX_SCALE = 5; const TAG_REMOVE_DISTANCE = 160; -const DRAG_PREVIEW_KEY = Symbol('dragPreview'); const DEBUG_DRAG = false; const DEBUG_FOCUS = true; const DEBUG_DROP = true; @@ -432,20 +431,17 @@ const SkeuomorphicWorkspace = ({ onRefresh, onDocumentOpen, resolveThumbnailUrl, - availableTags = [], - onCreateTag = null, onAssignTagToDocument = null, onRemoveTagFromDocument = null, ensureAssetUrl = null, getDocumentAsset = () => null, - prepareTagPayload = null, + activeTagIds = [], }) => { const items = useMemo(() => (searchResults ? searchResults : documents), [documents, searchResults]); const showingSearchResults = searchResults !== null; const containerRef = useRef(null); - const tagShelfRef = useRef(null); const layoutRef = useRef(new Map()); const itemRefs = useRef(new Map()); const zCounterRef = useRef(10); @@ -453,17 +449,27 @@ const SkeuomorphicWorkspace = ({ const [layoutSnapshot, setLayoutSnapshot] = useState(() => new Map()); const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 }); - const [tagShelfWidth, setTagShelfWidth] = useState(0); const [draggingId, setDraggingId] = useState(null); const [zoomedId, setZoomedId] = useState(null); const [tagDropTargetId, setTagDropTargetId] = useState(null); const [pendingTagDocId, setPendingTagDocId] = useState(null); const [pendingRemovalTag, setPendingRemovalTag] = useState(null); - const [activeShelfTagId, setActiveShelfTagId] = useState(null); const draggingTagRef = useRef(null); const pendingDocTagDragRef = useRef(null); const docSizeMapRef = useRef(new Map()); const removalCursorActiveRef = useRef(false); + const activeTagSet = useMemo(() => { + if (!Array.isArray(activeTagIds) || activeTagIds.length === 0) { + return new Set(); + } + const set = new Set(); + activeTagIds.forEach((id) => { + if (id != null) { + set.add(String(id)); + } + }); + return set; + }, [activeTagIds]); const resolvePreviewAsset = useCallback( (doc) => { @@ -591,72 +597,11 @@ const SkeuomorphicWorkspace = ({ ); }, []); - const handleShelfTagDragStart = useCallback((event, tag) => { - if (!tag) return; - try { - event.dataTransfer.effectAllowed = 'copy'; - const payload = JSON.stringify({ id: tag.id, label: tag.label }); - event.dataTransfer.setData('application/x-papercrate-tag', payload); - event.dataTransfer.setData('text/papercrate-tag', payload); - event.dataTransfer.setData('text/plain', tag.label || 'Tag'); - } catch (error) { - console.warn('Failed to initiate tag drag', error); - } - const node = event.currentTarget; - const hideNode = () => { - if (node instanceof HTMLElement) { - node.classList.add('is-drag-hidden'); - } - }; - if (node instanceof HTMLElement) { - if (node[DRAG_PREVIEW_KEY]) { - cleanupPreview(node[DRAG_PREVIEW_KEY]); - delete node[DRAG_PREVIEW_KEY]; - } - const preview = createDragPreview(node, event.clientX, event.clientY); - if (preview && event.dataTransfer) { - try { - event.dataTransfer.setDragImage(preview.clone, preview.offsetX, preview.offsetY); - node[DRAG_PREVIEW_KEY] = preview.clone; - } catch ( - // eslint-disable-next-line no-empty - error - ) {} - } - } - if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') { - window.requestAnimationFrame(hideNode); - } else { - setTimeout(hideNode, 0); - } - }, []); - const handleTagDragEnd = useCallback(() => { updateRemovalCursor(false); setTagDropTargetId(null); }, [updateRemovalCursor]); - const handleShelfTagDragEndWithReset = useCallback((event) => { - if (event?.currentTarget instanceof HTMLElement) { - const target = event.currentTarget; - const showNode = () => { - if (target instanceof HTMLElement) { - target.classList.remove('is-drag-hidden'); - const stored = target[DRAG_PREVIEW_KEY]; - cleanupPreview(stored); - delete target[DRAG_PREVIEW_KEY]; - } - }; - if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') { - window.requestAnimationFrame(showNode); - } else { - setTimeout(showNode, 0); - } - } - handleTagDragEnd(); - queueFocusCanvas(); - }, [handleTagDragEnd, queueFocusCanvas]); - const ensureDocumentSize = useCallback((doc) => { const key = resolveSizeKey(doc); const cache = docSizeMapRef.current.get(key); @@ -721,17 +666,6 @@ const SkeuomorphicWorkspace = ({ docSizeMapRef.current = new Map(); }, [items]); - useEffect(() => { - if (!activeShelfTagId) { - return; - } - const stillExists = availableTags.some((tag) => resolveTagKey(tag) === activeShelfTagId); - if (!stillExists) { - setActiveShelfTagId(null); - } - }, [activeShelfTagId, availableTags]); - - const resolveZoomMetrics = useCallback( (doc, cardWidth, cardHeight) => { const canvasWidth = canvasSize.width || DEFAULT_CANVAS_WIDTH; @@ -821,30 +755,6 @@ const SkeuomorphicWorkspace = ({ [resolvePreviewDimensions], ); - useLayoutEffect(() => { - const shelfNode = tagShelfRef.current; - if (!shelfNode || !availableTags.length) { - setTagShelfWidth(0); - return () => {}; - } - - const measure = () => { - const rect = shelfNode.getBoundingClientRect(); - setTagShelfWidth(Math.ceil(rect.width)); - }; - - measure(); - - if (typeof ResizeObserver === 'undefined') { - window.addEventListener('resize', measure); - return () => window.removeEventListener('resize', measure); - } - - const observer = new ResizeObserver(() => measure()); - observer.observe(shelfNode); - return () => observer.disconnect(); - }, [availableTags.length]); - const syncLayoutSnapshot = useCallback(() => { setLayoutSnapshot(new Map(layoutRef.current)); }, []); @@ -950,7 +860,7 @@ const SkeuomorphicWorkspace = ({ startZ: maxZ, rotationRange: ROTATION_RANGE, minSpacing: 48, - shelfWidth: tagShelfWidth > 0 ? tagShelfWidth + CANVAS_PADDING : 0, + shelfWidth: 0, }, ); generatedLayout.forEach((entry, docId) => { @@ -971,7 +881,6 @@ const SkeuomorphicWorkspace = ({ canvasSize.height, ensureDocumentSize, syncLayoutSnapshot, - tagShelfWidth, ]); useEffect(() => { @@ -1150,7 +1059,7 @@ const SkeuomorphicWorkspace = ({ setPendingTagDocId(doc.id); try { - await onAssignTagToDocument({ documentId: doc.id, tagId }); + await onAssignTagToDocument({ documentId: doc.id, tagId, tag: payload }); markActiveTagDropHandled(tagId, sourceDocId); if (DEBUG_DROP) { console.log('[skeuo] handleTagDropOnDoc: assigned tag', tagId, 'to doc', doc.id); @@ -1504,7 +1413,6 @@ const SkeuomorphicWorkspace = ({
resolveTagKey(tag)) .filter(Boolean); const matchesFilter = - !activeShelfTagId || docTagKeys.includes(activeShelfTagId); + activeTagSet.size === 0 || docTagKeys.some((key) => activeTagSet.has(key)); const dropActive = tagDropTargetId === doc.id; const dropPending = pendingTagDocId === doc.id; const itemClasses = ['skeuo-item']; @@ -1674,81 +1582,6 @@ const SkeuomorphicWorkspace = ({ ); }) )} - {(availableTags.length > 0 || onCreateTag) && ( -
- {availableTags.map((tag) => { - const colorValue = normalizeColor(tag.color); - const foreground = getContrastingTextColor(colorValue || '#1b1f24'); - const tagStyle = colorValue - ? { backgroundColor: colorValue, color: foreground } - : undefined; - const tagKey = resolveTagKey(tag); - const isSelected = activeShelfTagId === tagKey; - const shelfTagClasses = ['skeuo-tag']; - if (isSelected) { - shelfTagClasses.push('is-active'); - } else if (activeShelfTagId) { - shelfTagClasses.push('is-inactive'); - } - return ( -
handleShelfTagDragStart(event, tag)} - onDragEnd={handleShelfTagDragEndWithReset} - role="button" - onClick={(event) => { - event.preventDefault(); - event.stopPropagation(); - setActiveShelfTagId((current) => (current === tagKey ? null : tagKey)); - }} - onKeyDown={(event) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - setActiveShelfTagId((current) => (current === tagKey ? null : tagKey)); - } - }} - > - {tag.label} -
- ); - })} - {typeof onCreateTag === 'function' && ( - - )} -
- )}
); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 9f4bd1b..9e35802 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -749,7 +749,7 @@ button.icon-button.ghost:hover:not([disabled]) { } .column + .column { - border-left: 1px solid var(--border); + border-left: none; padding-left: 1.25rem; } @@ -940,6 +940,7 @@ button.icon-button.ghost:hover:not([disabled]) { overflow-y: auto; padding: 1.25rem; color: var(--sidebar-fg); + border-right: 1px solid var(--border); } .sidebar-section:first-of-type,