Files
fnx_web/svelte/src/filesystem/viewers/Image.svelte

357 lines
10 KiB
Svelte
Raw Normal View History

<script lang="ts">
import { swipe_nav } from "lib/SwipeNavigate";
import { fs_path_url } from "lib/FilesystemAPI.svelte";
import type { FSNavigator } from "filesystem/FSNavigator";
2025-10-14 00:03:48 +02:00
import { loading_finish, loading_start } from "lib/Loading";
2020-11-17 23:39:27 +01:00
2025-10-13 16:05:50 +02:00
let { nav }: {
nav: FSNavigator;
} = $props();
2026-07-30 21:33:26 +02:00
let viewport: HTMLDivElement = $state()
2025-10-13 16:05:50 +02:00
let container: HTMLDivElement = $state()
2026-07-30 21:33:26 +02:00
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
2025-10-13 16:05:50 +02:00
let swipe_prev = $state(true)
let swipe_next = $state(true)
export const update = async () => {
2025-10-14 00:03:48 +02:00
loading_start()
2026-07-30 21:33:26 +02:00
reset_zoom(false)
// Figure out if there are previous or next files. If not then we disable
// swiping controls in that direction
const siblings = await nav.get_siblings()
for (let i = 0; i < siblings.length; i++) {
if (siblings[i].name === nav.base.name) {
swipe_prev = i > 0
swipe_next = i < siblings.length-1
break
}
}
}
2025-10-14 00:03:48 +02:00
const on_load = () => loading_finish()
2026-07-30 21:33:26 +02:00
const clamp = (val: number, min: number, max: number) => Math.min(Math.max(val, min), max)
// 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()
})
2026-07-30 21:33:26 +02:00
// 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
}
2026-07-30 21:33:26 +02:00
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)
}
2026-07-30 21:33:26 +02:00
// 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)
2026-07-30 21:33:26 +02:00
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()
}
2026-07-30 21:33:26 +02:00
scale = 1
translate_x = 0
translate_y = 0
}
const start_animation = () => {
animate = true
clearTimeout(animate_timeout)
animate_timeout = setTimeout(() => animate = false, 200)
}
2026-07-30 21:33:26 +02:00
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 pointermove = (e: PointerEvent) => {
const previous = pointers.get(e.pointerId)
if (previous === undefined) {
return
}
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 dblclick = (e: MouseEvent) => {
start_animation()
if (zoomed) {
reset_zoom(false)
} else {
zoom_at(e.clientX, e.clientY, 3)
}
}
</script>
2026-07-30 21:33:26 +02:00
<svelte:window onresize={measure_area} />
2026-07-30 21:33:26 +02:00
<!-- 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"
2026-07-30 21:33:26 +02:00
class:zoomed
onwheel={wheel}
onpointerdown={pointerdown}
onpointermove={pointermove}
onpointerup={pointerup}
onpointercancel={pointerup}
ondblclick={dblclick}
use:swipe_nav={{
2026-07-30 21:33:26 +02:00
enabled: !zoomed,
prev: swipe_prev,
next: swipe_next,
on_prev: () => nav.open_sibling(-1),
on_next: () => nav.open_sibling(1),
}}
>
<img
2026-07-30 21:33:26 +02:00
bind:this={image}
2025-10-13 16:05:50 +02:00
onload={on_load}
onerror={on_load}
class="image"
2026-07-30 21:33:26 +02:00
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"
2026-07-30 21:33:26 +02:00
draggable="false"
/>
</div>
2026-07-30 21:33:26 +02:00
</div>
<style>
2026-07-30 21:33:26 +02:00
.viewport {
height: 100%;
width: 100%;
2026-07-30 21:33:26 +02:00
}
.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;
2026-07-30 21:33:26 +02:00
/* Disables the browser's own scroll and zoom gestures, we handle those
* ourselves */
touch-action: none;
}
2026-07-30 21:33:26 +02:00
.container.zoomed {
cursor: move;
}
.image {
2026-07-30 21:33:26 +02:00
/* 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;
2024-02-16 14:50:34 +01:00
box-shadow: 1px 1px 4px 0px var(--shadow_color);
2026-07-30 21:33:26 +02:00
transform-origin: center center;
will-change: transform;
user-select: none;
-webkit-user-select: none;
-webkit-user-drag: none;
}
2026-07-30 21:33:26 +02:00
.image.animate {
transition: transform 200ms ease-out;
}
</style>