Redesign image viewer with Claude
This commit is contained in:
@@ -8,15 +8,46 @@ let { nav }: {
|
|||||||
nav: FSNavigator;
|
nav: FSNavigator;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
let viewport: HTMLDivElement = $state()
|
||||||
let container: HTMLDivElement = $state()
|
let container: HTMLDivElement = $state()
|
||||||
let zoom = $state(false)
|
let image: HTMLImageElement = $state()
|
||||||
let x = 0, y = 0
|
|
||||||
let dragging = false
|
// 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_prev = $state(true)
|
||||||
let swipe_next = $state(true)
|
let swipe_next = $state(true)
|
||||||
|
|
||||||
export const update = async () => {
|
export const update = async () => {
|
||||||
loading_start()
|
loading_start()
|
||||||
|
reset_zoom(false)
|
||||||
|
|
||||||
// Figure out if there are previous or next files. If not then we disable
|
// Figure out if there are previous or next files. If not then we disable
|
||||||
// swiping controls in that direction
|
// swiping controls in that direction
|
||||||
@@ -32,92 +63,294 @@ export const update = async () => {
|
|||||||
|
|
||||||
const on_load = () => loading_finish()
|
const on_load = () => loading_finish()
|
||||||
|
|
||||||
const mousedown = (e: MouseEvent) => {
|
const clamp = (val: number, min: number, max: number) => Math.min(Math.max(val, min), max)
|
||||||
if (!dragging && e.which === 1 && zoom) {
|
|
||||||
x = e.pageX
|
|
||||||
y = e.pageY
|
|
||||||
dragging = true
|
|
||||||
|
|
||||||
e.preventDefault()
|
// Measures the area the image has to fit in. The container itself covers the
|
||||||
e.stopPropagation()
|
// whole screen, so its size can't be used for this
|
||||||
return false
|
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
|
const pointermove = (e: PointerEvent) => {
|
||||||
y = e.pageY
|
const previous = pointers.get(e.pointerId)
|
||||||
|
if (previous === undefined) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
e.preventDefault()
|
const current = { x: e.clientX, y: e.clientY }
|
||||||
e.stopPropagation()
|
pointers.set(e.pointerId, current)
|
||||||
return false
|
|
||||||
|
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()
|
const dblclick = (e: MouseEvent) => {
|
||||||
e.stopPropagation()
|
start_animation()
|
||||||
return false
|
|
||||||
|
if (zoomed) {
|
||||||
|
reset_zoom(false)
|
||||||
|
} else {
|
||||||
|
zoom_at(e.clientX, e.clientY, 3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</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
|
<div
|
||||||
bind:this={container}
|
bind:this={container}
|
||||||
class="container"
|
class="container"
|
||||||
class:zoom
|
class:zoomed
|
||||||
|
onwheel={wheel}
|
||||||
|
onpointerdown={pointerdown}
|
||||||
|
onpointermove={pointermove}
|
||||||
|
onpointerup={pointerup}
|
||||||
|
onpointercancel={pointerup}
|
||||||
|
ondblclick={dblclick}
|
||||||
use:swipe_nav={{
|
use:swipe_nav={{
|
||||||
enabled: !zoom,
|
enabled: !zoomed,
|
||||||
prev: swipe_prev,
|
prev: swipe_prev,
|
||||||
next: swipe_next,
|
next: swipe_next,
|
||||||
on_prev: () => nav.open_sibling(-1),
|
on_prev: () => nav.open_sibling(-1),
|
||||||
on_next: () => nav.open_sibling(1),
|
on_next: () => nav.open_sibling(1),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
||||||
<img
|
<img
|
||||||
ondblclick={() => {zoom = !zoom}}
|
bind:this={image}
|
||||||
onmousedown={mousedown}
|
|
||||||
onload={on_load}
|
onload={on_load}
|
||||||
onerror={on_load}
|
onerror={on_load}
|
||||||
class="image"
|
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)}
|
src={fs_path_url($nav.base.path)}
|
||||||
alt="no description available"
|
alt="no description available"
|
||||||
|
draggable="false"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.container {
|
.viewport {
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 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;
|
overflow: hidden;
|
||||||
|
/* Disables the browser's own scroll and zoom gestures, we handle those
|
||||||
|
* ourselves */
|
||||||
|
touch-action: none;
|
||||||
}
|
}
|
||||||
.container.zoom {
|
.container.zoomed {
|
||||||
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;
|
|
||||||
cursor: move;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -16,17 +16,31 @@ export const swipe_nav = (
|
|||||||
let start_x = 0
|
let start_x = 0
|
||||||
let start_y = 0
|
let start_y = 0
|
||||||
let render_offset = 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 enabled = props.enabled === undefined ? true : props.enabled
|
||||||
let prev = props.prev === undefined ? true : props.prev
|
let prev = props.prev === undefined ? true : props.prev
|
||||||
let next = props.next === undefined ? true : props.next
|
let next = props.next === undefined ? true : props.next
|
||||||
|
|
||||||
const touchstart = (e: TouchEvent) => {
|
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_x = e.touches[0].clientX
|
||||||
start_y = e.touches[0].clientY
|
start_y = e.touches[0].clientY
|
||||||
render_offset = 0
|
render_offset = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
const touchmove = (e: TouchEvent) => {
|
const touchmove = (e: TouchEvent) => {
|
||||||
|
if (multi_touch) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const offset_x = e.touches[0].clientX - start_x
|
const offset_x = e.touches[0].clientX - start_x
|
||||||
if (!enabled || (offset_x < 0 && !next) || (offset_x > 0 && !prev)) {
|
if (!enabled || (offset_x < 0 && !next) || (offset_x > 0 && !prev)) {
|
||||||
return
|
return
|
||||||
@@ -45,8 +59,8 @@ export const swipe_nav = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const touchend = (e: TouchEvent) => {
|
const touchend = (_: TouchEvent) => {
|
||||||
if (!enabled) {
|
if (!enabled || multi_touch) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -286,7 +286,6 @@ const set_offset = (off: number) => {
|
|||||||
}
|
}
|
||||||
.nav_container {
|
.nav_container {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
backdrop-filter: blur(3px);
|
|
||||||
z-index: 9;
|
z-index: 9;
|
||||||
}
|
}
|
||||||
.scroll_container {
|
.scroll_container {
|
||||||
@@ -305,6 +304,7 @@ const set_offset = (off: number) => {
|
|||||||
width: 15em;
|
width: 15em;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
|
backdrop-filter: blur(3px);
|
||||||
}
|
}
|
||||||
.button {
|
.button {
|
||||||
background: none;
|
background: none;
|
||||||
|
|||||||
Reference in New Issue
Block a user