Replaces "PhotoPrism" in UI strings (empty states, tooltips, toasts, log header, login screen) with neutral terms like "the indexer", "the library", "the server" — accurate regardless of backend. The login header becomes "Mulimage" and drops the explicit PhotoPrism mention. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
592 lines
20 KiB
TypeScript
592 lines
20 KiB
TypeScript
import { toast } from 'svelte-sonner';
|
||
import { batchEdit } from '$lib/services/batch';
|
||
import { invalidatePhotos } from '$lib/services/bulk';
|
||
import {
|
||
addToHeap,
|
||
approvePhoto,
|
||
batchArchive,
|
||
batchDelete,
|
||
batchRestore,
|
||
removeFromHeap,
|
||
type PpAlbum
|
||
} from '$lib/services/photoprism';
|
||
import { queryClient } from '$lib/queryClient';
|
||
import { filters } from '$lib/stores/filters.svelte';
|
||
import {
|
||
clearBulkToFirst,
|
||
clearSelection,
|
||
focusAfter,
|
||
indexOf,
|
||
selectRange,
|
||
selection,
|
||
setAnchor,
|
||
setFocused,
|
||
toggle
|
||
} from '$lib/stores/selection.svelte';
|
||
import { popAndRun, push as pushUndo } from '$lib/stores/undo.svelte';
|
||
import { openPreview, toggleLeftSidebar, toggleRightSidebar, view } 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, s + (1–9) add to
|
||
* heap N (bare s adds to the currently-viewed heap), b/Tab toggles
|
||
* left sidebar, i toggles right sidebar, 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. multi-selection set
|
||
* 2. 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];
|
||
// Plain arrow nav collapses any prior multi-selection down to the
|
||
// cursor: one ringed tile at a time. Shift-extend keeps `ids`
|
||
// growing from the anchor (selectRange runs after this).
|
||
if (!extending) clearSelection();
|
||
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. Multi-selection wins, then focused. */
|
||
function cullTargets(): string[] {
|
||
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);
|
||
}
|
||
|
||
// PhotoPrism's photo PUT silently drops the Archived field — the
|
||
// only working path is /api/v1/batch/photos/{archive,restore}. The
|
||
// previous patchTargets call PUT'd `{Archived: true}` and got a 200
|
||
// back, so the toast fired but nothing moved.
|
||
try {
|
||
if (target) await batchArchive(ids);
|
||
else await batchRestore(ids);
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : 'Archive failed');
|
||
return;
|
||
}
|
||
// Move focus forward before the photos query refetches, so the
|
||
// user can keep X-ing through the timeline without their cursor
|
||
// snapping back to photo[0]. Walks past every uid we just
|
||
// archived/restored — relevant when the cull targets came from a
|
||
// multi-selection rather than the single focused tile.
|
||
focusAfter(ids);
|
||
// Drop the now-stale selection set. The archived UIDs are about
|
||
// to leave the timeline on refetch, but the SvelteSet membership
|
||
// keeps the selection ring on them until then — confusing for
|
||
// the user and a footgun if they Ctrl-click to add more and end
|
||
// up re-archiving the same photos. The BulkActionBar button path
|
||
// clears for the same reason; mirror it here.
|
||
clearSelection();
|
||
invalidatePhotos(ids);
|
||
const label = target ? `Archived ${ids.length}` : `Restored ${ids.length}`;
|
||
toast.success(label);
|
||
pushUndo(label, async () => {
|
||
if (target) await batchRestore(ids);
|
||
else await batchArchive(ids);
|
||
invalidatePhotos(ids);
|
||
});
|
||
}
|
||
|
||
/** Permanently delete cull targets — only callable from the archive
|
||
* section (X is rerouted away from archive-toggle there). PhotoPrism
|
||
* rejects deletion of un-archived photos with a 4xx, so the section
|
||
* gate doubles as a safety guard against accidental deletes from the
|
||
* main timeline. Confirm dialog is mandatory — no undo path exists. */
|
||
async function deleteCullTargets() {
|
||
const ids = cullTargets();
|
||
if (ids.length === 0) {
|
||
toast.message('Nothing to delete', {
|
||
description: 'Click a photo or select some first'
|
||
});
|
||
return;
|
||
}
|
||
const msg =
|
||
ids.length === 1
|
||
? 'Permanently delete this photo? This cannot be undone.'
|
||
: `Permanently delete ${ids.length} photos? This cannot be undone.`;
|
||
if (!confirm(msg)) return;
|
||
try {
|
||
await batchDelete(ids);
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : 'Delete failed');
|
||
return;
|
||
}
|
||
focusAfter(ids);
|
||
clearSelection();
|
||
invalidatePhotos(ids);
|
||
toast.success(`Deleted ${ids.length}`);
|
||
}
|
||
|
||
/** Approve cull targets — clears them out of the review pile by
|
||
* bumping each photo's quality score above PhotoPrism's review
|
||
* threshold. The op is one-way (no /unapprove route), so we don't
|
||
* push an undo entry: a re-keyed S would just be a no-op on
|
||
* already-approved photos. */
|
||
async function approveCullTargets() {
|
||
const ids = cullTargets();
|
||
if (ids.length === 0) {
|
||
toast.message('Nothing to keep', {
|
||
description: 'Click a photo or select some first'
|
||
});
|
||
return;
|
||
}
|
||
const { updated, errors } = await batchEdit(ids, (id) => approvePhoto(id));
|
||
// Approve moves photos out of the review pile, so the same
|
||
// stale-selection trap as archive/delete applies — advance focus
|
||
// past the approved set and drop the now-irrelevant selection
|
||
// before invalidate refetches the (smaller) view.
|
||
focusAfter(ids);
|
||
clearSelection();
|
||
invalidatePhotos(ids);
|
||
if (errors.length) {
|
||
toast.error(`Kept ${updated.length}; ${errors.length} failed`, {
|
||
description: errors[0].message
|
||
});
|
||
return;
|
||
}
|
||
toast.success(`Kept ${ids.length}`);
|
||
}
|
||
|
||
// ── 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 {
|
||
const { added } = await addToHeap(heap.UID, ids);
|
||
void queryClient.invalidateQueries({ queryKey: ['heaps'] });
|
||
void queryClient.invalidateQueries({ queryKey: ['photos'] });
|
||
// PhotoPrism returns 200 even when nothing was added — distinguish
|
||
// "really added N" from "skipped all N" so the toast tells the
|
||
// truth.
|
||
if (added.length === 0) {
|
||
toast.error(`Nothing added to ${heap.Title}`, {
|
||
description: `The server rejected all ${ids.length} UIDs (already in heap, or not indexed).`
|
||
});
|
||
return;
|
||
}
|
||
if (added.length < ids.length) {
|
||
toast.success(`Added ${added.length}/${ids.length} → ${heap.Title}`, {
|
||
description: 'The rest were already in this heap.'
|
||
});
|
||
} else {
|
||
toast.success(`Added ${added.length} → ${heap.Title}`);
|
||
}
|
||
pushUndo(`Added ${added.length} to ${heap.Title}`, async () => {
|
||
await removeFromHeap(heap.UID, added);
|
||
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);
|
||
}
|
||
|
||
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;
|
||
|
||
// Modal owns arrow / Escape / Space while it's open — it handles
|
||
// its own linear nav, close-on-Esc, and close-on-Space. Action
|
||
// keys (X/S/U/A/Z) still pass through because they target the
|
||
// shared selection store and work the same in either context.
|
||
if (view.previewOpen) {
|
||
if (
|
||
e.key === 'ArrowLeft' ||
|
||
e.key === 'ArrowRight' ||
|
||
e.key === 'ArrowUp' ||
|
||
e.key === 'ArrowDown' ||
|
||
e.key === 'Escape' ||
|
||
e.key === ' ' ||
|
||
e.code === 'Space'
|
||
) {
|
||
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;
|
||
|
||
// Space on a focused tile opens the full-screen preview modal.
|
||
// Matches the dblclick gesture so the user has both keyboard and
|
||
// mouse paths to the same surface. `e.code === 'Space'` covers
|
||
// layouts where `e.key` is the dead-key combining mark.
|
||
if ((e.key === ' ' || e.code === 'Space') && !meta && !shift) {
|
||
if (selection.focused) {
|
||
e.preventDefault();
|
||
openPreview();
|
||
return;
|
||
}
|
||
}
|
||
|
||
// ── Grid nav keys ────────────────────────────────────────────────────
|
||
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':
|
||
// First Esc collapses a multi-selection back to single-focus
|
||
// on its first member — the user's "starting photo" stays
|
||
// visible instead of vanishing. Only when there's no bulk
|
||
// does Esc fully dismiss focus.
|
||
if (clearBulkToFirst()) return;
|
||
clearSelection();
|
||
setFocused(null);
|
||
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) {
|
||
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) {
|
||
e.preventDefault();
|
||
for (const id of selection.order) selection.ids.add(id);
|
||
}
|
||
return;
|
||
case 'x':
|
||
case 'X':
|
||
if (meta || shift) return;
|
||
e.preventDefault();
|
||
// Archive section: X becomes permanent delete (Keep/Delete
|
||
// is the binary flow there, mirroring Review's Keep/Archive).
|
||
// Everywhere else X toggles archive on the cull targets.
|
||
if (filters.section === 'archive') {
|
||
void deleteCullTargets();
|
||
return;
|
||
}
|
||
void toggleArchive('toggle');
|
||
return;
|
||
case 'u':
|
||
case 'U':
|
||
if (meta || shift) return;
|
||
e.preventDefault();
|
||
void toggleArchive('restore');
|
||
return;
|
||
case 's':
|
||
case 'S':
|
||
if (meta || shift) return;
|
||
e.preventDefault();
|
||
// Review section repurposes S as the Keep affordance —
|
||
// matches the BulkActionBar button and keeps the binary
|
||
// Keep/Archive flow on home-row keys (S/X). The heap chord
|
||
// is meaningless here anyway (review photos can't sensibly
|
||
// be filed before they're approved).
|
||
if (filters.section === 'review') {
|
||
void approveCullTargets();
|
||
return;
|
||
}
|
||
// Archive section: S = Keep = restore back to the timeline
|
||
// (inverse of Delete on X). Same rationale as review —
|
||
// heap-filing an archived photo isn't a flow that fits the
|
||
// section's intent.
|
||
if (filters.section === 'archive') {
|
||
void toggleArchive('restore');
|
||
return;
|
||
}
|
||
// Arm the chord. A digit 1–9 within S_CHORD_MS picks heap N;
|
||
// otherwise we fall back to the currently-viewed heap.
|
||
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;
|
||
// Modifier clicks are the only paths this document-level handler
|
||
// owns. Plain clicks bubble to the tile button's onclick, which
|
||
// reduces selection to just that tile.
|
||
if (e.shiftKey) {
|
||
e.preventDefault();
|
||
selectRange(uid);
|
||
setFocused(uid);
|
||
} else if (e.metaKey || e.ctrlKey) {
|
||
e.preventDefault();
|
||
toggle(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 filter inside `onKey` keeps form-field typing safe.
|
||
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();
|
||
}
|
||
};
|
||
}
|