Files
yellowjacket/frontend/src/utils/context-menu-controller.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

363 lines
9.7 KiB
TypeScript

import { css } from 'lit';
import type {
ReactiveController,
ReactiveControllerHost,
} from 'lit';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
import { registerViewAware } from './view-lifecycle';
/**
* Host interface for components using the ContextMenuController.
* The host must provide access to the popup elements (typically
* via @query decorators) and optionally a callback for cleanup
* when the context menu closes.
*/
export interface ContextMenuHost
extends ReactiveControllerHost {
updateComplete: Promise<boolean>;
shadowRoot: ShadowRoot | null;
/** Return the main context-menu popup element. */
getContextMenuPopup(): WaPopup | undefined;
/** Return the playlist submenu popup element. */
getPlaylistSubmenuPopup(): WaPopup | undefined;
/**
* Called when the context menu is closed by an
* outside click/contextmenu/mousedown. Components
* use this to clear domain-specific state (e.g.
* contextMenuAlbumId, contextMenuGenreName).
*/
onContextMenuClose?(): void;
}
/** Submenu close delay in milliseconds. */
const SUBMENU_CLOSE_DELAY = 150;
/**
* Reusable context menu controller that manages the open/close
* state of a wa-popup context menu with an optional playlist
* submenu.
*
* Handles:
* - Opening the context menu at a given screen position
* - Closing on outside click / contextmenu / mousedown
* - Playlist submenu open/close with hover delay
* - Document-level event listener lifecycle
*
* Does NOT handle:
* - Rendering the context menu template (component-specific)
* - Dispatching menu actions (component-specific)
* - File path resolution for the playlist picker
*/
export class ContextMenuController
implements ReactiveController
{
private host: ContextMenuHost;
/** Whether the main context menu popup is open. */
contextMenuOpen = false;
/** Whether the playlist submenu popup is open. */
playlistSubmenuOpen = false;
/** File paths to pass to the playlist picker. */
playlistFilePaths: string[] = [];
private submenuCloseTimer: ReturnType<
typeof setTimeout
> | null = null;
/** Bound close handler for document events. */
private closeHandler = () => this.close();
/** Bound mousedown handler for outside-click detection. */
private mousedownCloseHandler = (
e: MouseEvent,
) => {
const path = e.composedPath();
const popup =
this.host.getContextMenuPopup();
const submenu =
this.host.getPlaylistSubmenuPopup();
if (popup && path.includes(popup)) return;
if (submenu && path.includes(submenu)) {
return;
}
this.close();
};
constructor(host: ContextMenuHost) {
this.host = host;
host.addController(this);
}
// =================================================================
// LIFECYCLE
// =================================================================
/** Whether the document listeners are currently installed. */
private listening = false;
hostConnected(): void {
// On a cached view, connection is not the right signal: it never
// un-happens, so these listeners would stay on the document for
// the life of the session and close a menu belonging to a page
// the user left. A lifecycle host drives attach/detach instead.
const managed = registerViewAware(this.host, {
onHostActivate: () => this.attach(),
onHostDeactivate: () => this.detach(),
});
if (!managed) this.attach();
}
hostDisconnected(): void {
this.detach();
this.clearSubmenuCloseTimer();
}
private attach(): void {
if (this.listening) return;
this.listening = true;
document.addEventListener(
'click',
this.closeHandler,
);
document.addEventListener(
'contextmenu',
this.closeHandler,
);
document.addEventListener(
'mousedown',
this.mousedownCloseHandler,
);
}
private detach(): void {
if (!this.listening) return;
this.listening = false;
document.removeEventListener(
'click',
this.closeHandler,
);
document.removeEventListener(
'contextmenu',
this.closeHandler,
);
document.removeEventListener(
'mousedown',
this.mousedownCloseHandler,
);
}
// =================================================================
// MAIN CONTEXT MENU
// =================================================================
/**
* Open the context menu at the given screen
* coordinates using a virtual anchor.
*/
openAt(clientX: number, clientY: number): void {
this.contextMenuOpen = true;
this.host.requestUpdate();
void this.host.updateComplete.then(() => {
const popup =
this.host.getContextMenuPopup();
if (!popup) return;
popup.anchor = {
getBoundingClientRect() {
return new DOMRect(
clientX,
clientY,
0,
0,
);
},
};
popup.active = true;
});
}
/**
* Close the context menu and playlist submenu.
* Notifies the host via `onContextMenuClose()` so
* it can clear domain-specific state.
*/
close(): void {
if (!this.contextMenuOpen) return;
this.closePlaylistSubmenu();
this.contextMenuOpen = false;
this.playlistFilePaths = [];
const popup =
this.host.getContextMenuPopup();
if (popup) {
popup.active = false;
}
this.host.onContextMenuClose?.();
this.host.requestUpdate();
}
// =================================================================
// PLAYLIST SUBMENU
// =================================================================
/**
* Open the playlist submenu, positioning it
* relative to the `.submenu-item` trigger element.
*
* @param filePaths - File paths to pass to the
* playlist picker. The caller resolves these
* before calling (sync or async).
*/
async showPlaylistSubmenu(
filePaths: string[],
): Promise<void> {
this.clearSubmenuCloseTimer();
if (this.playlistSubmenuOpen) return;
if (filePaths.length === 0) return;
this.playlistFilePaths = filePaths;
this.playlistSubmenuOpen = true;
this.host.requestUpdate();
await this.host.updateComplete;
const submenu =
this.host.getPlaylistSubmenuPopup();
const trigger =
this.host.shadowRoot?.querySelector(
'.submenu-item',
);
if (submenu && trigger) {
submenu.anchor = trigger;
submenu.active = true;
}
const picker =
this.host.shadowRoot?.querySelector(
'playlist-picker',
) as
| (HTMLElement & { reset(): void })
| null;
picker?.reset();
}
/** Close the playlist submenu. */
closePlaylistSubmenu(): void {
this.clearSubmenuCloseTimer();
if (!this.playlistSubmenuOpen) return;
this.playlistSubmenuOpen = false;
const submenu =
this.host.getPlaylistSubmenuPopup();
if (submenu) {
submenu.active = false;
}
this.host.requestUpdate();
}
/** Clear any pending submenu close timer. */
clearSubmenuCloseTimer(): void {
if (this.submenuCloseTimer !== null) {
clearTimeout(this.submenuCloseTimer);
this.submenuCloseTimer = null;
}
}
/**
* Schedule the submenu to close after a short
* delay. Used on mouseleave to allow the user to
* move between the trigger and the submenu popup.
*/
scheduleSubmenuClose = (): void => {
this.clearSubmenuCloseTimer();
this.submenuCloseTimer = setTimeout(() => {
this.submenuCloseTimer = null;
this.closePlaylistSubmenu();
}, SUBMENU_CLOSE_DELAY);
};
/**
* Convenience callback for the playlist-picker's
* `playlist-action-complete` event. Closes the
* entire context menu.
*/
onPlaylistActionComplete = (): void => {
this.close();
};
}
/**
* Shared CSS styles for context menu and playlist submenu
* popups. Components include these via the static styles
* array: `static override styles = [myStyles, contextMenuStyles]`.
*/
export const contextMenuStyles = css`
#context-menu {
z-index: 200;
}
.context-menu-panel {
background-color: var(
--yj-bg-elevated,
#343a40
);
border: 1px solid var(--yj-border, #444);
border-radius: 6px;
padding: 4px 0;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
min-width: 160px;
}
.context-menu-panel wa-dropdown-item {
cursor: pointer;
--wa-color-text-normal: var(
--yj-text-primary,
#fff
);
font-size: 13px;
}
.context-menu-panel wa-dropdown-item:hover {
background-color: var(
--yj-hover-overlay,
rgba(255, 255, 255, 0.1)
);
}
.submenu-item {
position: relative;
}
.submenu-arrow {
font-size: 10px;
margin-left: auto;
padding-left: 12px;
}
#playlist-submenu {
z-index: 210;
}
`;