From 609dfc906ad6c68918dfa435fcef183a4b1ff0be Mon Sep 17 00:00:00 2001 From: Wim Brand Date: Thu, 30 Jul 2026 21:33:26 +0200 Subject: [PATCH] Redesign image viewer with Claude --- svelte/src/filesystem/viewers/Image.svelte | 337 +++++++++++++++++---- svelte/src/lib/SwipeNavigate.ts | 18 +- svelte/src/wrap/MainMenu.svelte | 2 +- 3 files changed, 302 insertions(+), 55 deletions(-) diff --git a/svelte/src/filesystem/viewers/Image.svelte b/svelte/src/filesystem/viewers/Image.svelte index f1b1a83..736c37f 100644 --- a/svelte/src/filesystem/viewers/Image.svelte +++ b/svelte/src/filesystem/viewers/Image.svelte @@ -8,15 +8,46 @@ let { nav }: { nav: FSNavigator; } = $props(); +let viewport: HTMLDivElement = $state() let container: HTMLDivElement = $state() -let zoom = $state(false) -let x = 0, y = 0 -let dragging = false +let image: HTMLImageElement = $state() + +// Position and size of the area between the breadcrumbs bar and the toolbar, +// in viewport coordinates. The image is fitted and centered inside of this +// area, while the container it lives in covers the whole screen. That way a +// zoomed in image can be panned underneath the two bars instead of being cut +// off by them. right and bottom are the distances to the screen edges, because +// that's what the CSS needs +let area = $state({ top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0 }) + +// Zoom level of the image. 1 means the image is scaled to fit the screen. +// translate_x and translate_y are the pan offset in screen pixels, relative to +// the centered position +let scale = $state(1) +let translate_x = $state(0) +let translate_y = $state(0) +// Enables a CSS transition. Only used for animated zoom steps (double click and +// reset), continuous gestures need to follow the cursor exactly +let animate = $state(false) +let animate_timeout: ReturnType + +let zoomed = $derived(scale > 1.001) + +// Currently active mouse / touch / pen contacts, by pointer id. This is +// deliberately not reactive. The swipe navigation resets its gesture whenever +// its properties change, so updating a state variable on every pointer event +// would cancel every swipe before it can trigger +let pointers = new Map() +let pinch_distance = 0 +let pinch_x = 0 +let pinch_y = 0 + let swipe_prev = $state(true) let swipe_next = $state(true) export const update = async () => { loading_start() + reset_zoom(false) // Figure out if there are previous or next files. If not then we disable // swiping controls in that direction @@ -32,92 +63,294 @@ export const update = async () => { const on_load = () => loading_finish() -const mousedown = (e: MouseEvent) => { - if (!dragging && e.which === 1 && zoom) { - x = e.pageX - y = e.pageY - dragging = true +const clamp = (val: number, min: number, max: number) => Math.min(Math.max(val, min), max) - e.preventDefault() - e.stopPropagation() - return false +// Measures the area the image has to fit in. The container itself covers the +// whole screen, so its size can't be used for this +const measure_area = () => { + if (!viewport) { + return + } + + const rect = viewport.getBoundingClientRect() + area = { + top: rect.top, + left: rect.left, + right: Math.max(0, document.documentElement.clientWidth - rect.right), + bottom: Math.max(0, document.documentElement.clientHeight - rect.bottom), + width: rect.width, + height: rect.height, + } + + clamp_pan() +} + +// The area changes when the window is resized, but also when the toolbar is +// collapsed or the navigation menu is opened +$effect(() => { + const observer = new ResizeObserver(measure_area) + observer.observe(viewport) + return () => observer.disconnect() +}) + +// The maximum zoom level. Zooming in further than the image's native +// resolution is pointless, but we always allow at least 8x so small images can +// be inspected too +const max_scale = () => { + if (!image || image.offsetWidth === 0) { + return 8 + } + return clamp(image.naturalWidth / image.offsetWidth, 8, 40) +} + +// Limits how far the image can be panned. Every edge of the image can be +// brought to the matching edge of the area, and no further, so that no part of +// the image can be pushed into a place where it can't be seen. When the scaled +// image is smaller than the area in an axis it stays centered on that axis. +// +// The limits are relative to the area and not to the container, even though +// the container is what clips the image. The container covers the whole +// screen, but the parts of it behind the toolbars and the navigation menu are +// covered up, so an edge parked there would be invisible +const clamp_pan = () => { + if (!image) { + return + } + + const max_x = Math.max(0, (image.offsetWidth * scale - area.width) / 2) + const max_y = Math.max(0, (image.offsetHeight * scale - area.height) / 2) + + translate_x = clamp(translate_x, -max_x, max_x) + translate_y = clamp(translate_y, -max_y, max_y) +} + +// Zooms to new_scale while keeping the image pixel which is under the given +// screen coordinate in place +const zoom_at = (client_x: number, client_y: number, new_scale: number) => { + if (!container) { + return + } + + const clamped = clamp(new_scale, 1, max_scale()) + const factor = clamped / scale + + // Cursor position relative to the center of the fitted image, which is also + // its transform origin + const off_x = client_x - (area.left + area.width / 2) + const off_y = client_y - (area.top + area.height / 2) + + translate_x = off_x * (1 - factor) + translate_x * factor + translate_y = off_y * (1 - factor) + translate_y * factor + scale = clamped + + clamp_pan() +} + +const reset_zoom = (smooth: boolean) => { + if (smooth) { + start_animation() + } + scale = 1 + translate_x = 0 + translate_y = 0 +} + +const start_animation = () => { + animate = true + clearTimeout(animate_timeout) + animate_timeout = setTimeout(() => animate = false, 200) +} + +const wheel = (e: WheelEvent) => { + e.preventDefault() + + let delta = e.deltaY + if (e.deltaMode === 1) { + delta *= 16 // Delta is in lines instead of pixels + } else if (e.deltaMode === 2) { + delta *= container.clientHeight // Delta is in pages + } + + // Trackpad pinch gestures are reported as a wheel event with the ctrl key + // held down. Those deltas are much smaller, so they need more amplification + const speed = e.ctrlKey ? 0.01 : 0.001 + + zoom_at(e.clientX, e.clientY, scale * Math.exp(-delta * speed)) +} + +const update_pinch = () => { + const [a, b] = [...pointers.values()] + pinch_distance = Math.hypot(a.x - b.x, a.y - b.y) + pinch_x = (a.x + b.x) / 2 + pinch_y = (a.y + b.y) / 2 +} + +const pointerdown = (e: PointerEvent) => { + if (e.pointerType === "mouse" && e.button !== 0) { + return + } + + pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }) + container.setPointerCapture(e.pointerId) + + if (pointers.size === 2) { + update_pinch() } } -const mousemove = (e: MouseEvent) => { - if (dragging) { - container.scrollLeft = container.scrollLeft - (e.pageX - x) - container.scrollTop = container.scrollTop - (e.pageY - y) - x = e.pageX - y = e.pageY +const pointermove = (e: PointerEvent) => { + const previous = pointers.get(e.pointerId) + if (previous === undefined) { + return + } - e.preventDefault() - e.stopPropagation() - return false + const current = { x: e.clientX, y: e.clientY } + pointers.set(e.pointerId, current) + + if (pointers.size === 1) { + // Panning only does something when the image is zoomed in. Otherwise we + // leave the gesture alone so the swipe navigation can pick it up + if (!zoomed) { + return + } + + translate_x += current.x - previous.x + translate_y += current.y - previous.y + clamp_pan() + } else if (pointers.size === 2) { + const [a, b] = [...pointers.values()] + const distance = Math.hypot(a.x - b.x, a.y - b.y) + const center_x = (a.x + b.x) / 2 + const center_y = (a.y + b.y) / 2 + + if (pinch_distance > 0) { + // Follow the movement of the fingers, then zoom around their center + translate_x += center_x - pinch_x + translate_y += center_y - pinch_y + zoom_at(center_x, center_y, scale * distance / pinch_distance) + } + + pinch_distance = distance + pinch_x = center_x + pinch_y = center_y + } + + e.preventDefault() +} + +const pointerup = (e: PointerEvent) => { + if (!pointers.delete(e.pointerId)) { + return + } + + if (pointers.size === 2) { + update_pinch() + } else { + pinch_distance = 0 } } -const mouseup = (e: MouseEvent) => { - if (dragging) { - dragging = false - e.preventDefault() - e.stopPropagation() - return false +const dblclick = (e: MouseEvent) => { + start_animation() + + if (zoomed) { + reset_zoom(false) + } else { + zoom_at(e.clientX, e.clientY, 3) } } - + + +
+
nav.open_sibling(-1), on_next: () => nav.open_sibling(1), }} > - {zoom = !zoom}} - onmousedown={mousedown} + bind:this={image} onload={on_load} onerror={on_load} class="image" - class:zoom + class:animate + style=" + top: {area.top}px; + right: {area.right}px; + bottom: {area.bottom}px; + left: {area.left}px; + max-width: {area.width}px; + max-height: {area.height}px; + transform: translate({translate_x}px, {translate_y}px) scale({scale}); + " src={fs_path_url($nav.base.path)} alt="no description available" + draggable="false" />
+
diff --git a/svelte/src/lib/SwipeNavigate.ts b/svelte/src/lib/SwipeNavigate.ts index 50c6714..a83c1a8 100644 --- a/svelte/src/lib/SwipeNavigate.ts +++ b/svelte/src/lib/SwipeNavigate.ts @@ -16,17 +16,31 @@ export const swipe_nav = ( let start_x = 0 let start_y = 0 let render_offset = 0 + // Set when a second finger touches the screen. Multi-touch gestures are + // zoom gestures, not swipes, so we ignore the rest of the gesture + let multi_touch = false let enabled = props.enabled === undefined ? true : props.enabled let prev = props.prev === undefined ? true : props.prev let next = props.next === undefined ? true : props.next const touchstart = (e: TouchEvent) => { + if (e.touches.length > 1) { + multi_touch = true + set_offset(0, true) + return + } + + multi_touch = false start_x = e.touches[0].clientX start_y = e.touches[0].clientY render_offset = 0 } const touchmove = (e: TouchEvent) => { + if (multi_touch) { + return + } + const offset_x = e.touches[0].clientX - start_x if (!enabled || (offset_x < 0 && !next) || (offset_x > 0 && !prev)) { return @@ -45,8 +59,8 @@ export const swipe_nav = ( } } - const touchend = (e: TouchEvent) => { - if (!enabled) { + const touchend = (_: TouchEvent) => { + if (!enabled || multi_touch) { return } diff --git a/svelte/src/wrap/MainMenu.svelte b/svelte/src/wrap/MainMenu.svelte index a0bd33f..7ea67a2 100644 --- a/svelte/src/wrap/MainMenu.svelte +++ b/svelte/src/wrap/MainMenu.svelte @@ -286,7 +286,6 @@ const set_offset = (off: number) => { } .nav_container { flex: 0 0 auto; - backdrop-filter: blur(3px); z-index: 9; } .scroll_container { @@ -305,6 +304,7 @@ const set_offset = (off: number) => { width: 15em; margin: 0; padding: 4px; + backdrop-filter: blur(3px); } .button { background: none;