Files
SubMiner/src/animeui/context-menu.ts
T
sudacode 1c9b2426d9 feat(anime): filter episodes and mark them watched by hand
- Add a filter box above the episode list for a number, range, or name substring, with a "N of M" counter
- Read watch marks from the immersion tracker stats and show them per episode, with a watched count in the header, refreshed on window focus
- Add a right-click menu to mark one episode or a whole catch-up span (this and everything below) watched/unwatched
- Add setWatched/getWatchState IPC plumbing so a manual mark creates the stats row for an episode that was never played
2026-08-14 22:42:11 -07:00

82 lines
2.6 KiB
TypeScript

/**
* A small right-click menu.
*
* Electron's native menus are a main-process affair and the browser window is
* plain HTML, so the menu is built in the page. It is a singleton: opening one
* closes whatever was open before, and any click, scroll, resize or Escape
* dismisses it.
*/
export interface ContextMenuItem {
label: string;
onSelect: () => void;
/** Shown greyed out and not selectable. */
disabled?: boolean;
/** Draws a divider above this item. */
separated?: boolean;
}
let open: HTMLDivElement | null = null;
export function closeContextMenu(): void {
open?.remove();
open = null;
}
/**
* Show `items` at the pointer. The menu is placed inside the viewport rather
* than at the raw coordinates, so a right-click near an edge is still readable.
*/
export function showContextMenu(x: number, y: number, items: ContextMenuItem[]): void {
closeContextMenu();
if (items.length === 0) return;
const menu = document.createElement('div');
menu.className = 'context-menu';
menu.setAttribute('role', 'menu');
for (const item of items) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'context-menu-item';
button.setAttribute('role', 'menuitem');
button.textContent = item.label;
if (item.separated) button.dataset.separated = 'true';
if (item.disabled) {
button.disabled = true;
} else {
button.addEventListener('click', () => {
closeContextMenu();
item.onSelect();
});
}
menu.append(button);
}
document.body.append(menu);
open = menu;
const { width, height } = menu.getBoundingClientRect();
const left = Math.max(4, Math.min(x, window.innerWidth - width - 4));
const top = Math.max(4, Math.min(y, window.innerHeight - height - 4));
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
menu.querySelector<HTMLButtonElement>('.context-menu-item:not(:disabled)')?.focus();
}
// Registered once: a menu that outlives the click that dismissed it is worse
// than no menu at all.
document.addEventListener('pointerdown', (event) => {
if (open && !open.contains(event.target as Node)) closeContextMenu();
});
document.addEventListener('keydown', (event) => {
if (!open || event.key !== 'Escape') return;
// The detail page also closes on Escape, from a listener on this same node,
// so stopping propagation alone would not spare it.
event.stopImmediatePropagation();
closeContextMenu();
});
window.addEventListener('blur', closeContextMenu);
window.addEventListener('resize', closeContextMenu);
document.addEventListener('scroll', closeContextMenu, true);