mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-16 01:55:51 -07:00
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
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 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);
|
||||
Reference in New Issue
Block a user