import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react'; import { resolveDocumentAssetUrl } from './asset_manager'; 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_WIDTH_MIN = 200; const CARD_WIDTH_MAX = 260; const CARD_HEIGHT_MIN = 240; const CARD_HEIGHT_MAX = 340; const CARD_ASPECT_MIN = 0.8; // height / width const CARD_ASPECT_MAX = 1.35; const ZOOM_FILL_RATIO = 0.97; 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 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) => { if (!hex) return '#1b1f24'; const value = parseInt(hex.slice(1), 16); const r = (value >> 16) & 0xff; const g = (value >> 8) & 0xff; const b = value & 0xff; const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; return luminance > 0.6 ? '#1b1f24' : '#ffffff'; }; const SkeuomorphicWorkspace = ({ documents = [], searchResults = null, breadcrumbs = [], currentFolderName = 'Folder', onExit, onRefresh, onDocumentOpen, resolveThumbnailUrl, availableTags = [], onCreateTag = null, onAssignTagToDocument = null, onRemoveTagFromDocument = null, ensureAssetUrl = null, getDocumentAsset = () => null, }) => { const items = useMemo(() => (searchResults ? searchResults : documents), [documents, searchResults]); const showingSearchResults = searchResults !== null; 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 [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) { const ratio = intrinsic.height / intrinsic.width || 1; width = intrinsic.width; height = intrinsic.height; // scale down to fit within max bounds if (width > CARD_WIDTH_MAX || height > CARD_HEIGHT_MAX) { const scale = Math.min(CARD_WIDTH_MAX / width, CARD_HEIGHT_MAX / height); width *= scale; height *= scale; } // scale up to satisfy minimum bounds while respecting ratio if (width < CARD_WIDTH_MIN || height < CARD_HEIGHT_MIN) { const scale = Math.max(CARD_WIDTH_MIN / width, CARD_HEIGHT_MIN / height); width *= scale; height *= scale; if (width > CARD_WIDTH_MAX || height > CARD_HEIGHT_MAX) { const downScale = Math.min(CARD_WIDTH_MAX / width, CARD_HEIGHT_MAX / height); width *= downScale; height *= downScale; } } width = clamp(width, CARD_WIDTH_MIN, CARD_WIDTH_MAX); height = clamp(height, CARD_HEIGHT_MIN, CARD_HEIGHT_MAX); } else { const widthSeed = seededRandom(`${key}:width`); const ratioSeed = seededRandom(`${key}:ratio`); const ratio = CARD_ASPECT_MIN + ratioSeed * (CARD_ASPECT_MAX - CARD_ASPECT_MIN); width = CARD_WIDTH_MIN + widthSeed * (CARD_WIDTH_MAX - CARD_WIDTH_MIN); height = width * ratio; if (width > CARD_WIDTH_MAX || height > CARD_HEIGHT_MAX) { const scale = Math.min(CARD_WIDTH_MAX / width, CARD_HEIGHT_MAX / height); width *= scale; height *= scale; } if (width < CARD_WIDTH_MIN || height < CARD_HEIGHT_MIN) { const scale = Math.max(CARD_WIDTH_MIN / width, CARD_HEIGHT_MIN / height); width *= scale; height *= scale; } width = clamp(width, CARD_WIDTH_MIN, CARD_WIDTH_MAX); height = clamp(height, CARD_HEIGHT_MIN, CARD_HEIGHT_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_WIDTH_MIN; const safeCardHeight = cardHeight || CARD_HEIGHT_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], ); const syncLayoutSnapshot = useCallback(() => { setLayoutSnapshot(new Map(layoutRef.current)); }, []); useLayoutEffect(() => { const container = containerRef.current; if (!container) return () => {}; 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(); }, []); useEffect(() => { 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; 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; } const seedX = seededRandom(`${resolveSizeKey(doc)}:centerX`); const seedY = seededRandom(`${resolveSizeKey(doc)}:centerY`); const rangeX = Math.max(maxCenterX - minCenterX, 0); const rangeY = Math.max(maxCenterY - minCenterY, 0); const centerX = rangeX > 0 ? minCenterX + seedX * rangeX : (minCenterX + maxCenterX) / 2; const centerY = rangeY > 0 ? minCenterY + seedY * rangeY : (minCenterY + maxCenterY) / 2; const rotation = randomRangeFromSeed(buildKey(doc.id, 'rotation'), -ROTATION_RANGE, ROTATION_RANGE); maxZ += 1; next.set(doc.id, { centerX, centerY, rotation, z: maxZ }); }); layoutRef.current = next; zCounterRef.current = Math.max(zCounterRef.current, maxZ); syncLayoutSnapshot(); }, [items, canvasSize.width, canvasSize.height, 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 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.