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 ( ); }; return (
No documents to show here yet. Drop files to make this space come alive.