frontend: make skeuomorphic workspace compatible with sidebar
This commit is contained in:
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user