feat: PhotoPrism M0 bring-up — compose stack, web client, sidecar, migrate
Replace the legacy mule-image backend with PhotoPrism plus a thin SvelteKit client and a Node sidecar for endpoints PhotoPrism doesn't expose (file rename), and add a two-phase migrator (metadata via PUT, heaps → albums) for the existing Postgres library. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
501
web/src/lib/actions/gridKeyNav.ts
Normal file
501
web/src/lib/actions/gridKeyNav.ts
Normal file
@@ -0,0 +1,501 @@
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { batchEdit } from '$lib/services/batch';
|
||||
import { patchTargets } from '$lib/services/bulk';
|
||||
import {
|
||||
addToHeap,
|
||||
likePhoto,
|
||||
removeFromHeap,
|
||||
unlikePhoto,
|
||||
type PpAlbum
|
||||
} from '$lib/services/photoprism';
|
||||
import { queryClient } from '$lib/queryClient';
|
||||
import { filters } from '$lib/stores/filters.svelte';
|
||||
import { closePreview, openPreview, preview } from '$lib/stores/preview.svelte';
|
||||
import {
|
||||
clearSelection,
|
||||
indexOf,
|
||||
selectRange,
|
||||
selection,
|
||||
setAnchor,
|
||||
setFocused,
|
||||
toggle
|
||||
} from '$lib/stores/selection.svelte';
|
||||
import { popAndRun, push as pushUndo } from '$lib/stores/undo.svelte';
|
||||
import { toggleLeftSidebar, toggleRightSidebar } from '$lib/stores/view.svelte';
|
||||
import type { PpPhoto } from '$lib/types/photoprism';
|
||||
|
||||
/**
|
||||
* Optional parameters the host passes via `use:gridKeyNav={...}`.
|
||||
*
|
||||
* - `scrollToIndex`: invoked when the action's own arrow-nav lands on a
|
||||
* tile that's currently windowed-out of the DOM. The host expands its
|
||||
* render window and scrolls the now-mounted shell into view.
|
||||
* - `onArrow`: when provided, the action delegates ALL arrow keys to the
|
||||
* host instead of computing moves itself. Required for grids with
|
||||
* interleaved non-tile rows (e.g. month headers): linear +/-cols math
|
||||
* skips wrong because the column count of header rows is 1 (full-span),
|
||||
* not the tile column count. The host owns the visual-row map and
|
||||
* handles the (row, col) translation. Mirrors mule-image's
|
||||
* `useGridKeyNav` pattern.
|
||||
*/
|
||||
export type ArrowKey = 'ArrowLeft' | 'ArrowRight' | 'ArrowUp' | 'ArrowDown';
|
||||
|
||||
export interface GridKeyNavParams {
|
||||
scrollToIndex?: (i: number) => void;
|
||||
onArrow?: (key: ArrowKey, extending: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Svelte `action` for the timeline grid. Owns:
|
||||
* - Arrow-key focus navigation (with shift-extend) inside the visible grid
|
||||
* - Click + shift/ctrl click selection mutations
|
||||
* - Window-level shortcuts mirroring mule-image's keyboard layer:
|
||||
* x archive-toggle, u restore, f favorite-toggle, s + (1–9) add to
|
||||
* heap N (bare s adds to the currently-viewed heap), b/Tab toggles
|
||||
* left sidebar, i toggles right sidebar, space/enter opens preview,
|
||||
* esc clears, ⌘Z undoes, ⌘A selects all visible.
|
||||
* Rating + color labels are mouse-driven via the metadata sidebar — no
|
||||
* keyboard shortcuts.
|
||||
*
|
||||
* Archive / restore target a synthesized "cull target list" — in priority:
|
||||
* 1. preview overlay uid (when open) — applies to the visible preview
|
||||
* photo even if the grid still shows a stale selection
|
||||
* 2. multi-selection set
|
||||
* 3. focused tile
|
||||
*/
|
||||
export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
||||
let scrollToIndex = params.scrollToIndex;
|
||||
let onArrow = params.onArrow;
|
||||
|
||||
/** Cached column count for the visible grid. Read from CSS
|
||||
* (`grid-template-columns` resolves to a space-separated list of px
|
||||
* sizes), invalidated by a `ResizeObserver` on the grid host. This
|
||||
* keeps the read DOM-cheap regardless of how many tiles are mounted —
|
||||
* critical once windowing renders only a slice of the order. */
|
||||
let cachedCols: number | null = null;
|
||||
let gridEl: HTMLElement | null = null;
|
||||
|
||||
function findGrid(): HTMLElement | null {
|
||||
// The `[role="group"][aria-label="Photos"]` or simply the first
|
||||
// element whose computed grid-template-columns has >1 track. The
|
||||
// timeline grid sits inside `node` (the action target = <main>).
|
||||
if (gridEl && node.contains(gridEl)) return gridEl;
|
||||
const candidate = node.querySelector<HTMLElement>('[data-photo-grid]');
|
||||
if (candidate) {
|
||||
gridEl = candidate;
|
||||
return candidate;
|
||||
}
|
||||
// Fallback: the first descendant that's display: grid with ≥2 cols.
|
||||
// Avoids a hard coupling on the data-attribute in case the host
|
||||
// hasn't tagged it yet.
|
||||
for (const el of node.querySelectorAll<HTMLElement>('*')) {
|
||||
const cs = getComputedStyle(el);
|
||||
if (cs.display === 'grid' && cs.gridTemplateColumns.split(' ').length > 1) {
|
||||
gridEl = el;
|
||||
return el;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function tilesPerRow(): number {
|
||||
if (cachedCols !== null) return cachedCols;
|
||||
const grid = findGrid();
|
||||
if (!grid) return 1;
|
||||
const cols = getComputedStyle(grid).gridTemplateColumns.split(' ').filter(Boolean).length;
|
||||
cachedCols = Math.max(1, cols);
|
||||
return cachedCols;
|
||||
}
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
// Container width changed → column count likely changed too.
|
||||
// Cheaper to invalidate than to recompute; tilesPerRow recomputes
|
||||
// on next access (which is per keystroke at most).
|
||||
cachedCols = null;
|
||||
});
|
||||
ro.observe(node);
|
||||
|
||||
function focusedIndex(): number {
|
||||
return indexOf(selection.focused);
|
||||
}
|
||||
|
||||
/** Move the focus cursor by `delta` tiles. When the move is NOT a
|
||||
* shift-extension, the anchor is bumped to the new focused tile so the
|
||||
* next shift-click/arrow starts from the user's current cursor (the
|
||||
* "starting photo") instead of a stale toggle/selectOnly anchor.
|
||||
*
|
||||
* Scroll-into-view tries the direct DOM lookup first (works pre-
|
||||
* windowing AND post-windowing for tiles already in the visible
|
||||
* window); if the tile isn't rendered (windowed out), defer to the
|
||||
* host-provided `scrollToIndex` which expands the window. */
|
||||
function moveFocus(delta: number, extending: boolean) {
|
||||
if (selection.order.length === 0) return;
|
||||
const cur = focusedIndex();
|
||||
const next =
|
||||
cur < 0
|
||||
? delta > 0
|
||||
? 0
|
||||
: selection.order.length - 1
|
||||
: Math.min(Math.max(0, cur + delta), selection.order.length - 1);
|
||||
const nextUid = selection.order[next];
|
||||
setFocused(nextUid);
|
||||
if (!extending) setAnchor(nextUid);
|
||||
const tile = node.querySelector<HTMLElement>(`[data-uid="${nextUid}"]`);
|
||||
if (tile) {
|
||||
tile.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
} else {
|
||||
scrollToIndex?.(next);
|
||||
}
|
||||
}
|
||||
|
||||
/** Synthesize a target list. Preview wins, then multi, then focused. */
|
||||
function cullTargets(): string[] {
|
||||
if (preview.uid) return [preview.uid];
|
||||
if (selection.ids.size > 0) return Array.from(selection.ids);
|
||||
if (selection.focused) return [selection.focused];
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Look up a photo's current cached state without forcing a refetch.
|
||||
* Walks every `['photos', …]` cache entry first, then the per-photo
|
||||
* cache. Lets `x` decide "archive vs restore" based on the actual current
|
||||
* state instead of always sending Archived=true.
|
||||
*
|
||||
* The `['photos', …]` namespace holds two shapes: a flat `PpPhoto[]`
|
||||
* (e.g. ratings/colors pools) and TanStack's `InfiniteData` envelope
|
||||
* (`{pages: PpPhoto[][], pageParams}`) used by the timeline's infinite
|
||||
* scroll. Walk both — assuming a flat array on the timeline cache used
|
||||
* to throw `list.find is not a function` and abort the F/X handlers. */
|
||||
function cachedPhoto(uid: string): PpPhoto | undefined {
|
||||
const lists = queryClient.getQueriesData({ queryKey: ['photos'] });
|
||||
for (const [, data] of lists) {
|
||||
if (!data) continue;
|
||||
if (Array.isArray(data)) {
|
||||
const hit = (data as PpPhoto[]).find((p) => p.UID === uid);
|
||||
if (hit) return hit;
|
||||
continue;
|
||||
}
|
||||
const pages = (data as { pages?: PpPhoto[][] }).pages;
|
||||
if (!Array.isArray(pages)) continue;
|
||||
for (const page of pages) {
|
||||
const hit = page?.find?.((p) => p.UID === uid);
|
||||
if (hit) return hit;
|
||||
}
|
||||
}
|
||||
return queryClient.getQueryData<PpPhoto>(['photo', uid]);
|
||||
}
|
||||
|
||||
async function toggleArchive(direction: 'archive' | 'restore' | 'toggle') {
|
||||
const ids = cullTargets();
|
||||
if (ids.length === 0) {
|
||||
const verb = direction === 'restore' ? 'restore' : 'archive';
|
||||
toast.message(`Nothing to ${verb}`, {
|
||||
description: 'Click a photo or select some first'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let target: boolean;
|
||||
if (direction === 'archive') target = true;
|
||||
else if (direction === 'restore') target = false;
|
||||
else {
|
||||
const first = cachedPhoto(ids[0]);
|
||||
target = !(first?.Archived ?? false);
|
||||
}
|
||||
|
||||
await patchTargets(
|
||||
ids,
|
||||
{ Archived: target },
|
||||
target ? `Archived ${ids.length}` : `Restored ${ids.length}`,
|
||||
(p) => ({ Archived: p.Archived ?? false })
|
||||
);
|
||||
}
|
||||
|
||||
/** Flip the Favorite (heart) flag on cull targets. Reads the first
|
||||
* target's cached `Favorite` to decide direction so a mixed selection
|
||||
* resolves to "make them all favorited" when the first isn't, mirroring
|
||||
* the way `toggleArchive('toggle')` works. */
|
||||
async function toggleFavoriteOnTargets() {
|
||||
const ids = cullTargets();
|
||||
if (ids.length === 0) {
|
||||
toast.message('Nothing to favorite', {
|
||||
description: 'Click a photo or select some first'
|
||||
});
|
||||
return;
|
||||
}
|
||||
const first = cachedPhoto(ids[0]);
|
||||
const next = !(first?.Favorite ?? false);
|
||||
const { updated, errors } = await batchEdit(ids, (id) =>
|
||||
next ? likePhoto(id) : unlikePhoto(id)
|
||||
);
|
||||
void queryClient.invalidateQueries({ queryKey: ['photos'] });
|
||||
for (const id of ids) {
|
||||
void queryClient.invalidateQueries({ queryKey: ['photo', id] });
|
||||
}
|
||||
const verb = next ? 'Favorited' : 'Unfavorited';
|
||||
if (errors.length) {
|
||||
// Surface the actual first error message — silent failures here are
|
||||
// the #1 reason `f` "doesn't work" (e.g. permission, network, 404).
|
||||
toast.error(`${verb} ${updated.length}; ${errors.length} failed`, {
|
||||
description: errors[0].message
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast.success(`${verb} ${ids.length}`);
|
||||
pushUndo(`${verb} ${ids.length}`, async () => {
|
||||
await batchEdit(ids, (id) => (next ? unlikePhoto(id) : likePhoto(id)));
|
||||
void queryClient.invalidateQueries({ queryKey: ['photos'] });
|
||||
for (const id of ids) {
|
||||
void queryClient.invalidateQueries({ queryKey: ['photo', id] });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── S chord (add-to-heap) ────────────────────────────────────────────
|
||||
// Press S: arm a short timer. A digit 1–9 within the window adds the
|
||||
// cull targets to the Nth heap in the heap list. Any other key cancels
|
||||
// the chord without firing. On timeout, fall back to the currently-
|
||||
// viewed heap (i.e. when section==='heap'); otherwise show a hint toast.
|
||||
let sChordTimer: number | null = null;
|
||||
const S_CHORD_MS = 500;
|
||||
|
||||
function clearSChord() {
|
||||
if (sChordTimer !== null) {
|
||||
window.clearTimeout(sChordTimer);
|
||||
sChordTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function addCullTargetsToHeap(heap: PpAlbum) {
|
||||
const ids = cullTargets();
|
||||
if (ids.length === 0) {
|
||||
toast.message('Nothing to add', {
|
||||
description: 'Click a photo or select some first'
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await addToHeap(heap.UID, ids);
|
||||
void queryClient.invalidateQueries({ queryKey: ['heaps'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['photos'] });
|
||||
toast.success(`Added ${ids.length} → ${heap.Title}`);
|
||||
pushUndo(`Added ${ids.length} to ${heap.Title}`, async () => {
|
||||
await removeFromHeap(heap.UID, ids);
|
||||
void queryClient.invalidateQueries({ queryKey: ['heaps'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['photos'] });
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Add-to-heap failed');
|
||||
}
|
||||
}
|
||||
|
||||
async function addCullTargetsToHeapByIndex(idx: number) {
|
||||
const heaps = queryClient.getQueryData<PpAlbum[]>(['heaps']) ?? [];
|
||||
if (idx < 1 || idx > heaps.length) {
|
||||
toast.message(`No heap #${idx}`);
|
||||
return;
|
||||
}
|
||||
await addCullTargetsToHeap(heaps[idx - 1]);
|
||||
}
|
||||
|
||||
async function addCullTargetsToActiveHeap() {
|
||||
if (filters.section !== 'heap' || !filters.heapUid) {
|
||||
toast.message('Press S then 1–9 to pick a heap');
|
||||
return;
|
||||
}
|
||||
const heaps = queryClient.getQueryData<PpAlbum[]>(['heaps']) ?? [];
|
||||
const heap = heaps.find((h) => h.UID === filters.heapUid);
|
||||
if (!heap) {
|
||||
toast.message('Active heap not found');
|
||||
return;
|
||||
}
|
||||
await addCullTargetsToHeap(heap);
|
||||
}
|
||||
|
||||
function openPreviewFromGrid() {
|
||||
const id = selection.focused ?? selection.order[0];
|
||||
if (!id) return;
|
||||
openPreview(id, selection.order);
|
||||
}
|
||||
|
||||
function togglePreview() {
|
||||
if (preview.uid) closePreview();
|
||||
else openPreviewFromGrid();
|
||||
}
|
||||
|
||||
async function onKey(e: KeyboardEvent) {
|
||||
// Don't hijack typing inside form fields.
|
||||
const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase();
|
||||
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
|
||||
|
||||
// S+digit chord. A digit 1–9 within the chord window consumes the key
|
||||
// and fires add-to-heap-N. Any other key cancels the chord without
|
||||
// firing the default active-heap action — the user switched intent —
|
||||
// and falls through to normal handling for that key.
|
||||
if (sChordTimer !== null) {
|
||||
if (/^[1-9]$/.test(e.key)) {
|
||||
e.preventDefault();
|
||||
clearSChord();
|
||||
void addCullTargetsToHeapByIndex(parseInt(e.key, 10));
|
||||
return;
|
||||
}
|
||||
clearSChord();
|
||||
}
|
||||
|
||||
const meta = e.metaKey || e.ctrlKey;
|
||||
const shift = e.shiftKey;
|
||||
const inPreview = preview.uid !== null;
|
||||
|
||||
// ── Grid-only nav keys (preview owns its own Arrow/Esc) ──────────────
|
||||
if (!inPreview) {
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowRight':
|
||||
case 'ArrowUp':
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
if (onArrow) {
|
||||
// Host owns the visual-row map (needed for grids with
|
||||
// interleaved headers). The host calls setFocused +
|
||||
// scrollToIndex + selectRange-on-shift itself.
|
||||
onArrow(e.key, shift);
|
||||
} else {
|
||||
const delta =
|
||||
e.key === 'ArrowLeft'
|
||||
? -1
|
||||
: e.key === 'ArrowRight'
|
||||
? 1
|
||||
: e.key === 'ArrowUp'
|
||||
? -tilesPerRow()
|
||||
: tilesPerRow();
|
||||
moveFocus(delta, shift);
|
||||
if (shift && selection.focused) selectRange(selection.focused);
|
||||
}
|
||||
return;
|
||||
case 'Escape':
|
||||
clearSelection();
|
||||
setFocused(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mode-aware shortcuts (work in grid AND preview) ──────────────────
|
||||
switch (e.key) {
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
togglePreview();
|
||||
return;
|
||||
case 'Tab':
|
||||
// Tab in the grid context = mule-image's left-sidebar toggle.
|
||||
// Browsers reserve Tab for focus traversal — preventDefault
|
||||
// here is fine because the grid owns this surface.
|
||||
e.preventDefault();
|
||||
toggleLeftSidebar();
|
||||
return;
|
||||
case 'i':
|
||||
case 'I':
|
||||
if (!meta && !shift && !inPreview) {
|
||||
e.preventDefault();
|
||||
toggleRightSidebar();
|
||||
}
|
||||
return;
|
||||
case 'b':
|
||||
case 'B':
|
||||
if (!meta && !shift) {
|
||||
e.preventDefault();
|
||||
toggleLeftSidebar();
|
||||
}
|
||||
return;
|
||||
case 'z':
|
||||
case 'Z':
|
||||
if (meta) {
|
||||
e.preventDefault();
|
||||
const entry = await popAndRun();
|
||||
if (entry) toast.success(`Undone: ${entry.label}`);
|
||||
else toast.message('Nothing to undo');
|
||||
}
|
||||
return;
|
||||
case 'a':
|
||||
case 'A':
|
||||
if (meta && !inPreview) {
|
||||
e.preventDefault();
|
||||
for (const id of selection.order) selection.ids.add(id);
|
||||
}
|
||||
return;
|
||||
case 'x':
|
||||
case 'X':
|
||||
if (meta || shift) return;
|
||||
e.preventDefault();
|
||||
void toggleArchive('toggle');
|
||||
return;
|
||||
case 'u':
|
||||
case 'U':
|
||||
if (meta || shift) return;
|
||||
e.preventDefault();
|
||||
void toggleArchive('restore');
|
||||
return;
|
||||
case 'f':
|
||||
case 'F':
|
||||
if (meta || shift) return;
|
||||
e.preventDefault();
|
||||
void toggleFavoriteOnTargets();
|
||||
return;
|
||||
case 's':
|
||||
case 'S':
|
||||
if (meta || shift) return;
|
||||
// Arm the chord. A digit 1–9 within S_CHORD_MS picks heap N;
|
||||
// otherwise we fall back to the currently-viewed heap.
|
||||
e.preventDefault();
|
||||
clearSChord();
|
||||
sChordTimer = window.setTimeout(() => {
|
||||
sChordTimer = null;
|
||||
void addCullTargetsToActiveHeap();
|
||||
}, S_CHORD_MS);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function onClick(e: MouseEvent) {
|
||||
const tile = (e.target as HTMLElement | null)?.closest<HTMLElement>('[data-tile]');
|
||||
if (!tile) return;
|
||||
const uid = tile.dataset.uid;
|
||||
if (!uid) return;
|
||||
if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
selectRange(uid);
|
||||
setFocused(uid);
|
||||
} else if (e.metaKey || e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
toggle(uid);
|
||||
setFocused(uid);
|
||||
} else if (selection.ids.size > 0) {
|
||||
// When a multi-selection is active, a plain click reduces it to
|
||||
// just this tile (matches mule-image's "selection mode" behaviour).
|
||||
e.preventDefault();
|
||||
selection.ids.clear();
|
||||
selection.ids.add(uid);
|
||||
setFocused(uid);
|
||||
}
|
||||
}
|
||||
|
||||
node.addEventListener('click', onClick);
|
||||
// Keydown lives on the window so arrow keys, Esc, ⌘Z, etc. work
|
||||
// immediately on page load regardless of which element holds focus.
|
||||
// The filters inside `onKey` keep form-field typing and preview mode
|
||||
// safe (preview owns its own Arrow/Esc).
|
||||
window.addEventListener('keydown', onKey);
|
||||
|
||||
return {
|
||||
update(next: GridKeyNavParams = {}) {
|
||||
scrollToIndex = next.scrollToIndex;
|
||||
onArrow = next.onArrow;
|
||||
},
|
||||
destroy() {
|
||||
clearSChord();
|
||||
node.removeEventListener('click', onClick);
|
||||
window.removeEventListener('keydown', onKey);
|
||||
ro.disconnect();
|
||||
}
|
||||
};
|
||||
}
|
||||
70
web/src/lib/actions/nearBottom.ts
Normal file
70
web/src/lib/actions/nearBottom.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Fires `onHit` whenever the attached element scrolls near the bottom of
|
||||
* its scroll container. Mirrors PhotoPrism's infinite-scroll trigger from
|
||||
* `frontend/src/page/photos.vue`: an IntersectionObserver on a sentinel
|
||||
* div with a rootMargin equal to ~4 viewport heights, so the next page is
|
||||
* fetched well before the user actually reaches the end.
|
||||
*
|
||||
* Usage: attach to a sentinel <div /> placed at the bottom of the scroll
|
||||
* area. The host gates calls via `enabled` (= `hasNextPage && !isFetching`).
|
||||
*
|
||||
* <div use:nearBottom={{ onHit: fetchNextPage, enabled: canFetch }} />
|
||||
*/
|
||||
export interface NearBottomParams {
|
||||
onHit: () => void;
|
||||
/** When false the observer ignores intersections (use for the
|
||||
* hasNextPage + !isFetchingNextPage gate). */
|
||||
enabled?: boolean;
|
||||
/** Pre-load distance in pixels. PhotoPrism uses `innerHeight * 4`;
|
||||
* we default to the same. Caller can pass a number for tests. */
|
||||
preloadPx?: number;
|
||||
/** Optional scroll root (defaults to the viewport). Pass the
|
||||
* scrolling ancestor when the page itself doesn't scroll, which is
|
||||
* our case — the timeline scrolls inside `<main>`. */
|
||||
root?: Element | null;
|
||||
}
|
||||
|
||||
export function nearBottom(node: HTMLElement, params: NearBottomParams) {
|
||||
let current: NearBottomParams = params;
|
||||
let io: IntersectionObserver | null = null;
|
||||
|
||||
function buildObserver(p: NearBottomParams) {
|
||||
io?.disconnect();
|
||||
const preload = p.preloadPx ?? Math.max(800, window.innerHeight * 4);
|
||||
io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (!current.enabled) return;
|
||||
for (const e of entries) {
|
||||
if (e.isIntersecting) {
|
||||
current.onHit();
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
root: p.root ?? null,
|
||||
// Inflate the root's bottom edge so we trip well before
|
||||
// the sentinel actually enters the viewport.
|
||||
rootMargin: `0px 0px ${preload}px 0px`
|
||||
}
|
||||
);
|
||||
io.observe(node);
|
||||
}
|
||||
|
||||
buildObserver(current);
|
||||
|
||||
return {
|
||||
update(next: NearBottomParams) {
|
||||
const rootChanged = next.root !== current.root;
|
||||
const preloadChanged = next.preloadPx !== current.preloadPx;
|
||||
current = next;
|
||||
// `enabled` and `onHit` are read live inside the callback,
|
||||
// so they don't require rebuilding the observer. Root and
|
||||
// preloadPx are baked in at construction.
|
||||
if (rootChanged || preloadChanged) buildObserver(current);
|
||||
},
|
||||
destroy() {
|
||||
io?.disconnect();
|
||||
}
|
||||
};
|
||||
}
|
||||
78
web/src/lib/actions/resizable.ts
Normal file
78
web/src/lib/actions/resizable.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Drag-to-resize Svelte action. Attaches pointerdown to the host element
|
||||
* (a thin handle on the inner edge of a sidebar) and writes the new width
|
||||
* back via the supplied setter. The pointer is captured so the drag keeps
|
||||
* tracking when the cursor leaves the handle.
|
||||
*
|
||||
* edge: 'right' — handle on the right edge of the panel; drag right widens
|
||||
* edge: 'left' — handle on the left edge of the panel; drag left widens
|
||||
*
|
||||
* Usage:
|
||||
* <div use:resizable={{ edge: 'right', getWidth: () => view.leftSidebarWidth, setWidth: setLeftSidebarWidth }} />
|
||||
*/
|
||||
export interface ResizableParams {
|
||||
edge: 'right' | 'left';
|
||||
getWidth: () => number;
|
||||
setWidth: (px: number) => void;
|
||||
}
|
||||
|
||||
export function resizable(node: HTMLElement, initial: ResizableParams) {
|
||||
let params = initial;
|
||||
let pointerId = -1;
|
||||
let startX = 0;
|
||||
let startWidth = 0;
|
||||
|
||||
function onDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return;
|
||||
pointerId = e.pointerId;
|
||||
startX = e.clientX;
|
||||
startWidth = params.getWidth();
|
||||
node.setPointerCapture(pointerId);
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
node.addEventListener('pointermove', onMove);
|
||||
node.addEventListener('pointerup', onUp);
|
||||
node.addEventListener('pointercancel', onUp);
|
||||
}
|
||||
|
||||
function onMove(e: PointerEvent) {
|
||||
if (e.pointerId !== pointerId) return;
|
||||
const dx = e.clientX - startX;
|
||||
const delta = params.edge === 'right' ? dx : -dx;
|
||||
params.setWidth(startWidth + delta);
|
||||
}
|
||||
|
||||
function onUp(e: PointerEvent) {
|
||||
if (pointerId === -1) return;
|
||||
try {
|
||||
node.releasePointerCapture(pointerId);
|
||||
} catch {
|
||||
// Pointer may already be released; ignore.
|
||||
}
|
||||
pointerId = -1;
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
node.removeEventListener('pointermove', onMove);
|
||||
node.removeEventListener('pointerup', onUp);
|
||||
node.removeEventListener('pointercancel', onUp);
|
||||
}
|
||||
|
||||
function onDoubleClick() {
|
||||
// Reset to the current default-ish midpoint. Callers can override by
|
||||
// providing their own dblclick handler; we just stop pointer events
|
||||
// from leaking up so the page underneath doesn't react.
|
||||
}
|
||||
|
||||
node.addEventListener('pointerdown', onDown);
|
||||
node.addEventListener('dblclick', onDoubleClick);
|
||||
|
||||
return {
|
||||
update(next: ResizableParams) {
|
||||
params = next;
|
||||
},
|
||||
destroy() {
|
||||
node.removeEventListener('pointerdown', onDown);
|
||||
node.removeEventListener('dblclick', onDoubleClick);
|
||||
}
|
||||
};
|
||||
}
|
||||
173
web/src/lib/actions/visibleRange.ts
Normal file
173
web/src/lib/actions/visibleRange.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Tracks the first/last visible tile indices inside the attached scroll
|
||||
* container so the host can render only `[first - BUFFER, last + BUFFER]`
|
||||
* and leave the rest unmounted. Mirrors PhotoPrism's pattern in
|
||||
* `frontend/src/component/photo/view/cards.vue`:
|
||||
*
|
||||
* - One IntersectionObserver on the scroll root.
|
||||
* - Observes every Nth tile (sample, not every tile, to keep observer
|
||||
* overhead flat as the order grows).
|
||||
* - The host registers nodes as they mount via `register(el, index)`
|
||||
* and unregisters via `unregister(el)`.
|
||||
* - The action calls `onChange(first, last)` whenever the visible
|
||||
* window updates.
|
||||
*
|
||||
* Use it like:
|
||||
*
|
||||
* const range = $state({ first: 0, last: 0 });
|
||||
* <main use:visibleRange={{
|
||||
* onChange: (f, l) => { range.first = f; range.last = l; },
|
||||
* sampleEvery: 5,
|
||||
* }}>
|
||||
* {#each photos as p, i}
|
||||
* {#if i >= range.first - BUFFER && i <= range.last + BUFFER}
|
||||
* <Tile {p} use:registerTile={i} />
|
||||
* {:else}
|
||||
* <div style="height: {tileHeight}px"></div>
|
||||
* {/if}
|
||||
* {/each}
|
||||
* </main>
|
||||
*
|
||||
* The returned controller exposes `register`/`unregister`/`expand` so the
|
||||
* host can plumb them through.
|
||||
*/
|
||||
|
||||
export interface VisibleRangeParams {
|
||||
onChange: (first: number, last: number) => void;
|
||||
/** Sample 1 in N tiles. Higher numbers reduce observer overhead at
|
||||
* the cost of resolution. PhotoPrism uses 5. */
|
||||
sampleEvery?: number;
|
||||
/** Optional override: trigger zone in px around the scroll root.
|
||||
* Defaults to 0 (only counts the visible viewport). */
|
||||
rootMargin?: string;
|
||||
}
|
||||
|
||||
export interface VisibleRangeController {
|
||||
register(el: HTMLElement, index: number): void;
|
||||
unregister(el: HTMLElement): void;
|
||||
/** Force the window to include `index` (used by keyboard navigation
|
||||
* before scrollIntoView so the target tile actually exists). The
|
||||
* host should expand its own `first`/`last` reactive state — the
|
||||
* action only tracks observed intersections. */
|
||||
}
|
||||
|
||||
/** Public handle the host attaches to each tile to enrol it in the
|
||||
* visibility observer. Returned by setup() rather than created here so
|
||||
* it closes over the active observer instance. */
|
||||
export interface VisibleRangeHandle {
|
||||
register(el: HTMLElement, index: number): void;
|
||||
unregister(el: HTMLElement): void;
|
||||
}
|
||||
|
||||
export function visibleRange(node: HTMLElement, params: VisibleRangeParams) {
|
||||
let current = params;
|
||||
const indexByEl = new WeakMap<Element, number>();
|
||||
const visibleIndices = new Set<number>();
|
||||
let observer: IntersectionObserver | null = null;
|
||||
let lastFirst = -1;
|
||||
let lastLast = -1;
|
||||
|
||||
function rebuild() {
|
||||
observer?.disconnect();
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
let dirty = false;
|
||||
for (const e of entries) {
|
||||
const i = indexByEl.get(e.target);
|
||||
if (i === undefined) continue;
|
||||
const wasIn = visibleIndices.has(i);
|
||||
if (e.isIntersecting && !wasIn) {
|
||||
visibleIndices.add(i);
|
||||
dirty = true;
|
||||
} else if (!e.isIntersecting && wasIn) {
|
||||
visibleIndices.delete(i);
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
if (!dirty) return;
|
||||
emit();
|
||||
},
|
||||
{
|
||||
root: node,
|
||||
rootMargin: current.rootMargin ?? '0px'
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function emit() {
|
||||
if (visibleIndices.size === 0) {
|
||||
// Don't emit (0, 0) — the host's last known window stays valid
|
||||
// and the user is likely between layout passes. Once a sample
|
||||
// tile re-enters view, the next intersection fires and we
|
||||
// update for real.
|
||||
return;
|
||||
}
|
||||
let first = Number.POSITIVE_INFINITY;
|
||||
let last = Number.NEGATIVE_INFINITY;
|
||||
for (const i of visibleIndices) {
|
||||
if (i < first) first = i;
|
||||
if (i > last) last = i;
|
||||
}
|
||||
if (first === lastFirst && last === lastLast) return;
|
||||
lastFirst = first;
|
||||
lastLast = last;
|
||||
current.onChange(first, last);
|
||||
}
|
||||
|
||||
rebuild();
|
||||
|
||||
const handle: VisibleRangeHandle = {
|
||||
register(el, index) {
|
||||
const every = current.sampleEvery ?? 5;
|
||||
// Sample 1-in-N tiles. The host blindly calls register for
|
||||
// every mounted tile; we only attach the observer to the
|
||||
// sample subset to keep observer load O(n/N).
|
||||
if (index % every !== 0) return;
|
||||
indexByEl.set(el, index);
|
||||
observer?.observe(el);
|
||||
},
|
||||
unregister(el) {
|
||||
if (!indexByEl.has(el)) return;
|
||||
const i = indexByEl.get(el);
|
||||
if (i !== undefined) visibleIndices.delete(i);
|
||||
indexByEl.delete(el);
|
||||
observer?.unobserve(el);
|
||||
emit();
|
||||
}
|
||||
};
|
||||
|
||||
// Stash the handle on the node so the host can grab it via the
|
||||
// action's return. Svelte's action API only returns update/destroy,
|
||||
// so we expose `getHandle` through a one-shot accessor on the host.
|
||||
(node as HTMLElement & { __visibleRange?: VisibleRangeHandle }).__visibleRange = handle;
|
||||
|
||||
return {
|
||||
update(next: VisibleRangeParams) {
|
||||
const sampleChanged = (next.sampleEvery ?? 5) !== (current.sampleEvery ?? 5);
|
||||
const marginChanged = next.rootMargin !== current.rootMargin;
|
||||
current = next;
|
||||
if (sampleChanged || marginChanged) {
|
||||
// Re-observe everything under the new config. Cheapest is
|
||||
// to disconnect; the host's tile-mount effects will
|
||||
// re-register on next paint when they read sampleEvery.
|
||||
observer?.disconnect();
|
||||
indexByEl as unknown; // no-op; entries stay valid for the rebuild
|
||||
visibleIndices.clear();
|
||||
lastFirst = -1;
|
||||
lastLast = -1;
|
||||
rebuild();
|
||||
}
|
||||
},
|
||||
destroy() {
|
||||
observer?.disconnect();
|
||||
delete (node as HTMLElement & { __visibleRange?: VisibleRangeHandle }).__visibleRange;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Read the handle the action stashed on the scroll-root node. Used by
|
||||
* the host's per-tile register/unregister calls. */
|
||||
export function getVisibleRangeHandle(node: HTMLElement | undefined): VisibleRangeHandle | null {
|
||||
if (!node) return null;
|
||||
return (node as HTMLElement & { __visibleRange?: VisibleRangeHandle }).__visibleRange ?? null;
|
||||
}
|
||||
Reference in New Issue
Block a user