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,