mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-15 13:55:51 -07:00
1c9b2426d9
- 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
82 lines
2.6 KiB
TypeScript
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);
|