diff --git a/frontend/src/lib/components/Viewer.svelte b/frontend/src/lib/components/Viewer.svelte index 5b6d7b4..8395fca 100644 --- a/frontend/src/lib/components/Viewer.svelte +++ b/frontend/src/lib/components/Viewer.svelte @@ -31,17 +31,29 @@ const current = $derived(photos[index]); - const MIN_ZOOM = 0.1; - const MAX_ZOOM = 8; + const MAX_ZOOM = 2; let viewerEl: HTMLDivElement | undefined = $state(); let scroller: HTMLDivElement | undefined = $state(); let imgEl: HTMLImageElement | undefined = $state(); + let topEl: HTMLDivElement | undefined = $state(); + let bottomEl: HTMLDivElement | undefined = $state(); let isFullscreen = $state(false); let zoomed = $state(false); let scale = $state(1); + let showTop = $state(false); + let showBottom = $state(false); + let showLeft = $state(false); + let showRight = $state(false); + + const EDGE = 8; + let hideTopTimer: ReturnType | undefined; + let hideBottomTimer: ReturnType | undefined; + let hideLeftTimer: ReturnType | undefined; + let hideRightTimer: ReturnType | undefined; + let dragging = false; let startX = 0; let startY = 0; @@ -68,13 +80,135 @@ }); $effect(() => { - const onFs = () => (isFullscreen = !!viewerEl && document.fullscreenElement === viewerEl); + const onFs = () => { + isFullscreen = !!viewerEl && document.fullscreenElement === viewerEl; + resetBars(); + }; document.addEventListener('fullscreenchange', onFs); - return () => document.removeEventListener('fullscreenchange', onFs); + return () => { + document.removeEventListener('fullscreenchange', onFs); + clearTimeout(hideTopTimer); + clearTimeout(hideBottomTimer); + clearTimeout(hideLeftTimer); + clearTimeout(hideRightTimer); + }; }); + const topClass = $derived( + isFullscreen + ? `border-border bg-chrome absolute inset-x-0 top-0 z-30 flex items-center justify-between gap-2 border-b px-3 py-2 transition-transform duration-200 ${showTop ? 'translate-y-0' : '-translate-y-full'}` + : 'border-border bg-chrome flex shrink-0 items-center justify-between gap-2 border-b px-3 py-2' + ); + const bottomClass = $derived( + isFullscreen + ? `border-border bg-chrome absolute inset-x-0 bottom-0 z-30 flex gap-2 overflow-x-auto border-t p-2 transition-transform duration-200 ${showBottom ? 'translate-y-0' : 'translate-y-full'}` + : 'border-border bg-chrome flex shrink-0 gap-2 overflow-x-auto border-t p-2' + ); + const arrowPos = + 'absolute top-1/2 z-20 inline-flex -translate-y-1/2 items-center justify-center rounded-full bg-black/40 p-1.5 text-text backdrop-blur transition-opacity duration-200 hover:text-white'; + const arrowVisibility = (shown: boolean, disabled: boolean, isZoomed: boolean) => + isZoomed || !shown ? 'pointer-events-none opacity-0' : disabled ? 'opacity-40' : 'opacity-100'; + const leftDisabled = $derived(index === 0 || zoomed); + const rightDisabled = $derived(index === photos.length - 1 || zoomed); + const leftBtnClass = $derived( + `${arrowPos} left-2 ${arrowVisibility(showLeft, leftDisabled, zoomed)}` + ); + const rightBtnClass = $derived( + `${arrowPos} right-2 ${arrowVisibility(showRight, rightDisabled, zoomed)}` + ); + + function resetBars() { + clearTimeout(hideTopTimer); + clearTimeout(hideBottomTimer); + clearTimeout(hideLeftTimer); + clearTimeout(hideRightTimer); + hideTopTimer = undefined; + hideBottomTimer = undefined; + hideLeftTimer = undefined; + hideRightTimer = undefined; + showTop = false; + showBottom = false; + showLeft = false; + showRight = false; + } + + function inRect(el: HTMLElement | undefined, x: number, y: number): boolean { + if (!el) return false; + const r = el.getBoundingClientRect(); + return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom; + } + + function scheduleTop(shouldShow: boolean) { + if (shouldShow) { + clearTimeout(hideTopTimer); + hideTopTimer = undefined; + showTop = true; + } else if (showTop && !hideTopTimer) { + hideTopTimer = setTimeout(() => { + showTop = false; + hideTopTimer = undefined; + }, 1000); + } + } + + function scheduleBottom(shouldShow: boolean) { + if (shouldShow) { + clearTimeout(hideBottomTimer); + hideBottomTimer = undefined; + showBottom = true; + } else if (showBottom && !hideBottomTimer) { + hideBottomTimer = setTimeout(() => { + showBottom = false; + hideBottomTimer = undefined; + }, 1000); + } + } + + function scheduleLeft(shouldShow: boolean) { + if (shouldShow) { + clearTimeout(hideLeftTimer); + hideLeftTimer = undefined; + showLeft = true; + } else if (showLeft && !hideLeftTimer) { + hideLeftTimer = setTimeout(() => { + showLeft = false; + hideLeftTimer = undefined; + }, 300); + } + } + + function scheduleRight(shouldShow: boolean) { + if (shouldShow) { + clearTimeout(hideRightTimer); + hideRightTimer = undefined; + showRight = true; + } else if (showRight && !hideRightTimer) { + hideRightTimer = setTimeout(() => { + showRight = false; + hideRightTimer = undefined; + }, 300); + } + } + + function onWinMouseMove(e: MouseEvent) { + const r = viewerEl?.getBoundingClientRect(); + if (r) { + const zoneX = r.width / 6; + scheduleLeft(!zoomed && e.clientX <= r.left + zoneX); + scheduleRight(!zoomed && e.clientX >= r.right - zoneX); + } + if (!isFullscreen) return; + const nearTop = e.clientY <= EDGE; + const nearBottom = e.clientY >= window.innerHeight - EDGE; + scheduleTop(nearTop || inRect(topEl, e.clientX, e.clientY)); + scheduleBottom(nearBottom || inRect(bottomEl, e.clientX, e.clientY)); + } + function baseWidth(): number { - return fresh?.width || current?.width || imgEl?.naturalWidth || 0; + return imgEl?.naturalWidth || fresh?.width || current?.width || 0; + } + function baseHeight(): number { + return imgEl?.naturalHeight || fresh?.height || current?.height || 0; } function prev() { @@ -85,42 +219,52 @@ } function toggleActual() { - if (zoomed && scale === 1) { + const atHundred = zoomed && Math.abs(scale - 1) < 0.01; + if (atHundred) { zoomed = false; scale = 1; + if (scroller) { + scroller.scrollLeft = 0; + scroller.scrollTop = 0; + } } else { zoomed = true; scale = 1; + tick().then(() => { + if (!scroller) return; + scroller.scrollLeft = (scroller.scrollWidth - scroller.clientWidth) / 2; + scroller.scrollTop = (scroller.scrollHeight - scroller.clientHeight) / 2; + }); } } function onWheel(ev: WheelEvent) { - if (!current || !scroller) return; + if (!current || !scroller || !imgEl) return; ev.preventDefault(); - const factor = ev.deltaY < 0 ? 1.15 : 1 / 1.15; - const newScale = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, scale * factor)); - if (newScale === scale) return; + ev.stopPropagation(); - const rect = scroller.getBoundingClientRect(); - const ox = ev.clientX - rect.left; - const oy = ev.clientY - rect.top; - const wasFit = !zoomed; - const oldScale = scale; + const natW = baseWidth(); + const natH = baseHeight(); + if (!natW || !natH) return; + + const factor = ev.deltaY < 0 ? 1.15 : 1 / 1.15; + const fitScale = Math.min(scroller.clientWidth / natW, scroller.clientHeight / natH, 1); + const curScale = zoomed ? scale : fitScale; + const newScale = Math.min(MAX_ZOOM, Math.max(fitScale, curScale * factor)); + if (Math.abs(newScale - curScale) < 1e-4) return; + + const r = imgEl.getBoundingClientRect(); + const fx = (ev.clientX - r.left) / r.width; + const fy = (ev.clientY - r.top) / r.height; zoomed = true; scale = newScale; tick().then(() => { if (!scroller) return; - if (wasFit) { - scroller.scrollLeft = scroller.scrollWidth / 2 - ox; - scroller.scrollTop = scroller.scrollHeight / 2 - oy; - } else { - const nx = (scroller.scrollLeft + ox) / oldScale; - const ny = (scroller.scrollTop + oy) / oldScale; - scroller.scrollLeft = nx * newScale - ox; - scroller.scrollTop = ny * newScale - oy; - } + const sr = scroller.getBoundingClientRect(); + scroller.scrollLeft = fx * natW * newScale - (ev.clientX - sr.left); + scroller.scrollTop = fy * natH * newScale - (ev.clientY - sr.top); }); } @@ -207,10 +351,10 @@ 'border-border text-danger hover:bg-bg-muted inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm'; - + -
-
+
+
-
+
{/if} @@ -302,20 +450,17 @@
-
+
{#each photos as photo, i (photo.id)}