diff --git a/frontend/src/detail/DetailPanel.jsx b/frontend/src/detail/DetailPanel.jsx index 4207e86..4dabd2b 100644 --- a/frontend/src/detail/DetailPanel.jsx +++ b/frontend/src/detail/DetailPanel.jsx @@ -1,5 +1,4 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { createPortal } from 'react-dom'; import { DownloadIcon, EditIcon, @@ -15,6 +14,7 @@ import { formatFileSize } from '../utils/format'; import { resolveDocumentAssetUrl, createAssetView } from '../asset_manager'; import { useAssetNavigator } from '../hooks/useAssetNavigator'; import { CORRESPONDENT_ROLES } from '../constants/correspondents'; +import PreviewZoomOverlay from './PreviewZoomOverlay'; const MAX_PREVIEW_STACK_ITEMS = 15; @@ -351,35 +351,6 @@ const DetailPanel = ({ const [ocrLoading, setOcrLoading] = useState(false); const [ocrError, setOcrError] = useState(null); const [zoomedPreview, setZoomedPreview] = useState(null); - const [zoomNative, setZoomNative] = useState(false); - const [zoomPan, setZoomPan] = useState({ x: 0, y: 0 }); - const [isZoomDragging, setIsZoomDragging] = useState(false); - const zoomStageRef = useRef(null); - const zoomImageMetricsRef = useRef({ naturalWidth: 0, naturalHeight: 0 }); - const zoomDragRef = useRef(null); - const zoomSkipClickRef = useRef(false); - - const clampPan = useCallback( - (x, y) => { - const stage = zoomStageRef.current; - const { naturalWidth, naturalHeight } = zoomImageMetricsRef.current; - if (!stage || !naturalWidth || !naturalHeight) { - return { x: 0, y: 0 }; - } - const stageRect = stage.getBoundingClientRect(); - if (stageRect.width <= 0 || stageRect.height <= 0) { - return { x: 0, y: 0 }; - } - const imageWidth = zoomNative ? naturalWidth : Math.min(naturalWidth, stageRect.width); - const imageHeight = zoomNative ? naturalHeight : Math.min(naturalHeight, stageRect.height); - const limitX = Math.max(0, (imageWidth - stageRect.width) / 2); - const limitY = Math.max(0, (imageHeight - stageRect.height) / 2); - const clampedX = Math.min(Math.max(x, -limitX), limitX); - const clampedY = Math.min(Math.max(y, -limitY), limitY); - return { x: clampedX, y: clampedY }; - }, - [zoomNative], - ); useEffect(() => { if (!singleDoc) { @@ -413,51 +384,6 @@ const DetailPanel = ({ setZoomedPreview(null); }, [selectionKey]); - useEffect(() => { - if (!zoomedPreview) { - setZoomNative(false); - setZoomPan({ x: 0, y: 0 }); - setIsZoomDragging(false); - zoomDragRef.current = null; - zoomSkipClickRef.current = false; - } - }, [zoomedPreview]); - - useEffect(() => { - zoomSkipClickRef.current = false; - if (zoomNative) { - setZoomPan((current) => { - const clamped = clampPan(current.x, current.y); - if (clamped.x === current.x && clamped.y === current.y) { - return current; - } - return clamped; - }); - } else { - setZoomPan({ x: 0, y: 0 }); - setIsZoomDragging(false); - zoomDragRef.current = null; - } - }, [zoomNative, clampPan]); - - useEffect(() => { - if (!zoomNative) { - return undefined; - } - const handleResize = () => { - setZoomPan((current) => { - const clamped = clampPan(current.x, current.y); - if (clamped.x === current.x && clamped.y === current.y) { - return current; - } - return clamped; - }); - }; - window.addEventListener('resize', handleResize); - handleResize(); - return () => window.removeEventListener('resize', handleResize); - }, [zoomNative, clampPan]); - const startTitleEdit = useCallback(() => { if (!singleDoc) return; setTitleEditDocId(singleDoc.id); @@ -826,22 +752,10 @@ const DetailPanel = ({ mode: config.mode, docId: config.docId ?? null, }); - setZoomNative(false); - setZoomPan({ x: 0, y: 0 }); - setIsZoomDragging(false); - zoomDragRef.current = null; - zoomSkipClickRef.current = false; - zoomImageMetricsRef.current = { naturalWidth: 0, naturalHeight: 0 }; }, []); const closeZoomPreview = useCallback(() => { setZoomedPreview(null); - setZoomNative(false); - setZoomPan({ x: 0, y: 0 }); - setIsZoomDragging(false); - zoomDragRef.current = null; - zoomSkipClickRef.current = false; - zoomImageMetricsRef.current = { naturalWidth: 0, naturalHeight: 0 }; }, []); const handleSingleZoom = useCallback( @@ -863,160 +777,6 @@ const DetailPanel = ({ [openZoomPreview, stackTopDocId, topHasPreview], ); - const handleZoomImageLoad = useCallback( - (event) => { - zoomImageMetricsRef.current = { - naturalWidth: event.currentTarget.naturalWidth || 0, - naturalHeight: event.currentTarget.naturalHeight || 0, - }; - setZoomPan((current) => { - const clamped = clampPan(current.x, current.y); - return { x: clamped.x, y: clamped.y }; - }); - }, - [clampPan], - ); - - const handleZoomImageClick = useCallback( - (event) => { - event.stopPropagation(); - if (zoomSkipClickRef.current) { - zoomSkipClickRef.current = false; - return; - } - - if (!zoomNative) { - const stage = zoomStageRef.current; - const { naturalWidth, naturalHeight } = zoomImageMetricsRef.current; - if (stage && naturalWidth && naturalHeight) { - const stageRect = stage.getBoundingClientRect(); - const imageRect = event.currentTarget.getBoundingClientRect(); - const clickX = event.clientX - imageRect.left; - const clickY = event.clientY - imageRect.top; - const ratioX = imageRect.width ? clickX / imageRect.width : 0.5; - const ratioY = imageRect.height ? clickY / imageRect.height : 0.5; - const focusX = naturalWidth * ratioX; - const focusY = naturalHeight * ratioY; - const halfWidth = naturalWidth / 2; - const halfHeight = naturalHeight / 2; - const limitX = Math.max(0, (naturalWidth - stageRect.width) / 2); - const limitY = Math.max(0, (naturalHeight - stageRect.height) / 2); - let targetPanX = -(focusX - halfWidth); - let targetPanY = -(focusY - halfHeight); - targetPanX = Math.min(Math.max(targetPanX, -limitX), limitX); - targetPanY = Math.min(Math.max(targetPanY, -limitY), limitY); - setZoomPan({ x: targetPanX, y: targetPanY }); - } else { - setZoomPan({ x: 0, y: 0 }); - } - setZoomNative(true); - } else { - setZoomNative(false); - } - }, - [zoomNative], - ); - - const handleZoomImagePointerDown = useCallback( - (event) => { - if (!zoomNative || event.button !== 0) { - return; - } - event.preventDefault(); - zoomSkipClickRef.current = false; - zoomDragRef.current = { - pointerId: event.pointerId, - originX: zoomPan.x, - originY: zoomPan.y, - startX: event.clientX, - startY: event.clientY, - moved: false, - }; - event.currentTarget.setPointerCapture(event.pointerId); - }, - [zoomNative, zoomPan], - ); - - const handleZoomImagePointerMove = useCallback( - (event) => { - const drag = zoomDragRef.current; - if (!drag || drag.pointerId !== event.pointerId) { - return; - } - const deltaX = event.clientX - drag.startX; - const deltaY = event.clientY - drag.startY; - if (!drag.moved && (Math.abs(deltaX) > 2 || Math.abs(deltaY) > 2)) { - drag.moved = true; - setIsZoomDragging(true); - } - if (drag.moved) { - const clamped = clampPan(drag.originX + deltaX, drag.originY + deltaY); - setZoomPan((current) => - current.x === clamped.x && current.y === clamped.y ? current : clamped, - ); - } - }, - [clampPan], - ); - - const endZoomDrag = useCallback(() => { - zoomDragRef.current = null; - setIsZoomDragging(false); - }, []); - - const handleZoomImagePointerUp = useCallback( - (event) => { - const drag = zoomDragRef.current; - if (!drag || drag.pointerId !== event.pointerId) { - return; - } - event.currentTarget.releasePointerCapture(event.pointerId); - zoomSkipClickRef.current = Boolean(drag.moved); - endZoomDrag(); - }, - [endZoomDrag], - ); - - const handleZoomImagePointerCancel = useCallback( - (event) => { - const drag = zoomDragRef.current; - if (!drag || drag.pointerId !== event.pointerId) { - return; - } - event.currentTarget.releasePointerCapture(event.pointerId); - zoomSkipClickRef.current = true; - endZoomDrag(); - }, - [endZoomDrag], - ); - - const handleZoomStageWheel = useCallback( - (event) => { - if (!zoomNative) { - return; - } - const deltaX = Number.isFinite(event.deltaX) ? event.deltaX : 0; - const deltaY = Number.isFinite(event.deltaY) ? event.deltaY : 0; - if (!deltaX && !deltaY) { - return; - } - let updated = false; - setZoomPan((current) => { - const clamped = clampPan(current.x - deltaX, current.y - deltaY); - if (clamped.x === current.x && clamped.y === current.y) { - return current; - } - updated = true; - return clamped; - }); - if (updated) { - event.preventDefault(); - event.stopPropagation(); - } - }, - [zoomNative, clampPan], - ); - const zoomDisplay = useMemo(() => { if (!zoomedPreview) { return null; @@ -1089,50 +849,6 @@ const DetailPanel = ({ } }, [zoomedPreview, zoomDisplay]); - const zoomDisplayUrl = zoomDisplay?.url; - - useEffect(() => { - if (!zoomDisplayUrl) { - return; - } - setZoomPan((current) => { - const clamped = clampPan(current.x, current.y); - if (clamped.x === current.x && clamped.y === current.y) { - return current; - } - return clamped; - }); - }, [zoomDisplayUrl, clampPan]); - - useEffect(() => { - if (!zoomedPreview) { - return undefined; - } - const handleKeyDown = (event) => { - if (event.key === 'Escape') { - setZoomedPreview(null); - return; - } - if (event.key === 'ArrowLeft') { - event.preventDefault(); - event.stopPropagation(); - if (zoomDisplay?.canGoPrev) { - zoomDisplay.goPrev?.(); - } - return; - } - if (event.key === 'ArrowRight') { - event.preventDefault(); - event.stopPropagation(); - if (zoomDisplay?.canGoNext) { - zoomDisplay.goNext?.(); - } - } - }; - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [zoomedPreview, zoomDisplay]); - useEffect(() => { if (typeof ensureAssetUrl !== 'function') { return; @@ -1196,38 +912,6 @@ const DetailPanel = ({ stackPreviewNavigator.canGoNext, ]); - const zoomImageStyle = useMemo(() => { - if (!zoomNative) { - return { - cursor: 'zoom-in', - transform: 'none', - maxWidth: '95vw', - maxHeight: '95vh', - }; - } - const { naturalWidth, naturalHeight } = zoomImageMetricsRef.current; - return { - cursor: isZoomDragging ? 'grabbing' : 'grab', - width: naturalWidth ? `${naturalWidth}px` : 'auto', - height: naturalHeight ? `${naturalHeight}px` : 'auto', - maxWidth: 'none', - maxHeight: 'none', - transform: `translate3d(${zoomPan.x}px, ${zoomPan.y}px, 0)`, - }; - }, [zoomNative, zoomPan.x, zoomPan.y, isZoomDragging]); - - const zoomStageClassName = useMemo( - () => - [ - 'preview-zoom__stage', - zoomNative ? 'preview-zoom__stage--native' : '', - isZoomDragging ? 'preview-zoom__stage--dragging' : '', - ] - .filter(Boolean) - .join(' '), - [zoomNative, isZoomDragging], - ); - const renderSingle = () => { if (!singleDoc) { return

Select a document to view metadata, tags and actions.

; @@ -1662,70 +1346,11 @@ const DetailPanel = ({ {selectedCount <= 1 ? renderSingle() : renderBulk()} - {zoomDisplay?.url - ? createPortal( -
-
- {zoomDisplay.alt} - {zoomDisplay.canGoPrev || zoomDisplay.canGoNext ? ( -
- - -
- ) : null} -
-
, - document.body, - ) - : null} + ); }; diff --git a/frontend/src/detail/PreviewZoomOverlay.jsx b/frontend/src/detail/PreviewZoomOverlay.jsx new file mode 100644 index 0000000..1b02046 --- /dev/null +++ b/frontend/src/detail/PreviewZoomOverlay.jsx @@ -0,0 +1,388 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { ArrowLeftIcon, ArrowRightIcon } from '../ui/icons'; + +const noop = () => {}; + +const clamp = (value, min, max) => { + if (value < min) return min; + if (value > max) return max; + return value; +}; + +const ensureDocumentRoot = () => { + if (typeof document === 'undefined') { + return null; + } + return document.body; +}; + +const PreviewZoomOverlay = ({ open = false, display = null, onClose = noop }) => { + const portalTarget = ensureDocumentRoot(); + const isActive = Boolean(open && display?.url && portalTarget); + + const stageRef = useRef(null); + const imageMetricsRef = useRef({ naturalWidth: 0, naturalHeight: 0 }); + const dragRef = useRef(null); + const skipClickRef = useRef(false); + + const [isNativeScale, setIsNativeScale] = useState(false); + const [pan, setPan] = useState({ x: 0, y: 0 }); + const [isDragging, setIsDragging] = useState(false); + + const resetInteraction = useCallback(() => { + setIsNativeScale(false); + setPan({ x: 0, y: 0 }); + setIsDragging(false); + dragRef.current = null; + skipClickRef.current = false; + imageMetricsRef.current = { naturalWidth: 0, naturalHeight: 0 }; + }, []); + + useEffect(() => { + if (!open) { + resetInteraction(); + } + }, [open, resetInteraction]); + + useEffect(() => { + if (!isActive) { + return; + } + resetInteraction(); + }, [isActive, display?.url, resetInteraction]); + + const clampPan = useCallback( + (x, y) => { + const stage = stageRef.current; + const { naturalWidth, naturalHeight } = imageMetricsRef.current; + if (!stage || !naturalWidth || !naturalHeight) { + return { x: 0, y: 0 }; + } + + const stageRect = stage.getBoundingClientRect(); + if (stageRect.width <= 0 || stageRect.height <= 0) { + return { x: 0, y: 0 }; + } + + const imageWidth = isNativeScale + ? naturalWidth + : Math.min(naturalWidth, stageRect.width); + const imageHeight = isNativeScale + ? naturalHeight + : Math.min(naturalHeight, stageRect.height); + + const limitX = Math.max(0, (imageWidth - stageRect.width) / 2); + const limitY = Math.max(0, (imageHeight - stageRect.height) / 2); + + return { + x: clamp(x, -limitX, limitX), + y: clamp(y, -limitY, limitY), + }; + }, + [isNativeScale], + ); + + const handleBackdropClick = useCallback(() => { + onClose(); + }, [onClose]); + + const handleStageClick = useCallback((event) => { + event.stopPropagation(); + }, []); + + const handleImageLoad = useCallback( + (event) => { + imageMetricsRef.current = { + naturalWidth: event.currentTarget.naturalWidth || 0, + naturalHeight: event.currentTarget.naturalHeight || 0, + }; + setPan((current) => { + const clamped = clampPan(current.x, current.y); + if (clamped.x === current.x && clamped.y === current.y) { + return current; + } + return clamped; + }); + }, + [clampPan], + ); + + const handleImageClick = useCallback( + (event) => { + event.stopPropagation(); + if (skipClickRef.current) { + skipClickRef.current = false; + return; + } + + if (!isNativeScale) { + const stage = stageRef.current; + const { naturalWidth, naturalHeight } = imageMetricsRef.current; + if (stage && naturalWidth && naturalHeight) { + const stageRect = stage.getBoundingClientRect(); + const imageRect = event.currentTarget.getBoundingClientRect(); + const clickX = event.clientX - imageRect.left; + const clickY = event.clientY - imageRect.top; + const ratioX = imageRect.width ? clickX / imageRect.width : 0.5; + const ratioY = imageRect.height ? clickY / imageRect.height : 0.5; + const focusX = naturalWidth * ratioX; + const focusY = naturalHeight * ratioY; + const halfWidth = naturalWidth / 2; + const halfHeight = naturalHeight / 2; + const limitX = Math.max(0, (naturalWidth - stageRect.width) / 2); + const limitY = Math.max(0, (naturalHeight - stageRect.height) / 2); + const targetPanX = clamp(-(focusX - halfWidth), -limitX, limitX); + const targetPanY = clamp(-(focusY - halfHeight), -limitY, limitY); + setPan({ x: targetPanX, y: targetPanY }); + } else { + setPan({ x: 0, y: 0 }); + } + setIsNativeScale(true); + } else { + setIsNativeScale(false); + } + }, + [isNativeScale], + ); + + const endDrag = useCallback(() => { + dragRef.current = null; + setIsDragging(false); + }, []); + + const handlePointerDown = useCallback( + (event) => { + if (!isNativeScale || event.button !== 0) { + return; + } + + event.preventDefault(); + skipClickRef.current = false; + dragRef.current = { + pointerId: event.pointerId, + originX: pan.x, + originY: pan.y, + startX: event.clientX, + startY: event.clientY, + moved: false, + }; + event.currentTarget.setPointerCapture(event.pointerId); + }, + [isNativeScale, pan.x, pan.y], + ); + + const handlePointerMove = useCallback( + (event) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) { + return; + } + + const deltaX = event.clientX - drag.startX; + const deltaY = event.clientY - drag.startY; + if (!drag.moved && (Math.abs(deltaX) > 2 || Math.abs(deltaY) > 2)) { + drag.moved = true; + setIsDragging(true); + } + if (drag.moved) { + const clamped = clampPan(drag.originX + deltaX, drag.originY + deltaY); + setPan((current) => + current.x === clamped.x && current.y === clamped.y ? current : clamped, + ); + } + }, + [clampPan], + ); + + const handlePointerUp = useCallback( + (event) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) { + return; + } + event.currentTarget.releasePointerCapture(event.pointerId); + skipClickRef.current = Boolean(drag.moved); + endDrag(); + }, + [endDrag], + ); + + const handlePointerCancel = useCallback( + (event) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) { + return; + } + event.currentTarget.releasePointerCapture(event.pointerId); + skipClickRef.current = true; + endDrag(); + }, + [endDrag], + ); + + const handleWheel = useCallback( + (event) => { + if (!isNativeScale) { + return; + } + const deltaX = Number.isFinite(event.deltaX) ? event.deltaX : 0; + const deltaY = Number.isFinite(event.deltaY) ? event.deltaY : 0; + if (!deltaX && !deltaY) { + return; + } + let updated = false; + setPan((current) => { + const clamped = clampPan(current.x - deltaX, current.y - deltaY); + if (clamped.x === current.x && clamped.y === current.y) { + return current; + } + updated = true; + return clamped; + }); + if (updated) { + event.preventDefault(); + event.stopPropagation(); + } + }, + [isNativeScale, clampPan], + ); + + useEffect(() => { + if (!isActive) { + return undefined; + } + + const handleKeyDown = (event) => { + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + onClose(); + return; + } + + if (event.key === 'ArrowLeft') { + event.preventDefault(); + event.stopPropagation(); + if (display?.canGoPrev && display?.goPrev) { + display.goPrev(); + } + return; + } + + if (event.key === 'ArrowRight') { + event.preventDefault(); + event.stopPropagation(); + if (display?.canGoNext && display?.goNext) { + display.goNext(); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isActive, display, onClose]); + + const imageStyle = useMemo(() => { + if (!isNativeScale) { + return { + cursor: 'zoom-in', + transform: 'none', + maxWidth: '95vw', + maxHeight: '95vh', + }; + } + + const { naturalWidth, naturalHeight } = imageMetricsRef.current; + return { + cursor: isDragging ? 'grabbing' : 'grab', + width: naturalWidth ? `${naturalWidth}px` : 'auto', + height: naturalHeight ? `${naturalHeight}px` : 'auto', + maxWidth: 'none', + maxHeight: 'none', + transform: `translate3d(${pan.x}px, ${pan.y}px, 0)`, + }; + }, [isNativeScale, pan.x, pan.y, isDragging]); + + const stageClassName = useMemo( + () => + [ + 'preview-zoom__stage', + isNativeScale ? 'preview-zoom__stage--native' : '', + isDragging ? 'preview-zoom__stage--dragging' : '', + ] + .filter(Boolean) + .join(' '), + [isNativeScale, isDragging], + ); + + if (!isActive) { + return null; + } + + return createPortal( + ( +
+
+ {display?.alt +
+ + +
+
+
+ ), + portalTarget, + ); +}; + +export default PreviewZoomOverlay; diff --git a/frontend/src/skeuomorphic_ws.css b/frontend/src/skeuomorphic_ws.css index ff0da1a..992ef54 100644 --- a/frontend/src/skeuomorphic_ws.css +++ b/frontend/src/skeuomorphic_ws.css @@ -68,10 +68,6 @@ transition: none; } -.skeuo-item.is-zoomed { - cursor: pointer; -} - .skeuo-item.is-tag-target .skeuo-item__card { outline: 1em dashed var(--accent); outline-offset: 1.41em; diff --git a/frontend/src/skeuomorphic_ws.jsx b/frontend/src/skeuomorphic_ws.jsx index 39a580a..b5bd2e4 100644 --- a/frontend/src/skeuomorphic_ws.jsx +++ b/frontend/src/skeuomorphic_ws.jsx @@ -9,6 +9,7 @@ import React, { import { resolveDocumentAssetUrl, createAssetView } from './asset_manager'; import { useAssetNavigator } from './hooks/useAssetNavigator'; import { ArrowLeftIcon, ArrowRightIcon } from './ui/icons'; +import PreviewZoomOverlay from './detail/PreviewZoomOverlay'; import { getReadableTextColor } from './utils/colors'; import './skeuomorphic_ws.css'; @@ -28,9 +29,6 @@ const resolveSizeKey = (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 DEBUG_DRAG = false; @@ -52,6 +50,7 @@ const SkeuoPreviewCard = ({ getDocumentAsset, navScale = 1, prefetch = 3, + onNavigatorSnapshot, }) => { const navigator = useAssetNavigator({ document: doc, @@ -62,6 +61,32 @@ const SkeuoPreviewCard = ({ }); const { currentUrl, cardinality, canGoPrev, canGoNext } = navigator; + const docId = doc?.id ?? null; + + useEffect(() => { + if (!onNavigatorSnapshot || !docId) { + return undefined; + } + const snapshot = { + url: currentUrl || null, + alt: title, + canGoPrev, + canGoNext, + goPrev: navigator.goPrev, + goNext: navigator.goNext, + }; + onNavigatorSnapshot(docId, snapshot); + return () => onNavigatorSnapshot(docId, null); + }, [ + docId, + currentUrl, + title, + canGoPrev, + canGoNext, + navigator.goPrev, + navigator.goNext, + onNavigatorSnapshot, + ]); const hasPreview = Boolean(currentUrl); const cardClasses = ['skeuo-item__card']; if (!hasPreview) cardClasses.push('skeuo-item__card--empty'); @@ -303,7 +328,7 @@ const useDocumentDrag = ({ setDraggingId, syncLayoutSnapshot, canvasSize, - toggleZoom, + openOverlayForDoc, }) => { const dragStateRef = useRef(null); @@ -330,7 +355,7 @@ const useDocumentDrag = ({ ); const handlePointerDown = useCallback( - (event, docId, { lockWhenZoomed = false } = {}) => { + (event, docId) => { if (DEBUG_DRAG) { console.log( '[skeuo] handlePointerDown fired for doc', @@ -376,7 +401,7 @@ const useDocumentDrag = ({ startY: event.clientY, rotation: entry?.rotation ?? 0, moved: false, - locked: Boolean(lockWhenZoomed), + locked: false, width: docWidth, height: docHeight, scale: baseScale, @@ -475,13 +500,13 @@ const useDocumentDrag = ({ const docId = state.docId; finishDrag(event.pointerId); if (!moved) { - toggleZoom(docId); + openOverlayForDoc(docId); } return; } finishDrag(event.pointerId); }, - [finishDrag, toggleZoom], + [finishDrag, openOverlayForDoc], ); const handlePointerCancel = useCallback( @@ -567,7 +592,8 @@ const SkeuomorphicWorkspace = ({ const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 }); const [draggingId, setDraggingId] = useState(null); - const [zoomedId, setZoomedId] = useState(null); + const [overlayDocId, setOverlayDocId] = useState(null); + const [previewSnapshots, setPreviewSnapshots] = useState(() => new Map()); const [tagDropTargetId, setTagDropTargetId] = useState(null); const [pendingTagDocId, setPendingTagDocId] = useState(null); const [pendingRemovalTag, setPendingRemovalTag] = useState(null); @@ -575,6 +601,36 @@ const SkeuomorphicWorkspace = ({ const pendingDocTagDragRef = useRef(null); const docSizeMapRef = useRef(new Map()); const removalCursorActiveRef = useRef(false); + 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; + if (sameSnapshot) { + return previous; + } + next.set(docId, snapshot); + return next; + }); + }, []); const activeTagSet = useMemo(() => { if (!Array.isArray(activeTagIds) || activeTagIds.length === 0) { return new Set(); @@ -774,70 +830,35 @@ const SkeuomorphicWorkspace = ({ docSizeMapRef.current = new Map(); }, [items]); - 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; + 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]); - let zoomWidth = safeCardWidth; - let zoomHeight = safeCardHeight; + const closeOverlay = useCallback(() => { + setOverlayDocId(null); + }, []); - 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], - ); + useEffect(() => { + if (overlayDocId && !documentLookup.has(overlayDocId)) { + setOverlayDocId(null); + } + }, [overlayDocId, documentLookup]); const resolveBaseMetrics = useCallback( (doc, cardWidth, cardHeight) => { @@ -1009,17 +1030,19 @@ const SkeuomorphicWorkspace = ({ [syncLayoutSnapshot], ); - const toggleZoom = useCallback( + const openOverlayForDoc = useCallback( (docId) => { - setZoomedId((current) => { - if (current === docId) { - return null; - } - bringToFront(docId); - return docId; - }); + if (!docId) { + return; + } + const snapshot = previewSnapshots.get(docId); + if (!snapshot || !snapshot.url) { + return; + } + bringToFront(docId); + setOverlayDocId(docId); }, - [bringToFront], + [bringToFront, previewSnapshots], ); const { handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel } = useDocumentDrag({ layoutRef, @@ -1031,7 +1054,7 @@ const SkeuomorphicWorkspace = ({ setDraggingId, syncLayoutSnapshot, canvasSize, - toggleZoom, + openOverlayForDoc, }); const handleTagDragEnterDoc = useCallback( @@ -1485,14 +1508,15 @@ const SkeuomorphicWorkspace = ({ ); return ( -
-
+ <> +
+
{items.length === 0 ? (

No documents to show here yet. Drop files to make this space come alive.

@@ -1513,31 +1537,17 @@ const SkeuomorphicWorkspace = ({ typeof layout.centerX === 'number' ? layout.centerX : fallbackLayout.centerX; const layoutCenterY = typeof layout.centerY === 'number' ? layout.centerY : fallbackLayout.centerY; - const isZoomed = zoomedId === doc.id; - const { zoomWidth, zoomHeight, zoomCenterX, zoomCenterY } = resolveZoomMetrics( - doc, - cardWidth, - cardHeight, - ); - const targetCenterX = isZoomed ? zoomCenterX : layoutCenterX; - const targetCenterY = isZoomed ? zoomCenterY : layoutCenterY; - const rotation = isZoomed ? 0 : layout.rotation || 0; - const zoomScale = isZoomed - ? Math.min( - 1, - Number.isFinite(zoomWidth / baseWidth) ? zoomWidth / baseWidth : 1, - Number.isFinite(zoomHeight / baseHeight) ? zoomHeight / baseHeight : 1, - ) - : baseScale; + const rotation = layout.rotation || 0; + const zoomScale = baseScale; const transform = formatTransform( - Math.round(targetCenterX), - Math.round(targetCenterY), + Math.round(layoutCenterX), + Math.round(layoutCenterY), rotation, zoomScale, ); const style = { transform, - zIndex: isZoomed ? 9999 : layout.z ?? 1, + zIndex: layout.z ?? 1, }; const bodyStyle = { width: Math.round(baseWidth), @@ -1558,7 +1568,6 @@ const SkeuomorphicWorkspace = ({ const dropPending = pendingTagDocId === doc.id; const itemClasses = ['skeuo-item']; if (dragging) itemClasses.push('is-dragging'); - if (isZoomed) itemClasses.push('is-zoomed'); if (dropActive) itemClasses.push('is-tag-target'); if (dropPending) itemClasses.push('is-tag-pending'); if (!matchesFilter) itemClasses.push('is-filtered-out'); @@ -1579,9 +1588,7 @@ const SkeuomorphicWorkspace = ({ itemRefs.current.delete(doc.id); } }} - onPointerDown={(event) => - handlePointerDown(event, doc.id, { lockWhenZoomed: isZoomed }) - } + onPointerDown={(event) => handlePointerDown(event, doc.id)} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} onPointerCancel={handlePointerCancel} @@ -1603,6 +1610,7 @@ const SkeuomorphicWorkspace = ({ ensureAssetUrl={ensureAssetUrl} getDocumentAsset={getDocumentAsset} navScale={inverseTagScale} + onNavigatorSnapshot={handleNavigatorSnapshot} /> {tags.length > 0 && (
-
+ + ); };