frontend: make skeuomorphic workspace compatible with sidebar
This commit is contained in:
@@ -430,6 +430,7 @@ const DocumentsTable = ({
|
|||||||
className={cardClasses.join(' ')}
|
className={cardClasses.join(' ')}
|
||||||
role="listitem"
|
role="listitem"
|
||||||
id={`document-card-${doc.id}`}
|
id={`document-card-${doc.id}`}
|
||||||
|
data-doc-id={doc.id}
|
||||||
onClick={(event) => onDocumentRowClick(doc.id, event)}
|
onClick={(event) => onDocumentRowClick(doc.id, event)}
|
||||||
onDoubleClick={() => onDocumentOpen(doc.id)}
|
onDoubleClick={() => onDocumentOpen(doc.id)}
|
||||||
draggable
|
draggable
|
||||||
@@ -646,6 +647,7 @@ const DocumentsTable = ({
|
|||||||
key={doc.id}
|
key={doc.id}
|
||||||
className={rowClasses.join(' ')}
|
className={rowClasses.join(' ')}
|
||||||
id={`document-row-${doc.id}`}
|
id={`document-row-${doc.id}`}
|
||||||
|
data-doc-id={doc.id}
|
||||||
onClick={(event) => onDocumentRowClick(doc.id, event)}
|
onClick={(event) => onDocumentRowClick(doc.id, event)}
|
||||||
onDoubleClick={() => onDocumentOpen(doc.id)}
|
onDoubleClick={() => onDocumentOpen(doc.id)}
|
||||||
draggable
|
draggable
|
||||||
|
|||||||
+44
-16
@@ -3300,13 +3300,38 @@ const AppLayout = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleDocumentTagAttach = useCallback(
|
const handleDocumentTagAttach = useCallback(
|
||||||
async ({ documentId, tagId }) => {
|
async ({ documentId, tagId, tag: tagData = null }) => {
|
||||||
if (!documentId || !tagId) {
|
if (!documentId || !tagId) {
|
||||||
return false;
|
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 {
|
try {
|
||||||
await api.post(`/documents/${documentId}/tags`, { tag_ids: [tagId] });
|
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');
|
setStatusMessage('Tag assigned.', 'success');
|
||||||
await refreshCurrentFolder();
|
await refreshCurrentFolder();
|
||||||
return true;
|
return true;
|
||||||
@@ -3316,7 +3341,14 @@ const AppLayout = () => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[api, refreshCurrentFolder, notifyApiError, setStatusMessage],
|
[
|
||||||
|
api,
|
||||||
|
refreshCurrentFolder,
|
||||||
|
notifyApiError,
|
||||||
|
setStatusMessage,
|
||||||
|
updateDocumentCaches,
|
||||||
|
tagLookupById,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDocumentTagDrop = useCallback(
|
const handleDocumentTagDrop = useCallback(
|
||||||
@@ -3329,7 +3361,7 @@ const AppLayout = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const attached = await handleDocumentTagAttach({ documentId, tagId: tag.id });
|
const attached = await handleDocumentTagAttach({ documentId, tagId: tag.id, tag });
|
||||||
if (!attached) {
|
if (!attached) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3794,14 +3826,14 @@ const AppLayout = () => {
|
|||||||
return TAG_MIME_TYPES.some((type) => Array.from(types).includes(type));
|
return TAG_MIME_TYPES.some((type) => Array.from(types).includes(type));
|
||||||
};
|
};
|
||||||
|
|
||||||
const isDocumentRowTarget = (target) =>
|
const isDocumentDropTarget = (target) =>
|
||||||
target instanceof Element ? Boolean(target.closest('tr.document')) : false;
|
target instanceof Element ? Boolean(target.closest('[data-doc-id]')) : false;
|
||||||
|
|
||||||
const handleTagDragOver = (event) => {
|
const handleTagDragOver = (event) => {
|
||||||
if (!isTagTransfer(event)) {
|
if (!isTagTransfer(event)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isDocumentRowTarget(event.target)) {
|
if (isDocumentDropTarget(event.target)) {
|
||||||
setTagRemovalCursor(false);
|
setTagRemovalCursor(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3816,7 +3848,7 @@ const AppLayout = () => {
|
|||||||
}
|
}
|
||||||
const related = event.relatedTarget;
|
const related = event.relatedTarget;
|
||||||
if (related instanceof Element && host.contains(related)) {
|
if (related instanceof Element && host.contains(related)) {
|
||||||
if (isDocumentRowTarget(related)) {
|
if (isDocumentDropTarget(related)) {
|
||||||
setTagRemovalCursor(false);
|
setTagRemovalCursor(false);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -3829,7 +3861,7 @@ const AppLayout = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setTagRemovalCursor(false);
|
setTagRemovalCursor(false);
|
||||||
if (isDocumentRowTarget(event.target) || event.defaultPrevented) {
|
if (isDocumentDropTarget(event.target) || event.defaultPrevented) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -4415,13 +4447,11 @@ const AppLayout = () => {
|
|||||||
onRefresh: refreshCurrentFolder,
|
onRefresh: refreshCurrentFolder,
|
||||||
onDocumentOpen: openDocumentPreview,
|
onDocumentOpen: openDocumentPreview,
|
||||||
resolveThumbnailUrl: resolveThumbnailUrlForDoc,
|
resolveThumbnailUrl: resolveThumbnailUrlForDoc,
|
||||||
availableTags: tags,
|
|
||||||
onCreateTag: handleTagCreate,
|
|
||||||
onAssignTagToDocument: handleDocumentTagAttach,
|
onAssignTagToDocument: handleDocumentTagAttach,
|
||||||
onRemoveTagFromDocument: handleTagRemove,
|
onRemoveTagFromDocument: handleTagRemove,
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
prepareTagPayload: buildTagPayload,
|
activeTagIds: activeTagFilters,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
documents,
|
documents,
|
||||||
@@ -4432,13 +4462,11 @@ const AppLayout = () => {
|
|||||||
refreshCurrentFolder,
|
refreshCurrentFolder,
|
||||||
openDocumentPreview,
|
openDocumentPreview,
|
||||||
resolveThumbnailUrlForDoc,
|
resolveThumbnailUrlForDoc,
|
||||||
tags,
|
|
||||||
handleTagCreate,
|
|
||||||
handleDocumentTagAttach,
|
handleDocumentTagAttach,
|
||||||
handleTagRemove,
|
handleTagRemove,
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
buildTagPayload,
|
activeTagFilters,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -4685,9 +4713,9 @@ const DocumentsRoute = () => {
|
|||||||
|
|
||||||
if (workspaceMode === 'skeuo') {
|
if (workspaceMode === 'skeuo') {
|
||||||
return (
|
return (
|
||||||
<main className="skeuo-main">
|
<DocumentsLayout sidebarProps={sidebarProps}>
|
||||||
<SkeuomorphicWorkspace {...skeuoWorkspaceProps} />
|
<SkeuomorphicWorkspace {...skeuoWorkspaceProps} />
|
||||||
</main>
|
</DocumentsLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
grid-column: 2 / -1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
background-color: var(--surface-subtle);
|
background-color: var(--surface-subtle);
|
||||||
@@ -192,68 +193,6 @@
|
|||||||
opacity: 0.4;
|
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,
|
||||||
body.skeuo-cursor-remove * {
|
body.skeuo-cursor-remove * {
|
||||||
cursor: not-allowed !important;
|
cursor: not-allowed !important;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import React, {
|
|||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { resolveDocumentAssetUrl } from './asset_manager';
|
import { resolveDocumentAssetUrl } from './asset_manager';
|
||||||
import { generateRandomTagColor, getReadableTextColor } from './utils/colors';
|
import { getReadableTextColor } from './utils/colors';
|
||||||
import './skeuomorphic_ws.css';
|
import './skeuomorphic_ws.css';
|
||||||
|
|
||||||
const ITEM_WIDTH = 220;
|
const ITEM_WIDTH = 220;
|
||||||
@@ -31,7 +31,6 @@ const ZOOM_MIN_SCALE = 1.05;
|
|||||||
const ZOOM_MAX_SCALE = 5;
|
const ZOOM_MAX_SCALE = 5;
|
||||||
const TAG_REMOVE_DISTANCE = 160;
|
const TAG_REMOVE_DISTANCE = 160;
|
||||||
|
|
||||||
const DRAG_PREVIEW_KEY = Symbol('dragPreview');
|
|
||||||
const DEBUG_DRAG = false;
|
const DEBUG_DRAG = false;
|
||||||
const DEBUG_FOCUS = true;
|
const DEBUG_FOCUS = true;
|
||||||
const DEBUG_DROP = true;
|
const DEBUG_DROP = true;
|
||||||
@@ -432,20 +431,17 @@ const SkeuomorphicWorkspace = ({
|
|||||||
onRefresh,
|
onRefresh,
|
||||||
onDocumentOpen,
|
onDocumentOpen,
|
||||||
resolveThumbnailUrl,
|
resolveThumbnailUrl,
|
||||||
availableTags = [],
|
|
||||||
onCreateTag = null,
|
|
||||||
onAssignTagToDocument = null,
|
onAssignTagToDocument = null,
|
||||||
onRemoveTagFromDocument = null,
|
onRemoveTagFromDocument = null,
|
||||||
ensureAssetUrl = null,
|
ensureAssetUrl = null,
|
||||||
getDocumentAsset = () => null,
|
getDocumentAsset = () => null,
|
||||||
prepareTagPayload = null,
|
activeTagIds = [],
|
||||||
}) => {
|
}) => {
|
||||||
const items = useMemo(() => (searchResults ? searchResults : documents), [documents, searchResults]);
|
const items = useMemo(() => (searchResults ? searchResults : documents), [documents, searchResults]);
|
||||||
const showingSearchResults = searchResults !== null;
|
const showingSearchResults = searchResults !== null;
|
||||||
|
|
||||||
|
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
const tagShelfRef = useRef(null);
|
|
||||||
const layoutRef = useRef(new Map());
|
const layoutRef = useRef(new Map());
|
||||||
const itemRefs = useRef(new Map());
|
const itemRefs = useRef(new Map());
|
||||||
const zCounterRef = useRef(10);
|
const zCounterRef = useRef(10);
|
||||||
@@ -453,17 +449,27 @@ const SkeuomorphicWorkspace = ({
|
|||||||
const [layoutSnapshot, setLayoutSnapshot] = useState(() => new Map());
|
const [layoutSnapshot, setLayoutSnapshot] = useState(() => new Map());
|
||||||
|
|
||||||
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
|
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
|
||||||
const [tagShelfWidth, setTagShelfWidth] = useState(0);
|
|
||||||
const [draggingId, setDraggingId] = useState(null);
|
const [draggingId, setDraggingId] = useState(null);
|
||||||
const [zoomedId, setZoomedId] = useState(null);
|
const [zoomedId, setZoomedId] = useState(null);
|
||||||
const [tagDropTargetId, setTagDropTargetId] = useState(null);
|
const [tagDropTargetId, setTagDropTargetId] = useState(null);
|
||||||
const [pendingTagDocId, setPendingTagDocId] = useState(null);
|
const [pendingTagDocId, setPendingTagDocId] = useState(null);
|
||||||
const [pendingRemovalTag, setPendingRemovalTag] = useState(null);
|
const [pendingRemovalTag, setPendingRemovalTag] = useState(null);
|
||||||
const [activeShelfTagId, setActiveShelfTagId] = useState(null);
|
|
||||||
const draggingTagRef = useRef(null);
|
const draggingTagRef = useRef(null);
|
||||||
const pendingDocTagDragRef = useRef(null);
|
const pendingDocTagDragRef = useRef(null);
|
||||||
const docSizeMapRef = useRef(new Map());
|
const docSizeMapRef = useRef(new Map());
|
||||||
const removalCursorActiveRef = useRef(false);
|
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(
|
const resolvePreviewAsset = useCallback(
|
||||||
(doc) => {
|
(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(() => {
|
const handleTagDragEnd = useCallback(() => {
|
||||||
updateRemovalCursor(false);
|
updateRemovalCursor(false);
|
||||||
setTagDropTargetId(null);
|
setTagDropTargetId(null);
|
||||||
}, [updateRemovalCursor]);
|
}, [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 ensureDocumentSize = useCallback((doc) => {
|
||||||
const key = resolveSizeKey(doc);
|
const key = resolveSizeKey(doc);
|
||||||
const cache = docSizeMapRef.current.get(key);
|
const cache = docSizeMapRef.current.get(key);
|
||||||
@@ -721,17 +666,6 @@ const SkeuomorphicWorkspace = ({
|
|||||||
docSizeMapRef.current = new Map();
|
docSizeMapRef.current = new Map();
|
||||||
}, [items]);
|
}, [items]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!activeShelfTagId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const stillExists = availableTags.some((tag) => resolveTagKey(tag) === activeShelfTagId);
|
|
||||||
if (!stillExists) {
|
|
||||||
setActiveShelfTagId(null);
|
|
||||||
}
|
|
||||||
}, [activeShelfTagId, availableTags]);
|
|
||||||
|
|
||||||
|
|
||||||
const resolveZoomMetrics = useCallback(
|
const resolveZoomMetrics = useCallback(
|
||||||
(doc, cardWidth, cardHeight) => {
|
(doc, cardWidth, cardHeight) => {
|
||||||
const canvasWidth = canvasSize.width || DEFAULT_CANVAS_WIDTH;
|
const canvasWidth = canvasSize.width || DEFAULT_CANVAS_WIDTH;
|
||||||
@@ -821,30 +755,6 @@ const SkeuomorphicWorkspace = ({
|
|||||||
[resolvePreviewDimensions],
|
[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(() => {
|
const syncLayoutSnapshot = useCallback(() => {
|
||||||
setLayoutSnapshot(new Map(layoutRef.current));
|
setLayoutSnapshot(new Map(layoutRef.current));
|
||||||
}, []);
|
}, []);
|
||||||
@@ -950,7 +860,7 @@ const SkeuomorphicWorkspace = ({
|
|||||||
startZ: maxZ,
|
startZ: maxZ,
|
||||||
rotationRange: ROTATION_RANGE,
|
rotationRange: ROTATION_RANGE,
|
||||||
minSpacing: 48,
|
minSpacing: 48,
|
||||||
shelfWidth: tagShelfWidth > 0 ? tagShelfWidth + CANVAS_PADDING : 0,
|
shelfWidth: 0,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
generatedLayout.forEach((entry, docId) => {
|
generatedLayout.forEach((entry, docId) => {
|
||||||
@@ -971,7 +881,6 @@ const SkeuomorphicWorkspace = ({
|
|||||||
canvasSize.height,
|
canvasSize.height,
|
||||||
ensureDocumentSize,
|
ensureDocumentSize,
|
||||||
syncLayoutSnapshot,
|
syncLayoutSnapshot,
|
||||||
tagShelfWidth,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1150,7 +1059,7 @@ const SkeuomorphicWorkspace = ({
|
|||||||
|
|
||||||
setPendingTagDocId(doc.id);
|
setPendingTagDocId(doc.id);
|
||||||
try {
|
try {
|
||||||
await onAssignTagToDocument({ documentId: doc.id, tagId });
|
await onAssignTagToDocument({ documentId: doc.id, tagId, tag: payload });
|
||||||
markActiveTagDropHandled(tagId, sourceDocId);
|
markActiveTagDropHandled(tagId, sourceDocId);
|
||||||
if (DEBUG_DROP) {
|
if (DEBUG_DROP) {
|
||||||
console.log('[skeuo] handleTagDropOnDoc: assigned tag', tagId, 'to doc', doc.id);
|
console.log('[skeuo] handleTagDropOnDoc: assigned tag', tagId, 'to doc', doc.id);
|
||||||
@@ -1504,7 +1413,6 @@ const SkeuomorphicWorkspace = ({
|
|||||||
<div
|
<div
|
||||||
className="skeuo-canvas"
|
className="skeuo-canvas"
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
data-active-tag={activeShelfTagId || undefined}
|
|
||||||
onDragOver={handleCanvasDragOver}
|
onDragOver={handleCanvasDragOver}
|
||||||
onDragLeave={handleCanvasDragLeave}
|
onDragLeave={handleCanvasDragLeave}
|
||||||
onDrop={handleCanvasDrop}
|
onDrop={handleCanvasDrop}
|
||||||
@@ -1572,7 +1480,7 @@ const SkeuomorphicWorkspace = ({
|
|||||||
.map((tag) => resolveTagKey(tag))
|
.map((tag) => resolveTagKey(tag))
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
const matchesFilter =
|
const matchesFilter =
|
||||||
!activeShelfTagId || docTagKeys.includes(activeShelfTagId);
|
activeTagSet.size === 0 || docTagKeys.some((key) => activeTagSet.has(key));
|
||||||
const dropActive = tagDropTargetId === doc.id;
|
const dropActive = tagDropTargetId === doc.id;
|
||||||
const dropPending = pendingTagDocId === doc.id;
|
const dropPending = pendingTagDocId === doc.id;
|
||||||
const itemClasses = ['skeuo-item'];
|
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -749,7 +749,7 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.column + .column {
|
.column + .column {
|
||||||
border-left: 1px solid var(--border);
|
border-left: none;
|
||||||
padding-left: 1.25rem;
|
padding-left: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -940,6 +940,7 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
color: var(--sidebar-fg);
|
color: var(--sidebar-fg);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-section:first-of-type,
|
.sidebar-section:first-of-type,
|
||||||
|
|||||||
Reference in New Issue
Block a user