From e351638e527bad9e11b4474d39aa4bdc84d940aa Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Tue, 28 Oct 2025 12:34:43 +0100 Subject: [PATCH] frontend --- frontend/src/desktop/useDocumentDrag.js | 22 ++-- frontend/src/detail/PreviewZoomOverlay.jsx | 120 ++++++++++++++++----- frontend/src/styles.css | 11 +- 3 files changed, 112 insertions(+), 41 deletions(-) diff --git a/frontend/src/desktop/useDocumentDrag.js b/frontend/src/desktop/useDocumentDrag.js index c310ce5..406c4f0 100644 --- a/frontend/src/desktop/useDocumentDrag.js +++ b/frontend/src/desktop/useDocumentDrag.js @@ -3,6 +3,9 @@ import { useDesktopContext } from './context'; import { preventAll } from './events'; import { clamp, formatTransform } from './math'; +const DRAG_HYSTERESIS_PX = 4; +const DRAG_HYSTERESIS_SQUARED = DRAG_HYSTERESIS_PX * DRAG_HYSTERESIS_PX; + const useDocumentDrag = () => { const { layoutRef, @@ -59,7 +62,6 @@ const useDocumentDrag = () => { ); } preventAll(event); - const entry = layoutRef.current.get(docId) || null; const docKey = docId != null ? String(docId) : null; const doc = docKey ? documentLookup.get(docKey) : null; const { width: docWidth, height: docHeight } = ensureDocumentSize(doc); @@ -68,6 +70,7 @@ const useDocumentDrag = () => { Number.isFinite(baseScale) && baseScale > 0 ? baseScale : 1; const defaultCenterX = canvasPadding + docWidth / 2; const defaultCenterY = canvasPadding + docHeight / 2; + const entry = layoutRef.current.get(docId) || null; const centerX = typeof entry?.centerX === 'number' ? entry.centerX : defaultCenterX; const centerY = typeof entry?.centerY === 'number' ? entry.centerY : defaultCenterY; @@ -99,7 +102,6 @@ const useDocumentDrag = () => { dragScale: 1, baseScale: normalizedBaseScale, capturedTarget, - raised: false, }; setDraggingId(docId); }, @@ -166,18 +168,13 @@ const useDocumentDrag = () => { 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 (debugDrag) { - console.log('[skeuo] handlePointerMove: movement under threshold for doc', state.docId); + if (!state.moved) { + const distanceSquared = deltaX * deltaX + deltaY * deltaY; + if (distanceSquared < DRAG_HYSTERESIS_SQUARED) { + return; } - return; - } - - if (!state.raised) { bringToFront(state.docId); - state.raised = true; + state.moved = true; } const updated = { ...entry, centerX: clampedCenterX, centerY: clampedCenterY }; @@ -192,7 +189,6 @@ const useDocumentDrag = () => { state.dragScale || 1, ); } - state.moved = true; if (debugDrag) { console.log('[skeuo] handlePointerMove: moved doc', state.docId, 'to', clampedCenterX, clampedCenterY); } diff --git a/frontend/src/detail/PreviewZoomOverlay.jsx b/frontend/src/detail/PreviewZoomOverlay.jsx index d07b23a..b579e13 100644 --- a/frontend/src/detail/PreviewZoomOverlay.jsx +++ b/frontend/src/detail/PreviewZoomOverlay.jsx @@ -25,10 +25,68 @@ const PreviewZoomOverlay = ({ const portalTarget = ensureDocumentRoot(); const [isNativeScale, setIsNativeScale] = useState(false); const [naturalSize, setNaturalSize] = useState({ width: null, height: null }); + const [renderBackdrop, setRenderBackdrop] = useState(false); + const [isBackdropVisible, setBackdropVisible] = useState(false); + const [displaySnapshot, setDisplaySnapshot] = useState(null); const scrollRef = useRef(null); const imageRef = useRef(null); const focusRef = useRef(null); const previouslyFocusedRef = useRef(null); + const visibilityTimerRef = useRef(null); + const displayTimerRef = useRef(null); + + useEffect(() => { + if (display?.url) { + setDisplaySnapshot(display); + } + }, [display]); + + useEffect(() => { + if (visibilityTimerRef.current) { + clearTimeout(visibilityTimerRef.current); + visibilityTimerRef.current = null; + } + if (displayTimerRef.current) { + cancelAnimationFrame(displayTimerRef.current); + displayTimerRef.current = null; + } + + if (open && display?.url) { + setRenderBackdrop(true); + displayTimerRef.current = requestAnimationFrame(() => { + displayTimerRef.current = requestAnimationFrame(() => { + setBackdropVisible(true); + }); + }); + return () => { + if (displayTimerRef.current) { + cancelAnimationFrame(displayTimerRef.current); + displayTimerRef.current = null; + } + }; + } + + setBackdropVisible(false); + visibilityTimerRef.current = setTimeout(() => { + setRenderBackdrop(false); + }, 260); + + return () => { + if (visibilityTimerRef.current) { + clearTimeout(visibilityTimerRef.current); + visibilityTimerRef.current = null; + } + }; + }, [open, display?.url]); + + useEffect(() => () => { + if (visibilityTimerRef.current) { + clearTimeout(visibilityTimerRef.current); + } + if (displayTimerRef.current) { + cancelAnimationFrame(displayTimerRef.current); + } + }, []); useEffect(() => { setIsNativeScale(false); @@ -75,7 +133,7 @@ const PreviewZoomOverlay = ({ previouslyFocusedRef.current.focus(); } previouslyFocusedRef.current = null; - return undefined; + return; } if (typeof document !== 'undefined') { @@ -86,24 +144,24 @@ const PreviewZoomOverlay = ({ previouslyFocusedRef.current = null; } } + }, [open]); - const scrollEl = scrollRef.current; - if (!scrollEl) { + const activeDisplay = open && display?.url ? display : displaySnapshot; + + useEffect(() => { + if (!renderBackdrop || !activeDisplay?.url) { return undefined; } const frame = requestAnimationFrame(() => { - scrollEl.focus(); + const scrollEl = scrollRef.current; + if (scrollEl && typeof scrollEl.focus === 'function') { + scrollEl.focus({ preventScroll: true }); + } }); - return () => { - cancelAnimationFrame(frame); - if (previouslyFocusedRef.current && typeof previouslyFocusedRef.current.focus === 'function') { - previouslyFocusedRef.current.focus(); - previouslyFocusedRef.current = null; - } - }; - }, [open]); + return () => cancelAnimationFrame(frame); + }, [renderBackdrop, activeDisplay?.url]); const handleKeyDown = (event) => { event.stopPropagation(); @@ -119,26 +177,27 @@ const PreviewZoomOverlay = ({ } if (event.key === 'ArrowLeft') { - if (display?.canGoPrev && display?.goPrev) { + if (activeDisplay?.canGoPrev && activeDisplay?.goPrev) { event.preventDefault(); - display.goPrev(); + activeDisplay.goPrev(); } return; } if (event.key === 'ArrowRight') { - if (display?.canGoNext && display?.goNext) { + if (activeDisplay?.canGoNext && activeDisplay?.goNext) { event.preventDefault(); - display.goNext(); + activeDisplay.goNext(); } } }; - if (!open || !display?.url || !portalTarget) { + if (!renderBackdrop || !activeDisplay?.url || !portalTarget) { return null; } - const navVisible = Boolean(display?.canGoPrev || display?.canGoNext); + const effectiveDisplay = activeDisplay; + const navVisible = Boolean(effectiveDisplay?.canGoPrev || effectiveDisplay?.canGoNext); const stageClassName = [ 'preview-zoom__stage', ] @@ -152,6 +211,13 @@ const PreviewZoomOverlay = ({ .filter(Boolean) .join(' '); + const backdropClassName = [ + 'preview-zoom-backdrop', + isBackdropVisible ? 'preview-zoom-backdrop--visible' : '', + ] + .filter(Boolean) + .join(' '); + const imageStyle = isNativeScale ? { cursor: 'zoom-out', @@ -169,7 +235,7 @@ const PreviewZoomOverlay = ({ return createPortal( (
{display.alt { event.stopPropagation(); - if (display?.canGoPrev && display?.goPrev) { - display.goPrev(); + if (effectiveDisplay?.canGoPrev && effectiveDisplay?.goPrev) { + effectiveDisplay.goPrev(); } }} aria-label="Previous preview" - disabled={!display?.canGoPrev} + disabled={!effectiveDisplay?.canGoPrev} > @@ -241,12 +307,12 @@ const PreviewZoomOverlay = ({ className="preview-zoom__nav-button" onClick={(event) => { event.stopPropagation(); - if (display?.canGoNext && display?.goNext) { - display.goNext(); + if (effectiveDisplay?.canGoNext && effectiveDisplay?.goNext) { + effectiveDisplay.goNext(); } }} aria-label="Next preview" - disabled={!display?.canGoNext} + disabled={!effectiveDisplay?.canGoNext} > diff --git a/frontend/src/styles.css b/frontend/src/styles.css index d1e9f3d..0673c7c 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -244,13 +244,22 @@ button.danger:hover:not([disabled]) { .preview-zoom-backdrop { position: fixed; inset: 0; - background: var(--overlay-backdrop); + background: rgba(15, 23, 42, 0); + transition: background 0.25s ease, opacity 0.25s ease; display: flex; align-items: center; justify-content: center; padding: 2rem; z-index: 3000; cursor: zoom-out; + opacity: 0; + pointer-events: none; +} + +.preview-zoom-backdrop--visible { + opacity: 1; + background: var(--overlay-backdrop); + pointer-events: auto; } .preview-zoom__stage {