diff --git a/frontend/src/detail/PreviewZoomOverlay.jsx b/frontend/src/detail/PreviewZoomOverlay.jsx index 1b02046..d07b23a 100644 --- a/frontend/src/detail/PreviewZoomOverlay.jsx +++ b/frontend/src/detail/PreviewZoomOverlay.jsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { ArrowLeftIcon, ArrowRightIcon } from '../ui/icons'; @@ -17,308 +17,154 @@ const ensureDocumentRoot = () => { return document.body; }; -const PreviewZoomOverlay = ({ open = false, display = null, onClose = noop }) => { +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 [naturalSize, setNaturalSize] = useState({ width: null, height: null }); + const scrollRef = useRef(null); + const imageRef = useRef(null); + const focusRef = useRef(null); + const previouslyFocusedRef = useRef(null); - const resetInteraction = useCallback(() => { + useEffect(() => { setIsNativeScale(false); - setPan({ x: 0, y: 0 }); - setIsDragging(false); - dragRef.current = null; - skipClickRef.current = false; - imageMetricsRef.current = { naturalWidth: 0, naturalHeight: 0 }; - }, []); + setNaturalSize({ width: null, height: null }); + focusRef.current = null; + const scrollEl = scrollRef.current; + if (scrollEl) { + scrollEl.scrollLeft = 0; + scrollEl.scrollTop = 0; + } + }, [open]); + + useEffect(() => { + if (!open || !isNativeScale) { + return; + } + + const scrollEl = scrollRef.current; + const imageEl = imageRef.current; + if (!scrollEl || !imageEl) { + return; + } + + const imageWidth = imageEl.naturalWidth || imageEl.clientWidth; + const imageHeight = imageEl.naturalHeight || imageEl.clientHeight; + if (!(imageWidth > 0 && imageHeight > 0)) { + return; + } + + const target = focusRef.current || { xRatio: 0.5, yRatio: 0.5 }; + const maxScrollLeft = Math.max(0, imageWidth - scrollEl.clientWidth); + const maxScrollTop = Math.max(0, imageHeight - scrollEl.clientHeight); + + const desiredLeft = target.xRatio * imageWidth - scrollEl.clientWidth / 2; + const desiredTop = target.yRatio * imageHeight - scrollEl.clientHeight / 2; + + scrollEl.scrollLeft = clamp(desiredLeft, 0, maxScrollLeft); + scrollEl.scrollTop = clamp(desiredTop, 0, maxScrollTop); + }, [open, isNativeScale, naturalSize.width, naturalSize.height]); 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 }; + if (previouslyFocusedRef.current && typeof previouslyFocusedRef.current.focus === 'function') { + previouslyFocusedRef.current.focus(); } - - 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) { + previouslyFocusedRef.current = null; return undefined; } - const handleKeyDown = (event) => { - if (event.key === 'Escape') { - event.preventDefault(); - event.stopPropagation(); - onClose(); - return; + if (typeof document !== 'undefined') { + const active = document.activeElement; + if (active && typeof active.focus === 'function') { + previouslyFocusedRef.current = active; + } else { + previouslyFocusedRef.current = null; } + } - if (event.key === 'ArrowLeft') { - event.preventDefault(); - event.stopPropagation(); - if (display?.canGoPrev && display?.goPrev) { - display.goPrev(); - } - return; - } + const scrollEl = scrollRef.current; + if (!scrollEl) { + return undefined; + } - if (event.key === 'ArrowRight') { - event.preventDefault(); - event.stopPropagation(); - if (display?.canGoNext && display?.goNext) { - display.goNext(); - } + const frame = requestAnimationFrame(() => { + scrollEl.focus(); + }); + + return () => { + cancelAnimationFrame(frame); + if (previouslyFocusedRef.current && typeof previouslyFocusedRef.current.focus === 'function') { + previouslyFocusedRef.current.focus(); + previouslyFocusedRef.current = null; } }; + }, [open]); - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [isActive, display, onClose]); + const handleKeyDown = (event) => { + event.stopPropagation(); - const imageStyle = useMemo(() => { - if (!isNativeScale) { - return { + if (!open) { + return; + } + + if (event.key === 'Escape') { + event.preventDefault(); + onClose(); + return; + } + + if (event.key === 'ArrowLeft') { + if (display?.canGoPrev && display?.goPrev) { + event.preventDefault(); + display.goPrev(); + } + return; + } + + if (event.key === 'ArrowRight') { + if (display?.canGoNext && display?.goNext) { + event.preventDefault(); + display.goNext(); + } + } + }; + + if (!open || !display?.url || !portalTarget) { + return null; + } + + const navVisible = Boolean(display?.canGoPrev || display?.canGoNext); + const stageClassName = [ + 'preview-zoom__stage', + ] + .filter(Boolean) + .join(' '); + + const containerClassName = [ + 'preview-zoom__scroll', + isNativeScale ? 'preview-zoom__scroll--native' : '', + ] + .filter(Boolean) + .join(' '); + + const imageStyle = isNativeScale + ? { + cursor: 'zoom-out', + width: naturalSize.width ? `${naturalSize.width}px` : 'auto', + height: naturalSize.height ? `${naturalSize.height}px` : 'auto', + maxWidth: 'none', + maxHeight: 'none', + } + : { 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( ( @@ -327,57 +173,85 @@ const PreviewZoomOverlay = ({ open = false, display = null, onClose = noop }) => role="dialog" aria-modal="true" aria-label="Enlarged document preview" - onClick={handleBackdropClick} + onClick={onClose} + > +
event.stopPropagation()} + onKeyDown={handleKeyDown} >
{display?.alt { + setNaturalSize({ + width: event.currentTarget.naturalWidth || null, + height: event.currentTarget.naturalHeight || null, + }); + }} + onClick={(event) => { + event.stopPropagation(); + if (!isNativeScale) { + const img = imageRef.current; + if (img) { + const rect = img.getBoundingClientRect(); + const xRatio = rect.width > 0 ? (event.clientX - rect.left) / rect.width : 0.5; + const yRatio = rect.height > 0 ? (event.clientY - rect.top) / rect.height : 0.5; + focusRef.current = { + xRatio: clamp(xRatio, 0, 1), + yRatio: clamp(yRatio, 0, 1), + }; + } else { + focusRef.current = null; + } + } else { + focusRef.current = null; + } + setIsNativeScale((current) => !current); + }} + style={imageStyle} /> +
+ {navVisible ? (
- -
+ onClick={(event) => { + event.stopPropagation(); + if (display?.canGoPrev && display?.goPrev) { + display.goPrev(); + } + }} + aria-label="Previous preview" + disabled={!display?.canGoPrev} + > + + + +
+ ) : null} ), diff --git a/frontend/src/skeuomorphic_ws.jsx b/frontend/src/skeuomorphic_ws.jsx index b5bd2e4..6b9f858 100644 --- a/frontend/src/skeuomorphic_ws.jsx +++ b/frontend/src/skeuomorphic_ws.jsx @@ -500,7 +500,13 @@ const useDocumentDrag = ({ const docId = state.docId; finishDrag(event.pointerId); if (!moved) { - openOverlayForDoc(docId); + const originInfo = { + rotation: state.rotation || 0, + scale: state.scale || 1, + width: state.width, + height: state.height, + }; + openOverlayForDoc(docId, originInfo); } return; } @@ -593,6 +599,8 @@ const SkeuomorphicWorkspace = ({ const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 }); const [draggingId, setDraggingId] = useState(null); const [overlayDocId, setOverlayDocId] = useState(null); + const [overlayOriginRect, setOverlayOriginRect] = useState(null); + const [overlayOriginTransform, setOverlayOriginTransform] = useState(null); const [previewSnapshots, setPreviewSnapshots] = useState(() => new Map()); const [tagDropTargetId, setTagDropTargetId] = useState(null); const [pendingTagDocId, setPendingTagDocId] = useState(null); @@ -852,11 +860,15 @@ const SkeuomorphicWorkspace = ({ const closeOverlay = useCallback(() => { setOverlayDocId(null); + setOverlayOriginRect(null); + setOverlayOriginTransform(null); }, []); useEffect(() => { if (overlayDocId && !documentLookup.has(overlayDocId)) { setOverlayDocId(null); + setOverlayOriginRect(null); + setOverlayOriginTransform(null); } }, [overlayDocId, documentLookup]); @@ -1031,7 +1043,7 @@ const SkeuomorphicWorkspace = ({ ); const openOverlayForDoc = useCallback( - (docId) => { + (docId, originInfo = null) => { if (!docId) { return; } @@ -1039,10 +1051,53 @@ const SkeuomorphicWorkspace = ({ if (!snapshot || !snapshot.url) { return; } + const container = itemRefs.current.get(docId); + const imageNode = container?.querySelector?.('.skeuo-item__card img'); + if (!container || !imageNode) { + return; + } + const rect = imageNode.getBoundingClientRect(); + let originTransform = null; + if (originInfo) { + const { rotation = 0, scale = 1, width: originWidth, height: originHeight } = originInfo; + originTransform = { + rotation, + scaleX: scale, + scaleY: scale, + baseWidth: originWidth, + baseHeight: originHeight, + }; + } + if (!originTransform) { + const entry = layoutRef.current.get(docId) || null; + const doc = documentLookup.get(docId) || null; + const { width: cardWidth, height: cardHeight } = ensureDocumentSize(doc); + const { baseWidth, baseHeight, baseScale } = resolveBaseMetrics(doc, cardWidth, cardHeight); + const effectiveWidth = baseWidth * baseScale; + const effectiveHeight = baseHeight * baseScale; + originTransform = { + rotation: entry?.rotation ?? 0, + scaleX: baseScale, + scaleY: baseScale, + baseWidth: Number.isFinite(effectiveWidth) && effectiveWidth > 0 ? effectiveWidth : cardWidth, + baseHeight: Number.isFinite(effectiveHeight) && effectiveHeight > 0 ? effectiveHeight : cardHeight, + }; + } bringToFront(docId); + setOverlayOriginRect(rect); + setOverlayOriginTransform(originTransform); setOverlayDocId(docId); }, - [bringToFront, previewSnapshots], + [ + bringToFront, + previewSnapshots, + itemRefs, + setOverlayOriginTransform, + ensureDocumentSize, + resolveBaseMetrics, + documentLookup, + layoutRef, + ], ); const { handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel } = useDocumentDrag({ layoutRef, @@ -1662,6 +1717,8 @@ const SkeuomorphicWorkspace = ({ open={Boolean(overlayDisplay?.url)} display={overlayDisplay} onClose={closeOverlay} + originRect={overlayOriginRect} + originTransform={overlayOriginTransform} /> ); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index efe4bb3..cbb246e 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -229,31 +229,37 @@ button.danger:hover:not([disabled]) { .preview-zoom__stage { position: relative; - display: inline-flex; - flex-direction: column; + display: flex; align-items: center; justify-content: center; max-width: 95vw; max-height: 95vh; - cursor: zoom-in; } - -.preview-zoom__stage--native { - cursor: grab; -} - -.preview-zoom__stage--dragging { - cursor: grabbing; -} - .preview-zoom__image { max-width: 95vw; max-height: 95vh; width: auto; height: auto; - border-radius: 0; +} + +.preview-zoom__scroll { + display: flex; + align-items: center; + justify-content: center; + max-width: 95vw; + max-height: 95vh; box-shadow: 0 32px 120px rgba(15, 23, 42, 0.55); - cursor: inherit; +} + +.preview-zoom__scroll:focus { + outline: none; +} + +.preview-zoom__scroll--native { + overflow: auto; + cursor: zoom-out; + justify-content: flex-start; + align-items: flex-start; } .preview-zoom__nav {