Files
papercrate/frontend/src/skeuomorphic_ws.jsx
T
nils 2713dcb047
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 15m15s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 15m14s
Initial commit
2025-10-17 18:07:39 +02:00

1758 lines
56 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { resolveDocumentAssetUrl } from './asset_manager';
import { generateRandomTagColor, getReadableTextColor } from './utils/colors';
import './skeuomorphic_ws.css';
const ITEM_WIDTH = 220;
const ITEM_HEIGHT = 260;
const CANVAS_PADDING = 24;
const ROTATION_RANGE = 7;
const JITTER_X = 26;
const JITTER_Y = 32;
const DEFAULT_CANVAS_WIDTH = 1024;
const DEFAULT_CANVAS_HEIGHT = 680;
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
const resolveSizeKey = (doc) =>
doc?.id || doc?.document_id || doc?.uuid || doc?.original_name || doc?.title || 'doc';
const CARD_MIN = 240;
const CARD_MAX = 340;
const EMPTY_CARD_ASPECT = 1.4;
const ZOOM_FILL_RATIO = 0.99;
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;
const resolveTagKey = (tag) => {
if (!tag) {
return null;
}
const key = tag.id ?? tag.uuid ?? tag.slug ?? tag.label;
return key != null ? String(key) : null;
};
const generateInitialLayout = (
entries,
{
canvasWidth,
canvasHeight,
padding,
startZ = 0,
rotationRange = ROTATION_RANGE,
minSpacing = 48,
shelfWidth = 0,
},
) => {
const layout = new Map();
let currentZ = startZ;
let maxZ = startZ;
if (!entries.length) {
return { layout, maxZ };
}
const shelfOffset = Math.max(shelfWidth, 0);
const usableWidth = Math.max(canvasWidth - shelfOffset - padding * 2, 1);
const usableHeight = Math.max(canvasHeight - padding * 2, 1);
const spacingBuffer = Math.max(minSpacing, 0);
const placed = [];
const resolveBounds = (width, height) => {
const halfWidth = width / 2;
const halfHeight = height / 2;
return {
minCenterX: padding + halfWidth,
maxCenterX: Math.max(
padding + halfWidth,
canvasWidth - shelfOffset - padding - halfWidth,
),
minCenterY: padding + halfHeight,
maxCenterY: Math.max(padding + halfHeight, canvasHeight - padding - halfHeight),
};
};
const evaluateCandidateSpacing = (x, y, radius) => {
if (!placed.length) {
return Number.POSITIVE_INFINITY;
}
let best = Number.POSITIVE_INFINITY;
for (let i = 0; i < placed.length; i += 1) {
const item = placed[i];
const dx = item.x - x;
const dy = item.y - y;
const distance = Math.sqrt(dx * dx + dy * dy) - item.radius - radius - spacingBuffer;
if (distance < best) {
best = distance;
}
}
return best;
};
entries.forEach((entry, index) => {
const width = Number(entry.width) || 0;
const height = Number(entry.height) || 0;
if (!entry.id || width <= 0 || height <= 0) {
return;
}
const { minCenterX, maxCenterX, minCenterY, maxCenterY } = resolveBounds(width, height);
const radius = Math.sqrt(width * width + height * height) / 2;
let bestScore = -Infinity;
let bestX = (minCenterX + maxCenterX) / 2;
let bestY = (minCenterY + maxCenterY) / 2;
const samplesPerAxis = 14;
for (let gx = 0; gx < samplesPerAxis; gx += 1) {
const fracX = (gx + 0.5) / samplesPerAxis;
for (let gy = 0; gy < samplesPerAxis; gy += 1) {
const fracY = (gy + 0.5) / samplesPerAxis;
const candidateX = minCenterX + fracX * (maxCenterX - minCenterX);
const candidateY = minCenterY + fracY * (maxCenterY - minCenterY);
const edgeSpacing = Math.min(
candidateX - minCenterX,
maxCenterX - candidateX,
candidateY - minCenterY,
maxCenterY - candidateY,
) - spacingBuffer * 0.5;
if (edgeSpacing <= 0) {
continue;
}
const neighborSpacing = evaluateCandidateSpacing(candidateX, candidateY, radius);
const score = Math.min(edgeSpacing, neighborSpacing);
if (score > bestScore) {
bestScore = score;
bestX = candidateX;
bestY = candidateY;
}
}
}
const centerX = clamp(bestX, minCenterX, maxCenterX);
const centerY = clamp(bestY, minCenterY, maxCenterY);
const rotation = randomRangeFromSeed(
buildKey(entry.id, 'rotation'),
-rotationRange,
rotationRange,
);
currentZ += 1;
layout.set(entry.id, {
centerX,
centerY,
rotation,
z: currentZ,
});
maxZ = Math.max(maxZ, currentZ);
placed.push({ x: centerX, y: centerY, radius });
});
return { layout, maxZ };
};
const createDragPreview = (node, clientX, clientY) => {
if (!(node instanceof HTMLElement)) {
return null;
}
const rect = node.getBoundingClientRect();
const safeClientX = Number.isFinite(clientX) ? clientX : rect.left + rect.width / 2;
const safeClientY = Number.isFinite(clientY) ? clientY : rect.top + rect.height / 2;
const offsetX = clamp(safeClientX - rect.left, 0, rect.width);
const offsetY = clamp(safeClientY - rect.top, 0, rect.height);
const clone = node.cloneNode(true);
clone.style.position = 'absolute';
clone.style.top = '-9999px';
clone.style.left = '-9999px';
clone.style.pointerEvents = 'none';
clone.style.opacity = '1';
clone.style.transform = 'none';
document.body.appendChild(clone);
return { clone, offsetX, offsetY };
};
const cleanupPreview = (previewNode) => {
if (previewNode && previewNode.parentNode) {
previewNode.parentNode.removeChild(previewNode);
}
};
const useDocumentDrag = ({
layoutRef,
itemRefs,
documentLookup,
ensureDocumentSize,
resolveBaseMetrics,
bringToFront,
setDraggingId,
syncLayoutSnapshot,
canvasSize,
toggleZoom,
}) => {
const dragStateRef = useRef(null);
const finishDrag = useCallback(
(pointerId) => {
const state = dragStateRef.current;
if (!state || state.pointerId !== pointerId) {
return;
}
dragStateRef.current = null;
setDraggingId((current) => (current === state.docId ? null : current));
syncLayoutSnapshot();
},
[setDraggingId, syncLayoutSnapshot],
);
const handlePointerDown = useCallback(
(event, docId, { lockWhenZoomed = false } = {}) => {
if (DEBUG_DRAG) {
console.log(
'[skeuo] handlePointerDown fired for doc',
docId,
'button',
event.button,
'pointerType',
event.pointerType,
'pointerId',
event.pointerId,
);
}
event.preventDefault();
const entry = layoutRef.current.get(docId) || null;
const doc = documentLookup.get(docId) || null;
const { width: docWidth, height: docHeight } = ensureDocumentSize(doc);
const { baseScale } = resolveBaseMetrics(doc, docWidth, docHeight);
const defaultCenterX = CANVAS_PADDING + docWidth / 2;
const defaultCenterY = CANVAS_PADDING + docHeight / 2;
const centerX = typeof entry?.centerX === 'number' ? entry.centerX : defaultCenterX;
const centerY = typeof entry?.centerY === 'number' ? entry.centerY : defaultCenterY;
if (entry && (entry.centerX !== centerX || entry.centerY !== centerY)) {
layoutRef.current.set(docId, { ...entry, centerX, centerY });
}
bringToFront(docId);
dragStateRef.current = {
docId,
pointerId: event.pointerId,
originCenterX: centerX,
originCenterY: centerY,
startX: event.clientX,
startY: event.clientY,
rotation: entry?.rotation ?? 0,
moved: false,
locked: Boolean(lockWhenZoomed),
width: docWidth,
height: docHeight,
scale: baseScale,
};
setDraggingId(docId);
},
[bringToFront, documentLookup, ensureDocumentSize, layoutRef, resolveBaseMetrics, setDraggingId],
);
const handlePointerMove = useCallback(
(event) => {
const state = dragStateRef.current;
if (!state) {
if (DEBUG_DRAG) {
console.log('[skeuo] handlePointerMove: no drag state for pointer', event.pointerId);
}
return;
}
if (state.pointerId !== event.pointerId) {
if (DEBUG_DRAG) {
console.log(
'[skeuo] handlePointerMove: pointer mismatch expected',
state.pointerId,
'got',
event.pointerId,
);
}
return;
}
event.preventDefault();
if (state.locked) {
if (DEBUG_DRAG) {
console.log('[skeuo] handlePointerMove: locked drag for doc', state.docId);
}
return;
}
const entry = layoutRef.current.get(state.docId);
if (!entry) {
return;
}
const deltaX = event.clientX - state.startX;
const deltaY = event.clientY - state.startY;
const nextCenterX = state.originCenterX + deltaX;
const nextCenterY = state.originCenterY + deltaY;
const docWidth = state.width;
const docHeight = state.height;
const halfWidth = docWidth / 2;
const halfHeight = docHeight / 2;
const canvasWidth = canvasSize.width || DEFAULT_CANVAS_WIDTH;
const canvasHeight = canvasSize.height || DEFAULT_CANVAS_HEIGHT;
const minCenterX = CANVAS_PADDING + halfWidth;
const maxCenterX = Math.max(minCenterX, canvasWidth - CANVAS_PADDING - halfWidth);
const minCenterY = CANVAS_PADDING + halfHeight;
const maxCenterY = Math.max(minCenterY, canvasHeight - CANVAS_PADDING - halfHeight);
const clampedCenterX = clamp(nextCenterX, minCenterX, maxCenterX);
const clampedCenterY = clamp(nextCenterY, minCenterY, maxCenterY);
const prevCenterX = typeof entry.centerX === 'number' ? entry.centerX : state.originCenterX;
const prevCenterY = typeof entry.centerY === 'number' ? entry.centerY : state.originCenterY;
if (Math.abs(clampedCenterX - prevCenterX) < 0.5 && Math.abs(clampedCenterY - prevCenterY) < 0.5) {
if (DEBUG_DRAG) {
console.log('[skeuo] handlePointerMove: movement under threshold for doc', state.docId);
}
return;
}
const updated = { ...entry, centerX: clampedCenterX, centerY: clampedCenterY };
layoutRef.current.set(state.docId, updated);
const node = itemRefs.current.get(state.docId);
if (node) {
node.style.transform = formatTransform(
clampedCenterX,
clampedCenterY,
state.rotation,
state.scale || 1,
);
}
state.moved = true;
if (DEBUG_DRAG) {
console.log('[skeuo] handlePointerMove: moved doc', state.docId, 'to', clampedCenterX, clampedCenterY);
}
},
[canvasSize.height, canvasSize.width, itemRefs, layoutRef],
);
const handlePointerUp = useCallback(
(event) => {
const state = dragStateRef.current;
if (state && state.pointerId === event.pointerId) {
const moved = Boolean(state.moved);
const docId = state.docId;
finishDrag(event.pointerId);
if (!moved) {
toggleZoom(docId);
}
return;
}
finishDrag(event.pointerId);
},
[finishDrag, toggleZoom],
);
const handlePointerCancel = useCallback(
(event) => {
finishDrag(event.pointerId);
},
[finishDrag],
);
return {
handlePointerDown,
handlePointerMove,
handlePointerUp,
handlePointerCancel,
};
};
const seededRandom = (input) => {
const text = String(input);
let hash = 2166136261;
for (let index = 0; index < text.length; index += 1) {
hash ^= text.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0) / 4294967295;
};
const randomRangeFromSeed = (seedKey, min, max) => {
const span = max - min;
if (span <= 0) return min;
const seed = seededRandom(seedKey);
return min + seed * span;
};
const buildKey = (docId, suffix) => `${docId}::${suffix}`;
const clamp = (value, min, max) => {
if (value < min) return min;
if (value > max) return max;
return value;
};
const formatTransform = (x, y, rotation = 0, scale = 1) =>
`translate3d(${x}px, ${y}px, 0) rotate(${rotation}deg) scale(${scale})`;
const normalizeColor = (input) => {
if (!input) return null;
const value = String(input).trim();
if (!value) return null;
if (/^#(?:[0-9a-fA-F]{3}){1,2}$/.test(value)) {
return value.length === 4
? `#${value[1]}${value[1]}${value[2]}${value[2]}${value[3]}${value[3]}`
: value;
}
if (/^[0-9a-fA-F]{6}$/.test(value)) {
return `#${value}`;
}
return null;
};
const getContrastingTextColor = (hex) => getReadableTextColor(hex, { light: '#1f2125' });
const SkeuomorphicWorkspace = ({
documents = [],
searchResults = null,
breadcrumbs = [],
currentFolderName = 'Folder',
onExit,
onRefresh,
onDocumentOpen,
resolveThumbnailUrl,
availableTags = [],
onCreateTag = null,
onAssignTagToDocument = null,
onRemoveTagFromDocument = null,
ensureAssetUrl = null,
getDocumentAsset = () => null,
prepareTagPayload = null,
}) => {
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);
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 resolvePreviewAsset = useCallback(
(doc) => {
if (!doc) return null;
return getDocumentAsset(doc, 'preview');
},
[getDocumentAsset],
);
const resolvePreviewDimensions = useCallback(
(doc) => {
if (!doc) return null;
const asset = resolvePreviewAsset(doc);
const width = asset?.metadata?.width;
const height = asset?.metadata?.height;
if (typeof width === 'number' && typeof height === 'number') {
return { width, height };
}
return null;
},
[resolvePreviewAsset],
);
const resolvePreviewUrl = useCallback(
(doc) => {
if (!doc) return null;
return resolveDocumentAssetUrl(doc, 'preview', {
ensureAssetUrl,
getAsset: getDocumentAsset,
});
},
[ensureAssetUrl, getDocumentAsset],
);
useEffect(() => {
if (!ensureAssetUrl) {
return;
}
items.forEach((doc) => {
resolveDocumentAssetUrl(doc, 'preview', {
ensureAssetUrl,
getAsset: getDocumentAsset,
});
});
}, [items, ensureAssetUrl, getDocumentAsset]);
const focusCanvas = useCallback(() => {
const canvas = containerRef.current;
if (canvas && typeof canvas.focus === 'function') {
const focusTarget = () => {
try {
if (DEBUG_FOCUS) {
console.log('[skeuo] focusCanvas -> attempting focus', canvas);
}
canvas.focus({ preventScroll: true });
if (DEBUG_FOCUS) {
console.log('[skeuo] focusCanvas: applied focus. activeElement:', document?.activeElement);
}
} catch (
// eslint-disable-next-line no-empty
error
) {}
};
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
window.requestAnimationFrame(focusTarget);
} else {
setTimeout(focusTarget, 0);
}
if (DEBUG_FOCUS) {
console.log('[skeuo] focusCanvas: scheduling focus. activeElement:', document?.activeElement);
}
}
}, []);
const queueFocusCanvas = useCallback(() => {
if (typeof window === 'undefined') {
focusCanvas();
return;
}
if (DEBUG_FOCUS) {
console.log('[skeuo] queueFocusCanvas -> scheduling deferred focus');
}
setTimeout(() => {
if (DEBUG_FOCUS) {
console.log('[skeuo] queueFocusCanvas -> executing deferred focus');
}
focusCanvas();
}, 0);
}, [focusCanvas]);
const updateRemovalCursor = useCallback((active) => {
if (typeof document === 'undefined') {
return;
}
if (removalCursorActiveRef.current === active) {
return;
}
const body = document.body;
if (!body) {
return;
}
removalCursorActiveRef.current = active;
if (active) {
body.classList.add('skeuo-cursor-remove');
} else {
body.classList.remove('skeuo-cursor-remove');
}
}, []);
useEffect(
() => () => {
updateRemovalCursor(false);
},
[updateRemovalCursor],
);
const isTagTransfer = useCallback((event) => {
const types = event.dataTransfer?.types;
if (!types) return false;
return TAG_MIME_TYPES.some((type) =>
typeof types.includes === 'function'
? types.includes(type)
: Array.from(types).includes(type),
);
}, []);
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);
if (cache) {
return cache;
}
let width;
let height;
const intrinsic = resolvePreviewDimensions(doc);
if (intrinsic?.width && intrinsic?.height) {
width = intrinsic.width;
height = intrinsic.height;
} else {
const seed = seededRandom(`${key}:size`);
width = CARD_MIN + seed * (Math.min(CARD_MAX, CARD_MAX / EMPTY_CARD_ASPECT) - CARD_MIN);
width = clamp(width, CARD_MIN, Math.min(CARD_MAX, CARD_MAX / EMPTY_CARD_ASPECT));
height = width * EMPTY_CARD_ASPECT;
}
if (!Number.isFinite(width) || width <= 0) {
width = CARD_MIN;
}
if (!Number.isFinite(height) || height <= 0) {
height = CARD_MIN;
}
const scaleDown = Math.min(1, CARD_MAX / width, CARD_MAX / height);
width *= scaleDown;
height *= scaleDown;
const minDim = Math.min(width, height);
if (minDim < CARD_MIN) {
const scaleUp = CARD_MIN / minDim;
width *= scaleUp;
height *= scaleUp;
const adjust = Math.min(1, CARD_MAX / width, CARD_MAX / height);
width *= adjust;
height *= adjust;
}
width = clamp(width, CARD_MIN, CARD_MAX);
height = clamp(height, CARD_MIN, CARD_MAX);
const size = { width, height };
docSizeMapRef.current.set(key, size);
return size;
}, [resolvePreviewDimensions]);
const documentLookup = useMemo(() => {
const map = new Map();
items.forEach((doc) => {
if (doc?.id) {
map.set(doc.id, doc);
}
});
return map;
}, [items]);
useEffect(() => {
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;
const canvasHeight = canvasSize.height || DEFAULT_CANVAS_HEIGHT;
const safeCardWidth = cardWidth || CARD_MIN;
const safeCardHeight = cardHeight || CARD_MIN;
const usableWidth = Math.max(canvasWidth - CANVAS_PADDING * 2, safeCardWidth);
const usableHeight = Math.max(canvasHeight - CANVAS_PADDING * 2, safeCardHeight);
const viewportTargetWidth = usableWidth * ZOOM_FILL_RATIO;
const viewportTargetHeight = usableHeight * ZOOM_FILL_RATIO;
let zoomWidth = safeCardWidth;
let zoomHeight = safeCardHeight;
const previewDims = doc ? resolvePreviewDimensions(doc) : null;
if (previewDims?.width && previewDims?.height) {
const previewWidth = previewDims.width;
const previewHeight = previewDims.height;
const widthScaleLimit = previewWidth > 0 ? viewportTargetWidth / previewWidth : 1;
const heightScaleLimit = previewHeight > 0 ? viewportTargetHeight / previewHeight : 1;
const scaleToFit = Math.min(1, widthScaleLimit || 1, heightScaleLimit || 1);
zoomWidth = previewWidth * scaleToFit;
zoomHeight = previewHeight * scaleToFit;
} else {
const rawScale = Math.min(
viewportTargetWidth / safeCardWidth,
viewportTargetHeight / safeCardHeight,
);
const boundedScale =
rawScale >= 1
? clamp(Math.max(rawScale, ZOOM_MIN_SCALE), ZOOM_MIN_SCALE, ZOOM_MAX_SCALE)
: rawScale;
zoomWidth = safeCardWidth * boundedScale;
zoomHeight = safeCardHeight * boundedScale;
}
if (!Number.isFinite(zoomWidth) || zoomWidth <= 0) {
zoomWidth = safeCardWidth;
}
if (!Number.isFinite(zoomHeight) || zoomHeight <= 0) {
zoomHeight = safeCardHeight;
}
zoomWidth = Math.max(zoomWidth, safeCardWidth);
zoomHeight = Math.max(zoomHeight, safeCardHeight);
const zoomTargetX = (canvasWidth - zoomWidth) / 2;
const zoomTargetY = (canvasHeight - zoomHeight) / 2;
const maxX = Math.max(CANVAS_PADDING, canvasWidth - zoomWidth - CANVAS_PADDING);
const maxY = Math.max(CANVAS_PADDING, canvasHeight - zoomHeight - CANVAS_PADDING);
const clampedX = clamp(zoomTargetX, CANVAS_PADDING, maxX);
const clampedY = clamp(zoomTargetY, CANVAS_PADDING, maxY);
const zoomCenterX = clampedX + zoomWidth / 2;
const zoomCenterY = clampedY + zoomHeight / 2;
return {
zoomWidth,
zoomHeight,
zoomCenterX,
zoomCenterY,
};
},
[canvasSize.width, canvasSize.height, resolvePreviewDimensions],
);
const resolveBaseMetrics = useCallback(
(doc, cardWidth, cardHeight) => {
const previewDims = doc ? resolvePreviewDimensions(doc) : null;
if (previewDims?.width && previewDims?.height) {
const baseWidth = Math.max(previewDims.width, cardWidth);
const baseHeight = Math.max(previewDims.height, cardHeight);
const scaleX = cardWidth / baseWidth;
const scaleY = cardHeight / baseHeight;
const baseScale = Math.min(scaleX, scaleY, 1);
return {
baseWidth,
baseHeight,
baseScale: Number.isFinite(baseScale) && baseScale > 0 ? baseScale : 1,
};
}
return {
baseWidth: cardWidth,
baseHeight: cardHeight,
baseScale: 1,
};
},
[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));
}, []);
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) return () => {};
if (process.env.NODE_ENV !== 'production') {
console.log('[skeuo] canvas element', container);
}
const commitSize = () => {
const rect = container.getBoundingClientRect();
const width = Math.floor(rect.width) || 0;
const height = Math.floor(rect.height) || 0;
setCanvasSize((prev) => {
if (prev.width === width && prev.height === height) {
return prev;
}
return { width, height };
});
};
commitSize();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', commitSize);
return () => {
window.removeEventListener('resize', commitSize);
};
}
const observer = new ResizeObserver(() => {
commitSize();
});
observer.observe(container);
return () => observer.disconnect();
}, []);
useLayoutEffect(() => {
const container = containerRef.current;
if (!container || !canvasSize.width || !canvasSize.height) {
return () => {};
}
const rafId = requestAnimationFrame(() => {
if (!items.length) {
layoutRef.current = new Map();
syncLayoutSnapshot();
return;
}
const previous = layoutRef.current;
const next = new Map();
let maxZ = zCounterRef.current;
const canvasWidth = canvasSize.width || DEFAULT_CANVAS_WIDTH;
const canvasHeight = canvasSize.height || DEFAULT_CANVAS_HEIGHT;
const docsNeedingLayout = [];
items.forEach((doc) => {
const { width: docWidth, height: docHeight } = ensureDocumentSize(doc);
const halfWidth = docWidth / 2;
const halfHeight = docHeight / 2;
const minCenterX = CANVAS_PADDING + halfWidth;
const maxCenterX = Math.max(minCenterX, canvasWidth - CANVAS_PADDING - halfWidth);
const minCenterY = CANVAS_PADDING + halfHeight;
const maxCenterY = Math.max(minCenterY, canvasHeight - CANVAS_PADDING - halfHeight);
const existing = previous.get(doc.id);
if (existing) {
const defaultCenterX = (minCenterX + maxCenterX) / 2;
const defaultCenterY = (minCenterY + maxCenterY) / 2;
const prevCenterX =
typeof existing.centerX === 'number' ? existing.centerX : defaultCenterX;
const prevCenterY =
typeof existing.centerY === 'number' ? existing.centerY : defaultCenterY;
const centerX = clamp(prevCenterX, minCenterX, maxCenterX);
const centerY = clamp(prevCenterY, minCenterY, maxCenterY);
const rotation = existing.rotation ?? 0;
const z = existing.z ?? maxZ;
maxZ = Math.max(maxZ, z);
next.set(doc.id, { centerX, centerY, rotation, z });
return;
}
docsNeedingLayout.push({
id: doc.id,
width: docWidth,
height: docHeight,
seedKey: resolveSizeKey(doc),
});
});
if (docsNeedingLayout.length) {
const { layout: generatedLayout, maxZ: updatedMaxZ } = generateInitialLayout(
docsNeedingLayout,
{
canvasWidth,
canvasHeight,
padding: CANVAS_PADDING,
startZ: maxZ,
rotationRange: ROTATION_RANGE,
minSpacing: 48,
shelfWidth: tagShelfWidth > 0 ? tagShelfWidth + CANVAS_PADDING : 0,
},
);
generatedLayout.forEach((entry, docId) => {
next.set(docId, entry);
});
maxZ = Math.max(maxZ, updatedMaxZ);
}
layoutRef.current = next;
zCounterRef.current = Math.max(zCounterRef.current, maxZ);
syncLayoutSnapshot();
});
return () => cancelAnimationFrame(rafId);
}, [
items,
canvasSize.width,
canvasSize.height,
ensureDocumentSize,
syncLayoutSnapshot,
tagShelfWidth,
]);
useEffect(() => {
if (draggingId && !items.some((doc) => doc.id === draggingId)) {
setDraggingId(null);
}
}, [draggingId, items]);
const bringToFront = useCallback(
(docId) => {
zCounterRef.current += 1;
const entry = layoutRef.current.get(docId);
if (!entry) return;
const updated = { ...entry, z: zCounterRef.current };
layoutRef.current.set(docId, updated);
syncLayoutSnapshot();
},
[syncLayoutSnapshot],
);
const toggleZoom = useCallback(
(docId) => {
setZoomedId((current) => {
if (current === docId) {
return null;
}
bringToFront(docId);
return docId;
});
},
[bringToFront],
);
const { handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel } = useDocumentDrag({
layoutRef,
itemRefs,
documentLookup,
ensureDocumentSize,
resolveBaseMetrics,
bringToFront,
setDraggingId,
syncLayoutSnapshot,
canvasSize,
toggleZoom,
});
const handleTagDragEnterDoc = useCallback(
(event, docId) => {
if (!isTagTransfer(event)) {
return;
}
event.preventDefault();
event.stopPropagation();
updateRemovalCursor(false);
if (tagDropTargetId !== docId) {
setTagDropTargetId(docId);
}
},
[isTagTransfer, tagDropTargetId, updateRemovalCursor],
);
const handleTagDragOverDoc = useCallback(
(event, docId) => {
if (!isTagTransfer(event)) {
return;
}
event.preventDefault();
event.stopPropagation();
updateRemovalCursor(false);
const activeDrag = draggingTagRef.current;
event.dataTransfer.dropEffect = activeDrag?.sourceDocId ? 'move' : 'copy';
if (tagDropTargetId !== docId) {
setTagDropTargetId(docId);
}
},
[isTagTransfer, tagDropTargetId, updateRemovalCursor],
);
const handleTagDragLeaveDoc = useCallback((event, docId) => {
if (!isTagTransfer(event)) {
return;
}
event.preventDefault();
event.stopPropagation();
if (
event.currentTarget instanceof HTMLElement &&
event.relatedTarget instanceof Node &&
event.currentTarget.contains(event.relatedTarget)
) {
return;
}
setTagDropTargetId((current) => (current === docId ? null : current));
updateRemovalCursor(false);
}, [isTagTransfer, updateRemovalCursor]);
const markActiveTagDropHandled = useCallback((tagId, sourceDocId = null) => {
const state = draggingTagRef.current;
if (!state) {
return;
}
if (state.tagId !== tagId) {
return;
}
if (sourceDocId && state.sourceDocId !== sourceDocId) {
return;
}
state.dropHandled = true;
}, []);
const handleTagDropOnDoc = useCallback(
async (event, doc) => {
if (!doc || !isTagTransfer(event) || typeof onAssignTagToDocument !== 'function') {
if (DEBUG_DROP) {
console.log('[skeuo] handleTagDropOnDoc: drop ignored', { doc, hasTransfer: isTagTransfer(event) });
}
return;
}
event.preventDefault();
event.stopPropagation();
if (DEBUG_DROP) {
console.log('[skeuo] handleTagDropOnDoc: drop accepted for doc', doc.id, 'event', event);
}
setTagDropTargetId(null);
let payload = null;
try {
const raw =
event.dataTransfer.getData('application/x-papercrate-tag') ||
event.dataTransfer.getData('text/papercrate-tag');
payload = raw ? JSON.parse(raw) : null;
} catch (error) {
if (DEBUG_DROP) {
console.warn('[skeuo] handleTagDropOnDoc: failed to parse payload', error);
}
payload = null;
}
if (!payload?.id) {
if (DEBUG_DROP) {
console.log('[skeuo] handleTagDropOnDoc: missing tag id payload', payload);
}
queueFocusCanvas();
return;
}
const tagId = payload.id;
const sourceDocId = payload.sourceDocId || null;
if (DEBUG_DROP) {
console.log('[skeuo] handleTagDropOnDoc: parsed payload', { tagId, sourceDocId });
}
if (sourceDocId && sourceDocId === doc.id) {
markActiveTagDropHandled(tagId, sourceDocId);
if (DEBUG_DROP) {
console.log('[skeuo] handleTagDropOnDoc: drop from same doc ignored', tagId);
}
queueFocusCanvas();
return;
}
const alreadyAssigned = Array.isArray(doc.tags)
? doc.tags.some((tag) => tag.id === tagId)
: false;
if (alreadyAssigned) {
markActiveTagDropHandled(tagId, sourceDocId);
if (DEBUG_DROP) {
console.log('[skeuo] handleTagDropOnDoc: tag already assigned', tagId);
}
queueFocusCanvas();
return;
}
const movingBetweenDocuments = Boolean(sourceDocId && sourceDocId !== doc.id);
if (DEBUG_DROP) {
console.log('[skeuo] handleTagDropOnDoc: movingBetweenDocuments', movingBetweenDocuments);
}
setPendingTagDocId(doc.id);
try {
await onAssignTagToDocument({ documentId: doc.id, tagId });
markActiveTagDropHandled(tagId, sourceDocId);
if (DEBUG_DROP) {
console.log('[skeuo] handleTagDropOnDoc: assigned tag', tagId, 'to doc', doc.id);
}
if (movingBetweenDocuments && typeof onRemoveTagFromDocument === 'function') {
setPendingRemovalTag({ docId: sourceDocId, tagId });
try {
await onRemoveTagFromDocument(sourceDocId, tagId);
if (DEBUG_DROP) {
console.log('[skeuo] handleTagDropOnDoc: removed tag from source doc', sourceDocId);
}
} finally {
setPendingRemovalTag(null);
}
}
} finally {
setPendingTagDocId(null);
if (DEBUG_DROP) {
console.log('[skeuo] handleTagDropOnDoc: finalizing drop for tag', tagId);
}
queueFocusCanvas();
}
},
[
isTagTransfer,
markActiveTagDropHandled,
onAssignTagToDocument,
onRemoveTagFromDocument,
queueFocusCanvas,
],
);
const handleCanvasDragOver = useCallback(
(event) => {
if (!isTagTransfer(event)) {
return;
}
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = 'move';
updateRemovalCursor(true);
setTagDropTargetId(null);
},
[isTagTransfer, updateRemovalCursor],
);
const handleCanvasDragLeave = useCallback(
(event) => {
if (!isTagTransfer(event)) {
return;
}
event.preventDefault();
event.stopPropagation();
if (
event.currentTarget instanceof HTMLElement &&
event.relatedTarget instanceof Node &&
event.currentTarget.contains(event.relatedTarget)
) {
return;
}
updateRemovalCursor(false);
},
[isTagTransfer, updateRemovalCursor],
);
const handleCanvasDrop = useCallback(
async (event) => {
if (!isTagTransfer(event)) {
return;
}
event.preventDefault();
event.stopPropagation();
updateRemovalCursor(false);
queueFocusCanvas();
setTagDropTargetId(null);
let payload = null;
try {
const raw =
event.dataTransfer.getData('application/x-papercrate-tag') ||
event.dataTransfer.getData('text/papercrate-tag');
payload = raw ? JSON.parse(raw) : null;
} catch (error) {
payload = null;
}
if (!payload?.id) {
return;
}
const tagId = payload.id;
const sourceDocId = payload.sourceDocId || null;
markActiveTagDropHandled(tagId, sourceDocId);
if (!sourceDocId || typeof onRemoveTagFromDocument !== 'function') {
return;
}
setPendingRemovalTag({ docId: sourceDocId, tagId });
try {
await onRemoveTagFromDocument(sourceDocId, tagId);
} catch (error) {
console.error('Failed to remove tag via canvas drop', error);
} finally {
setPendingRemovalTag(null);
}
},
[
isTagTransfer,
markActiveTagDropHandled,
onRemoveTagFromDocument,
queueFocusCanvas,
updateRemovalCursor,
],
);
const handleDocTagPointerDown = useCallback((event, doc, tag) => {
event.stopPropagation();
if (!doc || !tag) {
pendingDocTagDragRef.current = null;
return;
}
const startX = Number.isFinite(event.clientX)
? event.clientX
: Number.isFinite(event.pageX)
? event.pageX
: 0;
const startY = Number.isFinite(event.clientY)
? event.clientY
: Number.isFinite(event.pageY)
? event.pageY
: 0;
pendingDocTagDragRef.current = {
docId: doc.id,
tagId: tag.id,
startX,
startY,
};
updateRemovalCursor(false);
}, [updateRemovalCursor]);
const handleDocTagDragStart = useCallback(
(event, doc, tag) => {
if (!doc || !tag) {
return;
}
event.stopPropagation();
try {
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'copyMove';
}
} catch (
// eslint-disable-next-line no-empty
error
) {}
const payload = JSON.stringify({ id: tag.id, label: tag.label, sourceDocId: doc.id });
try {
event.dataTransfer?.setData('application/x-papercrate-tag', payload);
event.dataTransfer?.setData('text/papercrate-tag', payload);
event.dataTransfer?.setData('text/plain', tag.label || 'Tag');
} catch (
// eslint-disable-next-line no-empty
error
) {}
const pending = pendingDocTagDragRef.current;
const node = event.currentTarget;
const startX = Number.isFinite(event.clientX)
? event.clientX
: Number.isFinite(event.pageX)
? event.pageX
: 0;
const startY = Number.isFinite(event.clientY)
? event.clientY
: Number.isFinite(event.pageY)
? event.pageY
: 0;
const initialX =
pending && pending.docId === doc.id && pending.tagId === tag.id && Number.isFinite(pending.startX)
? pending.startX
: startX;
const initialY =
pending && pending.docId === doc.id && pending.tagId === tag.id && Number.isFinite(pending.startY)
? pending.startY
: startY;
pendingDocTagDragRef.current = null;
let preview = null;
if (node instanceof HTMLElement) {
preview = createDragPreview(node, event.clientX, event.clientY);
if (preview && event.dataTransfer) {
try {
event.dataTransfer.setDragImage(preview.clone, preview.offsetX, preview.offsetY);
} catch (
// eslint-disable-next-line no-empty
error
) {}
}
}
draggingTagRef.current = {
sourceDocId: doc.id,
tagId: tag.id,
tagLabel: tag.label || 'Tag',
startX: initialX,
startY: initialY,
distance: 0,
element: node instanceof HTMLElement ? node : null,
dropHandled: false,
hasPosition: Number.isFinite(initialX) && Number.isFinite(initialY),
previewClone: preview?.clone || null,
};
const hideNode = () => {
if (draggingTagRef.current?.element === node) {
node.classList.add('is-drag-hidden');
}
};
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
window.requestAnimationFrame(hideNode);
} else {
setTimeout(hideNode, 0);
}
},
[],
);
const handleDocTagDrag = useCallback(
(event) => {
const state = draggingTagRef.current;
if (!state) {
updateRemovalCursor(false);
return;
}
if (!state.hasPosition && Number.isFinite(event.clientX) && Number.isFinite(event.clientY)) {
state.startX = event.clientX;
state.startY = event.clientY;
state.hasPosition = true;
}
const clientX = Number.isFinite(event.clientX) ? event.clientX : state.startX;
const clientY = Number.isFinite(event.clientY) ? event.clientY : state.startY;
const deltaX = clientX - state.startX;
const deltaY = clientY - state.startY;
const distance = Math.hypot(deltaX, deltaY);
if (Number.isFinite(distance)) {
state.distance = distance;
}
const removalActive =
Boolean(state.sourceDocId) &&
!tagDropTargetId &&
Number.isFinite(state.distance) &&
state.distance >= TAG_REMOVE_DISTANCE;
updateRemovalCursor(removalActive);
},
[tagDropTargetId, updateRemovalCursor],
);
const handleDocTagDragEnd = useCallback(
(event) => {
handleTagDragEnd();
const state = draggingTagRef.current;
if (!state) {
return;
}
draggingTagRef.current = null;
const node = state.element;
const showNode = () => {
if (node instanceof HTMLElement) {
node.classList.remove('is-drag-hidden');
}
};
cleanupPreview(state.previewClone);
const scheduleShowNode = () => {
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
window.requestAnimationFrame(showNode);
} else {
setTimeout(showNode, 0);
}
};
const dropEffect = event?.dataTransfer?.dropEffect || 'none';
console.log('[skeuo] dragEnd dropEffect', dropEffect, 'dropHandled', state.dropHandled);
const shouldRemove =
!state.dropHandled &&
dropEffect === 'none' &&
state.sourceDocId &&
typeof onRemoveTagFromDocument === 'function' &&
(state.distance || 0) >= TAG_REMOVE_DISTANCE;
if (!shouldRemove) {
console.log('[skeuo] dragEnd -> no removal. distance:', state.distance);
scheduleShowNode();
queueFocusCanvas();
updateRemovalCursor(false);
return;
}
queueFocusCanvas();
updateRemovalCursor(false);
setPendingRemovalTag({ docId: state.sourceDocId, tagId: state.tagId });
void (async () => {
try {
await onRemoveTagFromDocument(state.sourceDocId, state.tagId);
console.log('[skeuo] dragEnd -> removed tag due to fling');
} catch (error) {
console.error('Failed to remove tag after drag', error);
scheduleShowNode();
} finally {
setPendingRemovalTag(null);
}
})();
},
[queueFocusCanvas, handleTagDragEnd, onRemoveTagFromDocument, updateRemovalCursor],
);
const renderBreadcrumbs = () => {
if (!breadcrumbs.length) return null;
return (
<nav className="skeuo-breadcrumbs" aria-label="Folder breadcrumbs">
{breadcrumbs.map((crumb, index) => {
const isLast = index === breadcrumbs.length - 1;
return (
<span key={crumb.id} className={`skeuo-crumb${isLast ? ' is-current' : ''}`}>
{crumb.name}
{!isLast && <span className="skeuo-crumb-separator"></span>}
</span>
);
})}
</nav>
);
};
return (
<div className="skeuo-shell">
<header className="skeuo-header">
<div className="skeuo-header__meta">
<h2>{currentFolderName}</h2>
{renderBreadcrumbs()}
{showingSearchResults && <span className="meta">Showing search results</span>}
</div>
<div className="skeuo-header__actions">
<button type="button" className="secondary" onClick={onRefresh}>
Refresh
</button>
<button type="button" onClick={onExit}>
Back to List
</button>
</div>
</header>
<div
className="skeuo-canvas"
ref={containerRef}
data-active-tag={activeShelfTagId || undefined}
onDragOver={handleCanvasDragOver}
onDragLeave={handleCanvasDragLeave}
onDrop={handleCanvasDrop}
>
{items.length === 0 ? (
<div className="skeuo-empty">
<p>No documents to show here yet. Drop files to make this space come alive.</p>
</div>
) : (
items.map((doc) => {
const { width: cardWidth, height: cardHeight } = ensureDocumentSize(doc);
const { baseWidth, baseHeight, baseScale } = resolveBaseMetrics(doc, cardWidth, cardHeight);
const fallbackLayout = {
centerX: CANVAS_PADDING + cardWidth / 2,
centerY: CANVAS_PADDING + cardHeight / 2,
rotation: 0,
z: 1,
};
const storedLayout = layoutSnapshot.get(doc.id) ?? layoutRef.current.get(doc.id);
const layout = storedLayout || fallbackLayout;
const layoutCenterX =
typeof layout.centerX === 'number' ? layout.centerX : fallbackLayout.centerX;
const layoutCenterY =
typeof layout.centerY === 'number' ? layout.centerY : fallbackLayout.centerY;
const isZoomed = zoomedId === doc.id;
const { zoomWidth, zoomHeight, zoomCenterX, zoomCenterY } = resolveZoomMetrics(
doc,
cardWidth,
cardHeight,
);
const targetCenterX = isZoomed ? zoomCenterX : layoutCenterX;
const targetCenterY = isZoomed ? zoomCenterY : layoutCenterY;
const rotation = isZoomed ? 0 : layout.rotation || 0;
const zoomScale = isZoomed
? Math.min(
1,
Number.isFinite(zoomWidth / baseWidth) ? zoomWidth / baseWidth : 1,
Number.isFinite(zoomHeight / baseHeight) ? zoomHeight / baseHeight : 1,
)
: baseScale;
const transform = formatTransform(
Math.round(targetCenterX),
Math.round(targetCenterY),
rotation,
zoomScale,
);
const style = {
transform,
zIndex: isZoomed ? 9999 : layout.z ?? 1,
};
const bodyStyle = {
width: Math.round(baseWidth),
height: Math.round(baseHeight),
};
const totalScale = zoomScale > 0 ? zoomScale : 1;
const inverseTagScale = totalScale > 0 ? 1 / totalScale : 1;
const tagsStyle = { '--tag-scale': inverseTagScale };
const previewUrl = resolvePreviewUrl(doc);
const imageUrl = previewUrl;
const hasPreview = Boolean(imageUrl);
const title = doc.title || doc.original_name || 'Document';
const dragging = draggingId === doc.id;
const tags = Array.isArray(doc.tags) ? doc.tags : [];
const docTagKeys = tags
.map((tag) => resolveTagKey(tag))
.filter(Boolean);
const matchesFilter =
!activeShelfTagId || docTagKeys.includes(activeShelfTagId);
const dropActive = tagDropTargetId === doc.id;
const dropPending = pendingTagDocId === doc.id;
const itemClasses = ['skeuo-item'];
if (dragging) itemClasses.push('is-dragging');
if (isZoomed) itemClasses.push('is-zoomed');
if (dropActive) itemClasses.push('is-tag-target');
if (dropPending) itemClasses.push('is-tag-pending');
if (!matchesFilter) itemClasses.push('is-filtered-out');
const cardClasses = ['skeuo-item__card'];
if (!hasPreview) cardClasses.push('skeuo-item__card--empty');
const docTagTokens = docTagKeys.join(' ');
return (
<div
key={doc.id}
className={itemClasses.join(' ')}
style={style}
role="button"
data-doc-id={doc.id}
data-tag-ids={docTagTokens || undefined}
aria-hidden={matchesFilter ? undefined : 'true'}
ref={(node) => {
if (node) {
itemRefs.current.set(doc.id, node);
} else {
itemRefs.current.delete(doc.id);
}
}}
onPointerDown={(event) =>
handlePointerDown(event, doc.id, { lockWhenZoomed: isZoomed })
}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel}
onDragEnter={(event) => handleTagDragEnterDoc(event, doc.id)}
onDragOver={(event) => handleTagDragOverDoc(event, doc.id)}
onDragLeave={(event) => handleTagDragLeaveDoc(event, doc.id)}
onDrop={(event) => handleTagDropOnDoc(event, doc)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onDocumentOpen?.(doc.id);
}
}}
>
<div className="skeuo-item__body" style={bodyStyle}>
<div className={cardClasses.join(' ')}>
{hasPreview ? (
<img src={imageUrl} alt={title} />
) : (
<div className="skeuo-item__empty">
<div className="skeuo-item__placeholder">DOC</div>
<div className="skeuo-item__title" title={title}>
{title}
</div>
</div>
)}
</div>
{tags.length > 0 && (
<div className="skeuo-item__tags" aria-hidden="true" style={tagsStyle}>
{tags.map((tag) => {
const key = tag.id || tag.label || String(tag);
if (
pendingRemovalTag &&
pendingRemovalTag.docId === doc.id &&
pendingRemovalTag.tagId === tag.id
) {
return null;
}
const colorValue = normalizeColor(tag.color);
const foreground = getContrastingTextColor(colorValue || '#1b1f24');
const pendingRemoval =
pendingRemovalTag &&
pendingRemovalTag.docId === doc.id &&
pendingRemovalTag.tagId === tag.id;
const tagClasses = ['skeuo-tag'];
if (pendingRemoval) tagClasses.push('is-tear-pending');
const tagStyle =
colorValue ? { backgroundColor: colorValue, color: foreground } : undefined;
return (
<div
key={key}
className={tagClasses.join(' ')}
style={tagStyle}
title={tag.label || 'Tag'}
draggable
onPointerDown={(event) => handleDocTagPointerDown(event, doc, tag)}
onDragStart={(event) => handleDocTagDragStart(event, doc, tag)}
onDrag={handleDocTagDrag}
onDragEnd={(event) => handleDocTagDragEnd(event)}
>
<span>{tag.label || 'Tag'}</span>
</div>
);
})}
</div>
)}
</div>
</div>
);
})
)}
{(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>
);
};
export default SkeuomorphicWorkspace;