Redesign image viewer with Claude
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user