frontend: make skeuomorphic workspace compatible with sidebar
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 14m32s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 14m31s

This commit is contained in:
2025-10-19 23:05:38 +02:00
parent 3591415d46
commit 9a77e76ff4
5 changed files with 66 additions and 263 deletions
@@ -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
+44 -16
View File
@@ -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 (
<main className="skeuo-main">
<DocumentsLayout sidebarProps={sidebarProps}>
<SkeuomorphicWorkspace {...skeuoWorkspaceProps} />
</main>
</DocumentsLayout>
);
}
+1 -62
View File
@@ -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;
+17 -184
View File
@@ -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 = ({
<div
className="skeuo-canvas"
ref={containerRef}
data-active-tag={activeShelfTagId || undefined}
onDragOver={handleCanvasDragOver}
onDragLeave={handleCanvasDragLeave}
onDrop={handleCanvasDrop}
@@ -1572,7 +1480,7 @@ const SkeuomorphicWorkspace = ({
.map((tag) => 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) && (
<div
className="skeuo-tag-shelf"
aria-label="Available tags"
ref={tagShelfRef}
>
{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 (
<div
key={tag.id || tag.label}
className={shelfTagClasses.join(' ')}
style={tagStyle}
title={tag.label}
draggable
data-tag-id={tagKey || undefined}
onDragStart={(event) => 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));
}
}}
>
<span>{tag.label}</span>
</div>
);
})}
{typeof onCreateTag === 'function' && (
<button
type="button"
className="skeuo-tag-add"
aria-label="Add tag"
onClick={async () => {
const labelInput = window.prompt('New tag name?');
const label = labelInput ? labelInput.trim() : '';
if (!label) {
return;
}
try {
const payload =
typeof prepareTagPayload === 'function'
? prepareTagPayload({ label })
: { label, color: generateRandomTagColor() };
await onCreateTag(payload);
setActiveShelfTagId(null);
} catch (error) {
console.error('Failed to create tag', error);
}
}}
>
+
</button>
)}
</div>
)}
</div>
</div>
);
+2 -1
View File
@@ -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,