2025-01-09 23:44:08 +01:00
|
|
|
<script lang="ts">
|
2025-03-28 14:16:20 +01:00
|
|
|
import { swipe_nav } from "lib/SwipeNavigate";
|
2026-07-30 17:18:46 +02:00
|
|
|
import { fs_path_url } from "lib/FilesystemAPI.svelte";
|
2025-03-28 14:16:20 +01:00
|
|
|
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)
|
2024-04-11 18:40:26 +02:00
|
|
|
|
2024-08-30 17:19:25 +02:00
|
|
|
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)
|
2024-08-30 17:19:25 +02:00
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
}
|
2024-04-11 20:32:55 +02:00
|
|
|
}
|
|
|
|
|
|
2025-10-14 00:03:48 +02:00
|
|
|
const on_load = () => loading_finish()
|
2020-11-11 23:45:52 +01:00
|
|
|
|
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()
|
|
|
|
|
})
|
2020-11-11 23:45:52 +01:00
|
|
|
|
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
|
2020-11-11 23:45:52 +01:00
|
|
|
}
|
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)
|
2020-11-11 23:45:52 +01:00
|
|
|
}
|
|
|
|
|
|
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)
|
2020-11-11 23:45:52 +01:00
|
|
|
|
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()
|
2020-11-11 23:45:52 +01:00
|
|
|
}
|
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)
|
2020-11-11 23:45:52 +01:00
|
|
|
}
|
|
|
|
|
|
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)
|
2020-11-11 23:45:52 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
|
2026-07-30 21:33:26 +02:00
|
|
|
<svelte:window onresize={measure_area} />
|
2020-11-11 23:45:52 +01:00
|
|
|
|
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 -->
|
2024-04-11 18:40:26 +02:00
|
|
|
<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}
|
2025-03-28 14:16:20 +01:00
|
|
|
use:swipe_nav={{
|
2026-07-30 21:33:26 +02:00
|
|
|
enabled: !zoomed,
|
2025-03-28 14:16:20 +01:00
|
|
|
prev: swipe_prev,
|
|
|
|
|
next: swipe_next,
|
|
|
|
|
on_prev: () => nav.open_sibling(-1),
|
|
|
|
|
on_next: () => nav.open_sibling(1),
|
|
|
|
|
}}
|
2024-04-11 18:40:26 +02:00
|
|
|
>
|
2020-11-11 23:45:52 +01:00
|
|
|
<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}
|
2024-04-11 20:32:55 +02:00
|
|
|
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});
|
|
|
|
|
"
|
2024-08-09 13:02:07 +02:00
|
|
|
src={fs_path_url($nav.base.path)}
|
2024-08-30 15:16:01 +02:00
|
|
|
alt="no description available"
|
2026-07-30 21:33:26 +02:00
|
|
|
draggable="false"
|
2024-08-30 15:16:01 +02:00
|
|
|
/>
|
2020-11-11 23:45:52 +01:00
|
|
|
</div>
|
2026-07-30 21:33:26 +02:00
|
|
|
</div>
|
2020-11-11 23:45:52 +01:00
|
|
|
|
|
|
|
|
<style>
|
2026-07-30 21:33:26 +02:00
|
|
|
.viewport {
|
2020-11-11 23:45:52 +01:00
|
|
|
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;
|
2020-11-11 23:45:52 +01:00
|
|
|
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;
|
2020-11-11 23:45:52 +01:00
|
|
|
}
|
2026-07-30 21:33:26 +02:00
|
|
|
.container.zoomed {
|
|
|
|
|
cursor: move;
|
2020-11-11 23:45:52 +01:00
|
|
|
}
|
|
|
|
|
.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;
|
2020-11-11 23:45:52 +01:00
|
|
|
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;
|
2020-11-11 23:45:52 +01:00
|
|
|
}
|
2026-07-30 21:33:26 +02:00
|
|
|
.image.animate {
|
|
|
|
|
transition: transform 200ms ease-out;
|
2020-11-11 23:45:52 +01:00
|
|
|
}
|
|
|
|
|
</style>
|