Redesign image viewer with Claude

This commit is contained in:
2026-07-30 21:33:26 +02:00
parent 39e0019ce3
commit 609dfc906a
3 changed files with 302 additions and 55 deletions

View File

@@ -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<typeof setTimeout>
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<number, { x: number, y: number }>()
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)
}
}
</script>
<svelte:window onmousemove={mousemove} onmouseup={mouseup} />
<svelte:window onresize={measure_area} />
<!-- This element is only here to measure the space between the breadcrumbs bar
and the toolbar. It has no contents of its own, the container is taken out of
the flow -->
<div class="viewport" bind:this={viewport}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={container}
class="container"
class:zoom
class:zoomed
onwheel={wheel}
onpointerdown={pointerdown}
onpointermove={pointermove}
onpointerup={pointerup}
onpointercancel={pointerup}
ondblclick={dblclick}
use:swipe_nav={{
enabled: !zoom,
enabled: !zoomed,
prev: swipe_prev,
next: swipe_next,
on_prev: () => nav.open_sibling(-1),
on_next: () => nav.open_sibling(1),
}}
>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<img
ondblclick={() => {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"
/>
</div>
</div>
<style>
.container {
display: flex;
justify-content: center;
.viewport {
height: 100%;
width: 100%;
}
.container {
/* Fixed positioning puts the container on top of the whole screen, and
* escapes the overflow of the preview area, so that a zoomed in image can
* slide underneath the breadcrumbs bar and the toolbar. Both of those are
* painted over it: the breadcrumbs bar has a z-index, and the toolbar comes
* after the preview area in the document */
position: fixed;
inset: 0;
overflow: hidden;
/* Disables the browser's own scroll and zoom gestures, we handle those
* ourselves */
touch-action: none;
}
.container.zoom {
overflow: auto;
justify-content: unset;
}
.image {
position: relative;
display: block;
margin: auto;
max-width: 100%;
max-height: 100%;
cursor: pointer;
box-shadow: 1px 1px 4px 0px var(--shadow_color);
}
.image.zoom {
max-width: none;
max-height: none;
.container.zoomed {
cursor: move;
}
.image {
/* The image is positioned absolutely so that its size does not affect the
* height of the viewport element. If it did then a tall image would stretch
* the page beyond the bottom of the screen, because a percentage max-height
* cannot be resolved against a parent which is sized by its contents.
*
* The insets and the maximum size are set from the measured area, in
* pixels. They can't be percentages, because those would resolve against
* the container, which covers the entire screen. Padding on the container
* would not work either, absolutely positioned elements ignore it. The
* automatic margins center the image within the insets */
position: absolute;
display: block;
margin: auto;
box-shadow: 1px 1px 4px 0px var(--shadow_color);
transform-origin: center center;
will-change: transform;
user-select: none;
-webkit-user-select: none;
-webkit-user-drag: none;
}
.image.animate {
transition: transform 200ms ease-out;
}
</style>

View File

@@ -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
}

View File

@@ -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;