feat(web): native favorites, lightbox zoom, EXIF jump links + perf fix
- Favorites use PhotoPrism's own like/unlike endpoint (not a mule-only mark) so they sync to third-party gallery apps, with heart controls in the tile, sidebar, and an `f` shortcut. - Lightbox: wheel-zoom around cursor, double-click to 2.5x, drag-to-pan, auto-upgrades to the fit_2048 tile past 1.25x zoom. - Sidebar: copy-EXIF button, clickable Camera/Lens values that jump to a filtered timeline (camera:/lens: DSL), matching the existing Country link. - Fix filtersToQ() quoting the entire search string whenever it contained a colon, which silently turned any raw DSL operator (camera:, taken:2024, etc.) into a literal phrase search — discovered while verifying the new jump-links against production. - Disable TanStack Query's refetchOnWindowFocus: the indexer WebSocket already invalidates photo queries on real changes, so the focus refetch was just a redundant full-timeline re-render on tab-switch. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -101,6 +101,90 @@
|
||||
setFocused(next);
|
||||
setAnchor(next);
|
||||
}
|
||||
|
||||
// ── Zoom & pan ───────────────────────────────────────────────────────
|
||||
// Wheel zooms around the cursor, double-click toggles 1↔2.5, drag pans
|
||||
// while zoomed. Transform lives on a wrapper so the LQIP layer and the
|
||||
// sharp image scale together. Resets on photo change. Past 1.25× the
|
||||
// sharp <img> switches to fit_2048 so zoomed pixels stay crisp.
|
||||
const MAX_ZOOM = 6;
|
||||
let zoom = $state(1);
|
||||
let tx = $state(0);
|
||||
let ty = $state(0);
|
||||
let zoomHost = $state<HTMLElement | undefined>();
|
||||
let panning = $state(false);
|
||||
let lastX = 0;
|
||||
let lastY = 0;
|
||||
|
||||
$effect(() => {
|
||||
void uid;
|
||||
zoom = 1;
|
||||
tx = 0;
|
||||
ty = 0;
|
||||
});
|
||||
|
||||
function applyZoom(next: number, clientX: number, clientY: number) {
|
||||
if (!zoomHost) return;
|
||||
const clamped = Math.min(MAX_ZOOM, Math.max(1, next));
|
||||
if (clamped === zoom) return;
|
||||
// Keep the point under the cursor fixed: translate offsets are in
|
||||
// post-scale pixels around the container centre.
|
||||
const rect = zoomHost.getBoundingClientRect();
|
||||
const cx = clientX - rect.left - rect.width / 2;
|
||||
const cy = clientY - rect.top - rect.height / 2;
|
||||
const s = clamped / zoom;
|
||||
tx = cx + (tx - cx) * s;
|
||||
ty = cy + (ty - cy) * s;
|
||||
zoom = clamped;
|
||||
if (zoom === 1) {
|
||||
tx = 0;
|
||||
ty = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function onWheel(e: WheelEvent) {
|
||||
e.preventDefault();
|
||||
applyZoom(zoom * Math.exp(-e.deltaY * 0.0018), e.clientX, e.clientY);
|
||||
}
|
||||
|
||||
/** Svelte marks wheel handlers passive; zooming needs preventDefault,
|
||||
* so the listener is attached manually as non-passive. */
|
||||
function wheelZoom(node: HTMLElement) {
|
||||
node.addEventListener('wheel', onWheel, { passive: false });
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener('wheel', onWheel);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function onDblClickZoom(e: MouseEvent) {
|
||||
if (zoom > 1) {
|
||||
zoom = 1;
|
||||
tx = 0;
|
||||
ty = 0;
|
||||
} else {
|
||||
applyZoom(2.5, e.clientX, e.clientY);
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (zoom === 1) return;
|
||||
panning = true;
|
||||
lastX = e.clientX;
|
||||
lastY = e.clientY;
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!panning) return;
|
||||
tx += e.clientX - lastX;
|
||||
ty += e.clientY - lastY;
|
||||
lastX = e.clientX;
|
||||
lastY = e.clientY;
|
||||
}
|
||||
function onPointerUp() {
|
||||
panning = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative flex h-full w-full items-center justify-center bg-black/40 p-4">
|
||||
@@ -145,30 +229,61 @@
|
||||
photoQuery.data.OriginalName ??
|
||||
pf.Name ??
|
||||
(isVideo(photoQuery.data) ? 'Video' : 'Photo')}
|
||||
{#if pf.Width && pf.Height}
|
||||
<!-- LQIP layer: the same URL the grid loaded, blurred to mask
|
||||
the tile_*'s square center-crop against the sharp image's
|
||||
true aspect. Sized via aspect-ratio + max-* + m-auto so it
|
||||
lands in the exact same bounding box as the sharp <img>
|
||||
beside it (object-contain semantics, but expressible on a
|
||||
positioned element). Paints from the HTTP cache the moment
|
||||
the modal opens. -->
|
||||
<img
|
||||
src={thumbSrc(pf.Hash, view.thumbnailSize)}
|
||||
srcset={thumbSrcSet(pf.Hash, view.thumbnailSize)}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-0 m-auto max-h-full max-w-full rounded-md object-cover blur-2xl"
|
||||
style="aspect-ratio: {pf.Width} / {pf.Height};"
|
||||
/>
|
||||
{/if}
|
||||
<img
|
||||
src={thumbUrl(pf.Hash, 'fit_1280')}
|
||||
alt={altText}
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
class="relative max-h-full max-w-full rounded-md object-contain shadow-2xl"
|
||||
/>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
bind:this={zoomHost}
|
||||
use:wheelZoom
|
||||
ondblclick={onDblClickZoom}
|
||||
onpointerdown={onPointerDown}
|
||||
onpointermove={onPointerMove}
|
||||
onpointerup={onPointerUp}
|
||||
onpointercancel={onPointerUp}
|
||||
class="relative flex h-full w-full items-center justify-center overflow-hidden {zoom > 1
|
||||
? panning
|
||||
? 'cursor-grabbing'
|
||||
: 'cursor-grab'
|
||||
: 'cursor-zoom-in'}"
|
||||
>
|
||||
<div
|
||||
class="relative flex h-full w-full items-center justify-center"
|
||||
class:transition-transform={!panning}
|
||||
class:duration-150={!panning}
|
||||
style="transform: translate({tx}px, {ty}px) scale({zoom});"
|
||||
>
|
||||
{#if pf.Width && pf.Height}
|
||||
<!-- LQIP layer: the same URL the grid loaded, blurred to mask
|
||||
the tile_*'s square center-crop against the sharp image's
|
||||
true aspect. Sized via aspect-ratio + max-* + m-auto so it
|
||||
lands in the exact same bounding box as the sharp <img>
|
||||
beside it (object-contain semantics, but expressible on a
|
||||
positioned element). Paints from the HTTP cache the moment
|
||||
the modal opens. -->
|
||||
<img
|
||||
src={thumbSrc(pf.Hash, view.thumbnailSize)}
|
||||
srcset={thumbSrcSet(pf.Hash, view.thumbnailSize)}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-0 m-auto max-h-full max-w-full rounded-md object-cover blur-2xl"
|
||||
style="aspect-ratio: {pf.Width} / {pf.Height};"
|
||||
/>
|
||||
{/if}
|
||||
<img
|
||||
src={thumbUrl(pf.Hash, zoom > 1.25 ? 'fit_2048' : 'fit_1280')}
|
||||
alt={altText}
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
draggable="false"
|
||||
class="relative max-h-full max-w-full select-none rounded-md object-contain shadow-2xl"
|
||||
/>
|
||||
</div>
|
||||
{#if zoom > 1}
|
||||
<span
|
||||
class="absolute bottom-2 left-1/2 -translate-x-1/2 rounded bg-background/80 px-2 py-0.5 text-[11px] text-foreground"
|
||||
>
|
||||
{Math.round(zoom * 100)}% · double-click to reset
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user