Files
yellowjacket/frontend/src/services/shortcut-scope.ts
T
logan 7acb197daf feat(frontend): give a cached view a lifecycle and a keyboard owner
`index.ts` caches primary views and hides them with a class so
scrollTop survives navigation. Nothing else was told: `disconnectedCallback`
never fires for one, so everything written to clean up there never
cleans up. The worst case was not a leak — pressing `s` on Settings
skipped two albums out of the Autotag queue, and `a` on that same live
handler rewrites tags on disk.

- `utils/view-lifecycle.ts` is the missing half: `viewActivated` /
  `viewDeactivated`, with `listenWhileActive`, `intervalWhileActive`
  and `whileActive` torn down on the way out, and an off-screen view
  that does not render. `registerViewAware` gives a shared reactive
  controller the same treatment, because a controller cannot know
  whether its host is a cached view — `ContextMenuController` bound
  three document listeners in `hostConnected`, which for a cached host
  is "forever".
- `services/shortcut-scope.ts` publishes the ambient scope. Resolving
  scope from focus alone was not enough: this app is driven with the
  mouse, focus sits on `<body>`, and a focus-only rule would have made
  the panel keys work only after a click landed inside the panel.
- Global bindings yield to a focused control that owns the key —
  button, select, slider, checkbox, menu, grid row, or anything inside
  an open dialog — so the unmodified single-key bindings stop stealing
  Space and the arrows.
- `utils/roving-grid.ts` gives a card grid one tab stop moved with the
  arrows, since a card per tab stop makes a library-length tab
  sequence.
2026-08-12 01:18:34 -04:00

46 lines
1.6 KiB
TypeScript

/**
* The ambient shortcut scope: which panel's bindings apply when nothing
* inside a panel has focus.
*
* `resolveScope` in the shortcut service walks up from the focused
* element looking for `data-shortcut-scope`, which is the right answer
* when something is focused — but this app is mostly driven with the
* mouse, so the usual state is that focus sits on `<body>` and the walk
* finds nothing. Without a fallback the panel bindings would only work
* after a click landed inside the panel, which is not the behaviour the
* Autotag page has today and not one worth regressing to.
*
* A claim is held by the *active view* (see `utils/view-lifecycle.ts`),
* so it is released the moment the view leaves the screen — which is
* what stops an off-screen view's keys from firing.
*/
/** Claims, innermost last. A stack rather than a single value so that
* releasing an outer claim out of order cannot resurrect it. */
const claims: Array<{ scope: string }> = [];
/**
* Claim `scope` as the ambient panel scope. Returns the release.
*/
export function claimShortcutScope(scope: string): () => void {
const claim = { scope };
claims.push(claim);
return () => {
const at = claims.indexOf(claim);
if (at >= 0) claims.splice(at, 1);
};
}
/** The innermost claimed scope, or null. */
export function ambientShortcutScope(): string | null {
return claims.length > 0 ? claims[claims.length - 1]!.scope : null;
}
/** Drop every claim. Test-only escape hatch. */
export function resetShortcutScopes(): void {
claims.length = 0;
}