1788 lines
55 KiB
React
1788 lines
55 KiB
React
import React, {
|
|
useCallback,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import { resolveDocumentAssetUrl, createAssetView } from './asset_manager';
|
|
import { useAssetNavigator } from './hooks/useAssetNavigator';
|
|
import { ArrowLeftIcon, ArrowRightIcon } from './ui/icons';
|
|
import PreviewZoomOverlay from './detail/PreviewZoomOverlay';
|
|
import { getTagColorStyle } 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 TAG_REMOVE_DISTANCE = 160;
|
|
|
|
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 SkeuoPreviewCard = ({
|
|
doc,
|
|
title,
|
|
ensureAssetUrl,
|
|
getDocumentAsset,
|
|
navScale = 1,
|
|
prefetch = 3,
|
|
onNavigatorSnapshot,
|
|
}) => {
|
|
const navigator = useAssetNavigator({
|
|
document: doc,
|
|
assetType: 'preview',
|
|
ensureAssetUrl,
|
|
getAsset: getDocumentAsset,
|
|
prefetch,
|
|
});
|
|
|
|
const { currentUrl, cardinality, canGoPrev, canGoNext, currentMetadata, ordinal } = navigator;
|
|
const docId = doc?.id ?? null;
|
|
|
|
const metadataWidth = Number(currentMetadata?.width);
|
|
const metadataHeight = Number(currentMetadata?.height);
|
|
|
|
useEffect(() => {
|
|
if (!onNavigatorSnapshot || !docId) {
|
|
return undefined;
|
|
}
|
|
const snapshot = {
|
|
url: currentUrl || null,
|
|
alt: title,
|
|
canGoPrev,
|
|
canGoNext,
|
|
goPrev: navigator.goPrev,
|
|
goNext: navigator.goNext,
|
|
ordinal,
|
|
width: Number.isFinite(metadataWidth) && metadataWidth > 0 ? metadataWidth : null,
|
|
height: Number.isFinite(metadataHeight) && metadataHeight > 0 ? metadataHeight : null,
|
|
};
|
|
onNavigatorSnapshot(docId, snapshot);
|
|
return () => onNavigatorSnapshot(docId, null);
|
|
}, [
|
|
docId,
|
|
currentUrl,
|
|
title,
|
|
canGoPrev,
|
|
canGoNext,
|
|
ordinal,
|
|
metadataWidth,
|
|
metadataHeight,
|
|
navigator.goPrev,
|
|
navigator.goNext,
|
|
onNavigatorSnapshot,
|
|
]);
|
|
const hasPreview = Boolean(currentUrl);
|
|
const cardClasses = ['skeuo-item__card'];
|
|
if (!hasPreview) cardClasses.push('skeuo-item__card--empty');
|
|
const showNav = hasPreview && (cardinality > 1 || canGoPrev || canGoNext);
|
|
const navStyle = useMemo(() => ({ '--nav-scale': navScale }), [navScale]);
|
|
|
|
return (
|
|
<div className={cardClasses.join(' ')}>
|
|
{hasPreview ? (
|
|
<img src={currentUrl} alt={title} />
|
|
) : (
|
|
<div className="skeuo-item__empty">
|
|
<div className="skeuo-item__placeholder">DOC</div>
|
|
<div className="skeuo-item__title" title={title}>
|
|
{title}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{showNav ? (
|
|
<div className="skeuo-card__nav" style={navStyle}>
|
|
<button
|
|
type="button"
|
|
className="skeuo-card__nav-button"
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
navigator.goPrev();
|
|
}}
|
|
onPointerDown={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
}}
|
|
onPointerUp={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
}}
|
|
onMouseDown={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
}}
|
|
onMouseUp={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
}}
|
|
disabled={!canGoPrev}
|
|
aria-label="Previous preview"
|
|
>
|
|
<ArrowLeftIcon />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="skeuo-card__nav-button"
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
navigator.goNext();
|
|
}}
|
|
onPointerDown={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
}}
|
|
onPointerUp={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
}}
|
|
onMouseDown={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
}}
|
|
onMouseUp={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
}}
|
|
disabled={!canGoNext}
|
|
aria-label="Next preview"
|
|
>
|
|
<ArrowRightIcon />
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
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,
|
|
openOverlayForDoc,
|
|
}) => {
|
|
const dragStateRef = useRef(null);
|
|
|
|
const finishDrag = useCallback(
|
|
(pointerId) => {
|
|
const state = dragStateRef.current;
|
|
if (!state || state.pointerId !== pointerId) {
|
|
return;
|
|
}
|
|
const capturedTarget = state.capturedTarget;
|
|
if (capturedTarget && typeof capturedTarget.releasePointerCapture === 'function') {
|
|
try {
|
|
capturedTarget.releasePointerCapture(pointerId);
|
|
} catch (
|
|
// eslint-disable-next-line no-empty
|
|
error
|
|
) {}
|
|
}
|
|
dragStateRef.current = null;
|
|
setDraggingId((current) => (current === state.docId ? null : current));
|
|
syncLayoutSnapshot();
|
|
},
|
|
[setDraggingId, syncLayoutSnapshot],
|
|
);
|
|
|
|
const handlePointerDown = useCallback(
|
|
(event, docId) => {
|
|
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);
|
|
const capturedTarget = event.currentTarget instanceof HTMLElement ? event.currentTarget : null;
|
|
if (capturedTarget && typeof capturedTarget.setPointerCapture === 'function') {
|
|
try {
|
|
capturedTarget.setPointerCapture(event.pointerId);
|
|
} catch (
|
|
// eslint-disable-next-line no-empty
|
|
error
|
|
) {}
|
|
}
|
|
dragStateRef.current = {
|
|
docId,
|
|
pointerId: event.pointerId,
|
|
originCenterX: centerX,
|
|
originCenterY: centerY,
|
|
startX: event.clientX,
|
|
startY: event.clientY,
|
|
rotation: entry?.rotation ?? 0,
|
|
moved: false,
|
|
locked: false,
|
|
width: docWidth,
|
|
height: docHeight,
|
|
scale: baseScale,
|
|
capturedTarget,
|
|
};
|
|
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) {
|
|
const originInfo = {
|
|
rotation: state.rotation || 0,
|
|
scale: state.scale || 1,
|
|
width: state.width,
|
|
height: state.height,
|
|
};
|
|
openOverlayForDoc(docId, originInfo);
|
|
}
|
|
return;
|
|
}
|
|
finishDrag(event.pointerId);
|
|
},
|
|
[finishDrag, openOverlayForDoc],
|
|
);
|
|
|
|
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 clampCardDimensions = (width, height) => {
|
|
let nextWidth = Number(width);
|
|
let nextHeight = Number(height);
|
|
if (!Number.isFinite(nextWidth) || nextWidth <= 0 || !Number.isFinite(nextHeight) || nextHeight <= 0) {
|
|
return null;
|
|
}
|
|
|
|
const scaleDown = Math.min(1, CARD_MAX / nextWidth, CARD_MAX / nextHeight);
|
|
nextWidth *= scaleDown;
|
|
nextHeight *= scaleDown;
|
|
|
|
const minDim = Math.min(nextWidth, nextHeight);
|
|
if (minDim > 0 && minDim < CARD_MIN) {
|
|
const scaleUp = CARD_MIN / minDim;
|
|
nextWidth *= scaleUp;
|
|
nextHeight *= scaleUp;
|
|
|
|
const adjust = Math.min(1, CARD_MAX / nextWidth, CARD_MAX / nextHeight);
|
|
nextWidth *= adjust;
|
|
nextHeight *= adjust;
|
|
}
|
|
|
|
nextWidth = clamp(nextWidth, CARD_MIN, CARD_MAX);
|
|
nextHeight = clamp(nextHeight, CARD_MIN, CARD_MAX);
|
|
|
|
return {
|
|
width: nextWidth,
|
|
height: nextHeight,
|
|
};
|
|
};
|
|
|
|
const formatTransform = (x, y, rotation = 0, scale = 1) =>
|
|
`translate3d(${x}px, ${y}px, 0) rotate(${rotation}deg) scale(${scale})`;
|
|
|
|
const SkeuomorphicWorkspace = ({
|
|
documents = [],
|
|
searchResults = null,
|
|
onDocumentOpen,
|
|
resolveThumbnailUrl,
|
|
onAssignTagToDocument = null,
|
|
onRemoveTagFromDocument = null,
|
|
ensureAssetUrl = null,
|
|
getDocumentAsset = () => null,
|
|
activeTagIds = [],
|
|
}) => {
|
|
const items = useMemo(() => (searchResults ? searchResults : documents), [documents, searchResults]);
|
|
|
|
|
|
const containerRef = 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 [draggingId, setDraggingId] = useState(null);
|
|
const [overlayDocId, setOverlayDocId] = useState(null);
|
|
const [overlayOriginRect, setOverlayOriginRect] = useState(null);
|
|
const [overlayOriginTransform, setOverlayOriginTransform] = useState(null);
|
|
const [previewSnapshots, setPreviewSnapshots] = useState(() => new Map());
|
|
const [docSizeVersion, setDocSizeVersion] = useState(0);
|
|
const [tagDropTargetId, setTagDropTargetId] = useState(null);
|
|
const [pendingTagDocId, setPendingTagDocId] = useState(null);
|
|
const [pendingRemovalTag, setPendingRemovalTag] = useState(null);
|
|
const draggingTagRef = useRef(null);
|
|
const pendingDocTagDragRef = useRef(null);
|
|
const docSizeMapRef = useRef(new Map());
|
|
const documentLookupRef = useRef(new Map());
|
|
const removalCursorActiveRef = useRef(false);
|
|
const applySnapshotDimensions = useCallback(
|
|
(docId, snapshot) => {
|
|
if (!docId || !snapshot) {
|
|
return;
|
|
}
|
|
|
|
const doc = documentLookupRef.current.get(docId);
|
|
if (!doc) {
|
|
return;
|
|
}
|
|
|
|
const width = Number(snapshot.width);
|
|
const height = Number(snapshot.height);
|
|
if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(height) || height <= 0) {
|
|
return;
|
|
}
|
|
|
|
const normalized = clampCardDimensions(width, height);
|
|
if (!normalized) {
|
|
return;
|
|
}
|
|
|
|
const key = resolveSizeKey(doc);
|
|
const existing = docSizeMapRef.current.get(key);
|
|
if (existing && existing.width === normalized.width && existing.height === normalized.height) {
|
|
return;
|
|
}
|
|
|
|
docSizeMapRef.current.set(key, normalized);
|
|
setDocSizeVersion((value) => value + 1);
|
|
},
|
|
[],
|
|
);
|
|
const handleNavigatorSnapshot = useCallback(
|
|
(docId, snapshot) => {
|
|
if (!docId) {
|
|
return;
|
|
}
|
|
|
|
setPreviewSnapshots((previous) => {
|
|
const prevSnapshot = previous.get(docId);
|
|
if (!snapshot) {
|
|
if (!previous.has(docId)) {
|
|
return previous;
|
|
}
|
|
const next = new Map(previous);
|
|
next.delete(docId);
|
|
return next;
|
|
}
|
|
|
|
const next = new Map(previous);
|
|
const sameSnapshot =
|
|
prevSnapshot &&
|
|
prevSnapshot.url === snapshot.url &&
|
|
prevSnapshot.alt === snapshot.alt &&
|
|
prevSnapshot.canGoPrev === snapshot.canGoPrev &&
|
|
prevSnapshot.canGoNext === snapshot.canGoNext &&
|
|
prevSnapshot.goPrev === snapshot.goPrev &&
|
|
prevSnapshot.goNext === snapshot.goNext &&
|
|
prevSnapshot.ordinal === snapshot.ordinal &&
|
|
prevSnapshot.width === snapshot.width &&
|
|
prevSnapshot.height === snapshot.height;
|
|
if (sameSnapshot) {
|
|
return previous;
|
|
}
|
|
next.set(docId, snapshot);
|
|
return next;
|
|
});
|
|
|
|
if (snapshot) {
|
|
applySnapshotDimensions(docId, snapshot);
|
|
}
|
|
},
|
|
[applySnapshotDimensions],
|
|
);
|
|
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) => {
|
|
if (!doc) return null;
|
|
return getDocumentAsset(doc, 'preview');
|
|
},
|
|
[getDocumentAsset],
|
|
);
|
|
|
|
const resolvePreviewDimensions = useCallback(
|
|
(doc) => {
|
|
if (!doc) return null;
|
|
|
|
const snapshot = previewSnapshots.get(doc.id);
|
|
if (snapshot && snapshot.width && snapshot.height) {
|
|
return {
|
|
width: snapshot.width,
|
|
height: snapshot.height,
|
|
};
|
|
}
|
|
|
|
const asset = resolvePreviewAsset(doc);
|
|
const view = createAssetView(asset);
|
|
const metadata = view.getPrimaryMetadata() || {};
|
|
const width = metadata?.width;
|
|
const height = metadata?.height;
|
|
if (typeof width === 'number' && typeof height === 'number') {
|
|
return { width, height };
|
|
}
|
|
return null;
|
|
},
|
|
[previewSnapshots, resolvePreviewAsset],
|
|
);
|
|
|
|
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 handleTagDragEnd = useCallback(() => {
|
|
updateRemovalCursor(false);
|
|
setTagDropTargetId(null);
|
|
}, [updateRemovalCursor]);
|
|
|
|
const ensureDocumentSize = useCallback((doc) => {
|
|
const key = resolveSizeKey(doc);
|
|
const cache = docSizeMapRef.current.get(key);
|
|
if (cache) {
|
|
return cache;
|
|
}
|
|
const intrinsic = resolvePreviewDimensions(doc);
|
|
let normalized = null;
|
|
if (intrinsic?.width && intrinsic?.height) {
|
|
normalized = clampCardDimensions(intrinsic.width, intrinsic.height);
|
|
}
|
|
|
|
if (!normalized) {
|
|
const seed = seededRandom(`${key}:size`);
|
|
let 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));
|
|
const height = width * EMPTY_CARD_ASPECT;
|
|
normalized = clampCardDimensions(width, height);
|
|
}
|
|
|
|
if (!normalized) {
|
|
normalized = { width: CARD_MIN, height: CARD_MIN };
|
|
}
|
|
|
|
docSizeMapRef.current.set(key, normalized);
|
|
return normalized;
|
|
}, [resolvePreviewDimensions]);
|
|
|
|
const documentLookup = useMemo(() => {
|
|
const map = new Map();
|
|
items.forEach((doc) => {
|
|
if (doc?.id) {
|
|
map.set(doc.id, doc);
|
|
}
|
|
});
|
|
return map;
|
|
}, [items]);
|
|
|
|
useEffect(() => {
|
|
documentLookupRef.current = documentLookup;
|
|
}, [documentLookup]);
|
|
|
|
useEffect(() => {
|
|
docSizeMapRef.current = new Map();
|
|
setDocSizeVersion((value) => value + 1);
|
|
}, [items]);
|
|
|
|
const overlayDisplay = useMemo(() => {
|
|
if (!overlayDocId) {
|
|
return null;
|
|
}
|
|
const snapshot = previewSnapshots.get(overlayDocId);
|
|
if (!snapshot || !snapshot.url) {
|
|
return null;
|
|
}
|
|
const doc = documentLookup.get(overlayDocId);
|
|
const alt = snapshot.alt || doc?.title || doc?.original_name || 'Document preview';
|
|
return {
|
|
url: snapshot.url,
|
|
alt,
|
|
canGoPrev: snapshot.canGoPrev,
|
|
canGoNext: snapshot.canGoNext,
|
|
goPrev: snapshot.goPrev,
|
|
goNext: snapshot.goNext,
|
|
};
|
|
}, [overlayDocId, previewSnapshots, documentLookup]);
|
|
|
|
const closeOverlay = useCallback(() => {
|
|
setOverlayDocId(null);
|
|
setOverlayOriginRect(null);
|
|
setOverlayOriginTransform(null);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (overlayDocId && !documentLookup.has(overlayDocId)) {
|
|
setOverlayDocId(null);
|
|
setOverlayOriginRect(null);
|
|
setOverlayOriginTransform(null);
|
|
}
|
|
}, [overlayDocId, documentLookup]);
|
|
|
|
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],
|
|
);
|
|
|
|
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: 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,
|
|
docSizeVersion,
|
|
ensureDocumentSize,
|
|
syncLayoutSnapshot,
|
|
]);
|
|
|
|
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 openOverlayForDoc = useCallback(
|
|
(docId, originInfo = null) => {
|
|
if (!docId) {
|
|
return;
|
|
}
|
|
const snapshot = previewSnapshots.get(docId);
|
|
if (!snapshot || !snapshot.url) {
|
|
return;
|
|
}
|
|
const container = itemRefs.current.get(docId);
|
|
const imageNode = container?.querySelector?.('.skeuo-item__card img');
|
|
if (!container || !imageNode) {
|
|
return;
|
|
}
|
|
const rect = imageNode.getBoundingClientRect();
|
|
let originTransform = null;
|
|
if (originInfo) {
|
|
const { rotation = 0, scale = 1, width: originWidth, height: originHeight } = originInfo;
|
|
originTransform = {
|
|
rotation,
|
|
scaleX: scale,
|
|
scaleY: scale,
|
|
baseWidth: originWidth,
|
|
baseHeight: originHeight,
|
|
};
|
|
}
|
|
if (!originTransform) {
|
|
const entry = layoutRef.current.get(docId) || null;
|
|
const doc = documentLookup.get(docId) || null;
|
|
const { width: cardWidth, height: cardHeight } = ensureDocumentSize(doc);
|
|
const { baseWidth, baseHeight, baseScale } = resolveBaseMetrics(doc, cardWidth, cardHeight);
|
|
const effectiveWidth = baseWidth * baseScale;
|
|
const effectiveHeight = baseHeight * baseScale;
|
|
originTransform = {
|
|
rotation: entry?.rotation ?? 0,
|
|
scaleX: baseScale,
|
|
scaleY: baseScale,
|
|
baseWidth: Number.isFinite(effectiveWidth) && effectiveWidth > 0 ? effectiveWidth : cardWidth,
|
|
baseHeight: Number.isFinite(effectiveHeight) && effectiveHeight > 0 ? effectiveHeight : cardHeight,
|
|
};
|
|
}
|
|
bringToFront(docId);
|
|
setOverlayOriginRect(rect);
|
|
setOverlayOriginTransform(originTransform);
|
|
setOverlayDocId(docId);
|
|
},
|
|
[
|
|
bringToFront,
|
|
previewSnapshots,
|
|
itemRefs,
|
|
setOverlayOriginTransform,
|
|
ensureDocumentSize,
|
|
resolveBaseMetrics,
|
|
documentLookup,
|
|
layoutRef,
|
|
],
|
|
);
|
|
const { handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel } = useDocumentDrag({
|
|
layoutRef,
|
|
itemRefs,
|
|
documentLookup,
|
|
ensureDocumentSize,
|
|
resolveBaseMetrics,
|
|
bringToFront,
|
|
setDraggingId,
|
|
syncLayoutSnapshot,
|
|
canvasSize,
|
|
openOverlayForDoc,
|
|
});
|
|
|
|
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, tag: payload });
|
|
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],
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<div className="skeuo-shell">
|
|
<div
|
|
className="skeuo-canvas"
|
|
ref={containerRef}
|
|
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 rotation = layout.rotation || 0;
|
|
const zoomScale = baseScale;
|
|
const transform = formatTransform(
|
|
Math.round(layoutCenterX),
|
|
Math.round(layoutCenterY),
|
|
rotation,
|
|
zoomScale,
|
|
);
|
|
const style = {
|
|
transform,
|
|
zIndex: 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 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 =
|
|
activeTagSet.size === 0 || docTagKeys.some((key) => activeTagSet.has(key));
|
|
const dropActive = tagDropTargetId === doc.id;
|
|
const dropPending = pendingTagDocId === doc.id;
|
|
const itemClasses = ['skeuo-item'];
|
|
if (dragging) itemClasses.push('is-dragging');
|
|
if (dropActive) itemClasses.push('is-tag-target');
|
|
if (dropPending) itemClasses.push('is-tag-pending');
|
|
if (!matchesFilter) itemClasses.push('is-filtered-out');
|
|
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)}
|
|
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}>
|
|
<SkeuoPreviewCard
|
|
doc={doc}
|
|
title={title}
|
|
ensureAssetUrl={ensureAssetUrl}
|
|
getDocumentAsset={getDocumentAsset}
|
|
navScale={inverseTagScale}
|
|
onNavigatorSnapshot={handleNavigatorSnapshot}
|
|
/>
|
|
{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 colorStyle = getTagColorStyle(tag.color);
|
|
const pendingRemoval =
|
|
pendingRemovalTag &&
|
|
pendingRemovalTag.docId === doc.id &&
|
|
pendingRemovalTag.tagId === tag.id;
|
|
const tagClasses = ['badge', 'tag-chip', 'tag-chip--draggable'];
|
|
if (pendingRemoval) tagClasses.push('tag-chip--tear-pending');
|
|
return (
|
|
<span
|
|
key={key}
|
|
className={tagClasses.join(' ')}
|
|
style={colorStyle || undefined}
|
|
title={tag.label || 'Tag'}
|
|
draggable
|
|
onPointerDown={(event) => handleDocTagPointerDown(event, doc, tag)}
|
|
onDragStart={(event) => handleDocTagDragStart(event, doc, tag)}
|
|
onDrag={handleDocTagDrag}
|
|
onDragEnd={(event) => handleDocTagDragEnd(event)}
|
|
>
|
|
<span className="tag-chip__label">{tag.label || 'Tag'}</span>
|
|
</span>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</div>
|
|
<PreviewZoomOverlay
|
|
open={Boolean(overlayDisplay?.url)}
|
|
display={overlayDisplay}
|
|
onClose={closeOverlay}
|
|
originRect={overlayOriginRect}
|
|
originTransform={overlayOriginTransform}
|
|
/>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default SkeuomorphicWorkspace;
|