feat(notifications): add overlay notifications with position config (#110)

This commit is contained in:
2026-06-10 22:46:52 -07:00
committed by GitHub
parent c09d009a3e
commit 7be1843c41
177 changed files with 7524 additions and 440 deletions
+3
View File
@@ -94,6 +94,7 @@ function createEmptyShortcuts(): ConfiguredShortcuts {
openControllerSelect: null,
openControllerDebug: null,
toggleSubtitleSidebar: null,
toggleNotificationHistory: null,
};
}
@@ -133,6 +134,7 @@ function installKeyboardTestGlobals() {
openControllerSelect: 'Alt+C',
openControllerDebug: 'Alt+Shift+C',
toggleSubtitleSidebar: '',
toggleNotificationHistory: '',
toggleVisibleOverlayGlobal: '',
};
let markActiveVideoWatchedResult = true;
@@ -1178,6 +1180,7 @@ test('refreshConfiguredShortcuts updates hot-reloaded stats and watched keys', a
openControllerSelect: 'Alt+C',
openControllerDebug: 'Alt+Shift+C',
toggleSubtitleSidebar: '',
toggleNotificationHistory: '',
toggleVisibleOverlayGlobal: '',
});
testGlobals.setStatsToggleKey('');
+31
View File
@@ -42,6 +42,37 @@
role="status"
aria-live="polite"
></div>
<div
id="overlayNotificationStack"
class="overlay-notification-stack position-top-right hidden"
aria-live="polite"
aria-atomic="false"
></div>
<aside
id="overlayNotificationHistory"
class="notification-history side-right"
role="dialog"
aria-label="Notification history"
aria-hidden="true"
>
<header class="notification-history-header">
<span class="notification-history-title">Notifications</span>
<div class="notification-history-header-actions">
<button class="notification-history-clear" type="button">Clear</button>
<button
class="notification-history-close"
type="button"
aria-label="Close notification history"
>
×
</button>
</div>
</header>
<div class="notification-history-body">
<ul class="notification-history-list"></ul>
<div class="notification-history-empty">No notifications yet</div>
</div>
</aside>
<div id="secondarySubContainer" class="secondary-sub-hidden">
<div id="secondarySubRoot"></div>
</div>
@@ -201,6 +201,8 @@ function describeSessionAction(
return 'Toggle secondary subtitle mode';
case 'toggleSubtitleSidebar':
return 'Toggle subtitle sidebar';
case 'toggleNotificationHistory':
return 'Toggle notification history';
case 'markAudioCard':
return 'Mark audio card';
case 'markWatched':
@@ -254,6 +256,7 @@ function sectionForSessionBinding(binding: CompiledSessionBinding): string {
case 'toggleVisibleOverlay':
case 'toggleSecondarySub':
case 'toggleSubtitleSidebar':
case 'toggleNotificationHistory':
return 'Overlay controls';
case 'triggerSubsync':
return 'Subtitle sync';
@@ -166,3 +166,88 @@ test('overlay measurement includes open subtitle sidebar bounds as an interactiv
}
}
});
test('overlay measurement includes overlay notification stack bounds as an interactive rect', () => {
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const reports: unknown[] = [];
Object.defineProperty(globalThis, 'window', {
configurable: true,
writable: true,
value: {
innerWidth: 1920,
innerHeight: 1080,
electronAPI: {
reportOverlayContentBounds: (payload: unknown) => {
reports.push(payload);
},
},
},
});
try {
const reporter = createOverlayContentMeasurementReporter({
platform: { overlayLayer: 'visible' },
state: { subtitleSidebarModalOpen: false },
dom: {
subtitleRoot: makeElement('', {
left: 0,
top: 0,
width: 0,
height: 0,
} as DOMRect),
subtitleContainer: makeElement('', {
left: 0,
top: 0,
width: 0,
height: 0,
} as DOMRect),
secondarySubRoot: makeElement('', {
left: 0,
top: 0,
width: 0,
height: 0,
} as DOMRect),
secondarySubContainer: makeElement('', {
left: 0,
top: 0,
width: 0,
height: 0,
} as DOMRect),
overlayNotificationStack: {
children: [{}, {}],
getBoundingClientRect: () =>
({
left: 1540,
top: 16,
width: 360,
height: 220,
}) as DOMRect,
},
},
} as never);
reporter.emitNow();
const measuredAtMs = (reports[0] as { measuredAtMs?: unknown } | undefined)?.measuredAtMs;
if (typeof measuredAtMs !== 'number') {
assert.fail('Expected report timestamp.');
}
assert.deepEqual(reports, [
{
layer: 'visible',
measuredAtMs,
viewport: { width: 1920, height: 1080 },
contentRect: { x: 1540, y: 16, width: 360, height: 220 },
interactiveRects: [{ x: 1540, y: 16, width: 360, height: 220 }],
},
]);
} finally {
if (originalWindow) {
Object.defineProperty(globalThis, 'window', originalWindow);
} else {
delete (globalThis as { window?: unknown }).window;
}
}
});
@@ -76,6 +76,22 @@ function collectInteractiveRects(ctx: RendererContext): OverlayContentRect[] {
}
}
if (ctx.dom.overlayNotificationStack?.children.length > 0) {
const notificationRect = toMeasuredRect(
ctx.dom.overlayNotificationStack.getBoundingClientRect(),
);
if (notificationRect && hasArea(notificationRect)) {
rects.push(notificationRect);
}
}
if (ctx.state?.notificationHistoryOpen) {
const historyRect = toMeasuredRect(ctx.dom.overlayNotificationHistory.getBoundingClientRect());
if (historyRect && hasArea(historyRect)) {
rects.push(historyRect);
}
}
return rects;
}
+5 -1
View File
@@ -29,7 +29,11 @@ export function syncOverlayMouseIgnoreState(ctx: RendererContext): void {
const shouldKeepWindowInteractive =
isYomitanPopupInteractionActive(ctx.state) || isBlockingOverlayModalOpen(ctx.state);
const shouldStayInteractive =
ctx.state.isOverSubtitle || ctx.state.isOverSubtitleSidebar || shouldKeepWindowInteractive;
ctx.state.isOverSubtitle ||
ctx.state.isOverSubtitleSidebar ||
ctx.state.isOverOverlayNotification ||
ctx.state.isOverNotificationHistory ||
shouldKeepWindowInteractive;
const shouldMarkOverlayInteractive = ctx.platform?.isLinuxPlatform
? shouldKeepWindowInteractive
: shouldStayInteractive;
@@ -0,0 +1,415 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { OverlayNotificationEntry } from './overlay-notifications';
import {
createOverlayNotificationHistoryPanel,
createOverlayNotificationHistoryStore,
resolveHistorySideFromStack,
} from './overlay-notification-history';
function entry(
overrides: Partial<OverlayNotificationEntry> & { id: string },
): OverlayNotificationEntry {
return {
title: overrides.title ?? overrides.id,
persistent: false,
createdAt: 0,
...overrides,
};
}
test('history store lists newest entries first', () => {
const store = createOverlayNotificationHistoryStore();
store.record(entry({ id: 'a', title: 'A' }));
store.record(entry({ id: 'b', title: 'B' }));
store.record(entry({ id: 'c', title: 'C' }));
assert.deepEqual(
store.list().map((item) => item.id),
['c', 'b', 'a'],
);
assert.equal(store.size(), 3);
});
test('history store updates an entry in place without reordering or duplicating', () => {
let clock = 100;
const store = createOverlayNotificationHistoryStore({ now: () => clock });
store.record(entry({ id: 'job', title: 'Working', body: 'Step 1', variant: 'progress' }));
store.record(entry({ id: 'other', title: 'Other' }));
clock = 200;
store.record(entry({ id: 'job', title: 'Done', body: 'Step 2', variant: 'success' }));
const list = store.list();
assert.equal(store.size(), 2);
// Newest-first ordering is by first-seen; the in-place update keeps 'other' on top.
assert.deepEqual(
list.map((item) => item.id),
['other', 'job'],
);
const job = list.find((item) => item.id === 'job');
assert.equal(job?.title, 'Done');
assert.equal(job?.body, 'Step 2');
assert.equal(job?.variant, 'success');
assert.equal(job?.createdAt, 100);
assert.equal(job?.updatedAt, 200);
});
test('history store keeps same live notification id when history ids differ', () => {
const store = createOverlayNotificationHistoryStore();
store.record(
entry({
id: 'character-dictionary-auto-sync',
title: 'Character dictionary',
body: 'Checking character dictionary...',
variant: 'progress',
historyId: 'character-dictionary-auto-sync-checking',
}),
);
store.record(
entry({
id: 'character-dictionary-auto-sync',
title: 'Character dictionary',
body: 'Building character dictionary...',
variant: 'progress',
historyId: 'character-dictionary-auto-sync-building',
}),
);
store.record(
entry({
id: 'character-dictionary-auto-sync',
title: 'Character dictionary',
body: 'Character dictionary ready',
variant: 'success',
historyId: 'character-dictionary-auto-sync-ready',
}),
);
assert.deepEqual(
store.list().map((item) => `${item.id}:${item.body}`),
[
'character-dictionary-auto-sync-ready:Character dictionary ready',
'character-dictionary-auto-sync-building:Building character dictionary...',
'character-dictionary-auto-sync-checking:Checking character dictionary...',
],
);
});
test('history store removes and clears entries', () => {
const store = createOverlayNotificationHistoryStore();
store.record(entry({ id: 'a' }));
store.record(entry({ id: 'b' }));
store.remove('a');
assert.deepEqual(
store.list().map((item) => item.id),
['b'],
);
store.clear();
assert.equal(store.size(), 0);
assert.deepEqual(store.list(), []);
});
test('history store caps to max and drops the oldest entries', () => {
const store = createOverlayNotificationHistoryStore({ max: 2 });
store.record(entry({ id: 'a' }));
store.record(entry({ id: 'b' }));
store.record(entry({ id: 'c' }));
assert.equal(store.size(), 2);
assert.deepEqual(
store.list().map((item) => item.id),
['c', 'b'],
);
});
test('history store defaults missing variant to info', () => {
const store = createOverlayNotificationHistoryStore();
store.record(entry({ id: 'a' }));
assert.equal(store.list()[0]?.variant, 'info');
});
test('history store preserves notification actions', () => {
const store = createOverlayNotificationHistoryStore();
store.record(
entry({
id: 'anki-update-progress',
title: 'Anki Card Updated',
actions: [{ id: 'open-anki-card', label: 'Open in Anki', noteId: 42 }],
}),
);
assert.deepEqual(store.list()[0]?.actions, [
{ id: 'open-anki-card', label: 'Open in Anki', noteId: 42 },
]);
});
test('panel side mirrors the notification stack position', () => {
const stackWith = (positionClass: string) =>
({ classList: { contains: (token: string) => token === positionClass } }) as unknown as Element;
assert.equal(resolveHistorySideFromStack(stackWith('position-top-left')), 'left');
assert.equal(resolveHistorySideFromStack(stackWith('position-top-right')), 'right');
// Center notifications open the panel from the right.
assert.equal(resolveHistorySideFromStack(stackWith('position-top')), 'right');
});
function createClassList(initialTokens: string[] = []) {
const tokens = new Set(initialTokens);
return {
add: (...entries: string[]) => {
for (const entry of entries) tokens.add(entry);
},
remove: (...entries: string[]) => {
for (const entry of entries) tokens.delete(entry);
},
contains: (entry: string) => tokens.has(entry),
toggle: (entry: string, force?: boolean) => {
if (force === true) tokens.add(entry);
else if (force === false) tokens.delete(entry);
else if (tokens.has(entry)) tokens.delete(entry);
else tokens.add(entry);
},
};
}
type FakeElement = {
tagName: string;
className: string;
textContent: string;
type: string;
dataset: Record<string, string>;
children: FakeElement[];
classList: ReturnType<typeof createClassList>;
append: (...children: FakeElement[]) => void;
replaceChildren: (...children: FakeElement[]) => void;
setAttribute: (name: string, value: string) => void;
addEventListener: (type: string, listener: () => void) => void;
dispatchEventType: (type: string) => void;
};
function createFakeElement(tagName = 'div'): FakeElement {
const listeners = new Map<string, Array<() => void>>();
const element: FakeElement = {
tagName: tagName.toUpperCase(),
className: '',
textContent: '',
type: '',
dataset: {},
children: [],
classList: createClassList(),
append: (...children) => {
element.children.push(...children);
},
replaceChildren: (...children) => {
element.children = [...children];
},
setAttribute: () => undefined,
addEventListener: (type, listener) => {
listeners.set(type, [...(listeners.get(type) ?? []), listener]);
},
dispatchEventType: (type) => {
for (const listener of listeners.get(type) ?? []) listener();
},
};
return element;
}
function findChildByClass(element: FakeElement, className: string): FakeElement | null {
if (element.className.split(/\s+/).includes(className)) {
return element;
}
for (const child of element.children) {
const match = findChildByClass(child, className);
if (match) return match;
}
return null;
}
function createPanelHarness(stackPositionClass: string) {
const stack = {
classList: createClassList([stackPositionClass]),
};
const clearButton = {
disabled: false,
addEventListener: () => undefined,
};
const closeButton = {
addEventListener: () => undefined,
};
const list = {
replaceChildren: () => undefined,
};
const empty = {
classList: createClassList(),
};
const panel = {
classList: createClassList(['notification-history', 'side-right']),
setAttribute: () => undefined,
addEventListener: () => undefined,
querySelector: (selector: string) => {
switch (selector) {
case '.notification-history-list':
return list;
case '.notification-history-empty':
return empty;
case '.notification-history-clear':
return clearButton;
case '.notification-history-close':
return closeButton;
default:
return null;
}
},
};
const controller = createOverlayNotificationHistoryPanel({
dom: {
overlay: createFakeElement(),
overlayNotificationHistory: panel,
overlayNotificationStack: stack,
},
state: {
isOverNotificationHistory: false,
notificationHistoryOpen: false,
},
platform: {
shouldToggleMouseIgnore: false,
},
} as never);
return { controller, panel, stack };
}
test('history panel applies the initial stack side while still closed', () => {
const { panel } = createPanelHarness('position-top-left');
assert.equal(panel.classList.contains('side-left'), true);
assert.equal(panel.classList.contains('side-right'), false);
assert.equal(panel.classList.contains('open'), false);
});
test('history panel resyncs the closed side before first open', () => {
const { controller, panel, stack } = createPanelHarness('position-top-right');
stack.classList.remove('position-top-right');
stack.classList.add('position-top-left');
const syncable = controller as unknown as { syncSide?: () => void };
assert.equal(typeof syncable.syncSide, 'function');
syncable.syncSide?.();
assert.equal(panel.classList.contains('side-left'), true);
assert.equal(panel.classList.contains('side-right'), false);
assert.equal(panel.classList.contains('open'), false);
});
test('history panel action buttons send action ids and note ids', () => {
const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const renderedItems: FakeElement[] = [];
const sentActions: Array<{ notificationId: string; actionId: string; noteId?: number }> = [];
const stack = {
classList: createClassList(['position-top-right']),
};
const clearButton = createFakeElement('button');
const closeButton = createFakeElement('button');
const list = {
replaceChildren: (...children: FakeElement[]) => {
renderedItems.splice(0, renderedItems.length, ...children);
},
};
const empty = createFakeElement();
const panel = {
classList: createClassList(['notification-history', 'side-right']),
setAttribute: () => undefined,
addEventListener: () => undefined,
querySelector: (selector: string) => {
switch (selector) {
case '.notification-history-list':
return list;
case '.notification-history-empty':
return empty;
case '.notification-history-clear':
return clearButton;
case '.notification-history-close':
return closeButton;
default:
return null;
}
},
};
Object.defineProperty(globalThis, 'document', {
configurable: true,
writable: true,
value: {
createElement: (tagName: string) => createFakeElement(tagName),
},
});
Object.defineProperty(globalThis, 'window', {
configurable: true,
writable: true,
value: {
electronAPI: {
sendOverlayNotificationAction: (
notificationId: string,
actionId: string,
options?: { noteId?: number },
) => {
sentActions.push({ notificationId, actionId, noteId: options?.noteId });
},
},
},
});
try {
const controller = createOverlayNotificationHistoryPanel({
dom: {
overlay: createFakeElement(),
overlayNotificationHistory: panel,
overlayNotificationStack: stack,
},
state: {
isOverNotificationHistory: false,
notificationHistoryOpen: false,
},
platform: {
shouldToggleMouseIgnore: false,
},
} as never);
controller.record(
entry({
id: 'anki-update-progress',
title: 'Anki Card Updated',
actions: [{ id: 'open-anki-card', label: 'Open in Anki', noteId: 42 }],
}),
);
controller.open();
const button = renderedItems[0]
? findChildByClass(renderedItems[0], 'notification-history-action')
: null;
if (!button) {
assert.fail('Expected notification history action button.');
}
button.dispatchEventType('click');
assert.deepEqual(sentActions, [
{ notificationId: 'anki-update-progress', actionId: 'open-anki-card', noteId: 42 },
]);
} finally {
if (originalDocument) {
Object.defineProperty(globalThis, 'document', originalDocument);
} else {
delete (globalThis as { document?: unknown }).document;
}
if (originalWindow) {
Object.defineProperty(globalThis, 'window', originalWindow);
} else {
delete (globalThis as { window?: unknown }).window;
}
}
});
@@ -0,0 +1,265 @@
import type { OverlayNotificationAction, OverlayNotificationVariant } from '../types';
import type { RendererContext } from './context';
import type { OverlayNotificationEntry } from './overlay-notifications.js';
import { syncOverlayMouseIgnoreState } from './overlay-mouse-ignore.js';
export const DEFAULT_OVERLAY_NOTIFICATION_HISTORY_MAX = 200;
const OVERLAY_NOTIFICATION_HISTORY_VARIANT_CLASSES = [
'info',
'progress',
'success',
'warning',
'error',
] as const;
export type OverlayNotificationHistoryEntry = {
id: string;
title: string;
body?: string;
image?: string;
variant: OverlayNotificationVariant;
actions?: OverlayNotificationAction[];
createdAt: number;
updatedAt: number;
};
export type OverlayNotificationHistoryStoreOptions = {
max?: number;
now?: () => number;
};
function normalizeVariant(
variant: OverlayNotificationVariant | undefined,
): OverlayNotificationVariant {
return variant ?? 'info';
}
/**
* Session-scoped log of every overlay notification that was shown. Entries are keyed by historyId
* when provided, otherwise by live notification id. Reusing a key updates the record in place;
* distinct history keys preserve separate visible events. Ordering is by first-seen so the panel can
* render newest-first.
*/
export function createOverlayNotificationHistoryStore(
options: OverlayNotificationHistoryStoreOptions = {},
) {
const max = Math.max(1, options.max ?? DEFAULT_OVERLAY_NOTIFICATION_HISTORY_MAX);
const now = options.now ?? (() => Date.now());
const entries = new Map<string, OverlayNotificationHistoryEntry>();
function record(entry: OverlayNotificationEntry): OverlayNotificationHistoryEntry {
const timestamp = now();
const historyId = entry.historyId?.trim() || entry.id;
const existing = entries.get(historyId);
const next: OverlayNotificationHistoryEntry = {
id: historyId,
title: entry.title,
body: entry.body,
image: entry.image,
variant: normalizeVariant(entry.variant),
actions: entry.actions?.map((action) => ({ ...action })),
createdAt: existing?.createdAt ?? timestamp,
updatedAt: timestamp,
};
// Setting an existing key keeps its original insertion slot, so an in-place update (same id,
// new body) refreshes content without jumping the entry to the top of the panel.
entries.set(historyId, next);
while (entries.size > max) {
const oldest = entries.keys().next().value;
if (oldest === undefined) break;
entries.delete(oldest);
}
return next;
}
function remove(id: string): void {
entries.delete(id);
}
function clear(): void {
entries.clear();
}
function list(): OverlayNotificationHistoryEntry[] {
// Newest first.
return [...entries.values()].reverse();
}
function size(): number {
return entries.size;
}
return { record, remove, clear, list, size };
}
export type OverlayNotificationHistorySide = 'left' | 'right';
/**
* The history panel slides in from the same edge the notifications use: left when notifications are
* top-left, right otherwise (including center). We read the live position class off the notification
* stack so the panel always tracks the configured/last-used position.
*/
export function resolveHistorySideFromStack(stack: Element): OverlayNotificationHistorySide {
return stack.classList.contains('position-top-left') ? 'left' : 'right';
}
export function createOverlayNotificationHistoryPanel(
ctx: RendererContext,
options: { onChanged?: () => void } = {},
) {
const store = createOverlayNotificationHistoryStore();
const panel = ctx.dom.overlayNotificationHistory;
const list = panel.querySelector<HTMLUListElement>('.notification-history-list');
const empty = panel.querySelector<HTMLElement>('.notification-history-empty');
const clearButton = panel.querySelector<HTMLButtonElement>('.notification-history-clear');
const closeButton = panel.querySelector<HTMLButtonElement>('.notification-history-close');
let open = false;
function setInteractive(value: boolean): void {
ctx.state.isOverNotificationHistory = value;
syncOverlayMouseIgnoreState(ctx);
}
function applySide(): void {
const side = resolveHistorySideFromStack(ctx.dom.overlayNotificationStack);
panel.classList.toggle('side-left', side === 'left');
panel.classList.toggle('side-right', side === 'right');
}
function formatTime(timestamp: number): string {
try {
return new Date(timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
} catch {
return '';
}
}
function buildItem(entry: OverlayNotificationHistoryEntry): HTMLLIElement {
const item = document.createElement('li');
item.className = 'notification-history-item';
for (const variant of OVERLAY_NOTIFICATION_HISTORY_VARIANT_CLASSES) {
item.classList.toggle(variant, variant === entry.variant);
}
item.dataset.notificationId = entry.id;
const trimmedImage = entry.image?.trim();
const leading = trimmedImage ? document.createElement('img') : document.createElement('span');
leading.className = trimmedImage ? 'notification-history-thumb' : 'notification-history-icon';
leading.setAttribute('aria-hidden', 'true');
if (trimmedImage) {
const image = leading as HTMLImageElement;
image.src = trimmedImage;
image.alt = '';
image.decoding = 'async';
}
const content = document.createElement('div');
content.className = 'notification-history-content';
const title = document.createElement('div');
title.className = 'notification-history-item-title';
title.textContent = entry.title;
content.append(title);
if (entry.body && entry.body.trim().length > 0) {
const body = document.createElement('div');
body.className = 'notification-history-item-body';
body.textContent = entry.body;
content.append(body);
}
const time = document.createElement('time');
time.className = 'notification-history-time';
time.dateTime = new Date(entry.createdAt).toISOString();
time.textContent = formatTime(entry.createdAt);
content.append(time);
if (entry.actions && entry.actions.length > 0) {
const actions = document.createElement('div');
actions.className = 'notification-history-actions';
for (const action of entry.actions) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'notification-history-action';
button.textContent = action.label;
button.addEventListener('click', () => {
window.electronAPI.sendOverlayNotificationAction?.(entry.id, action.id, {
noteId: action.noteId,
});
});
actions.append(button);
}
content.append(actions);
}
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'notification-history-remove';
remove.setAttribute('aria-label', 'Remove from history');
remove.textContent = '×';
remove.addEventListener('click', () => {
store.remove(entry.id);
render();
});
item.append(leading, content, remove);
return item;
}
function render(): void {
if (!list || !empty) return;
const entries = store.list();
list.replaceChildren(...entries.map(buildItem));
empty.classList.toggle('hidden', entries.length > 0);
if (clearButton) clearButton.disabled = entries.length === 0;
options.onChanged?.();
}
function setOpen(next: boolean): void {
if (open === next) return;
open = next;
ctx.state.notificationHistoryOpen = next;
if (next) {
applySide();
render();
}
panel.classList.toggle('open', next);
panel.setAttribute('aria-hidden', next ? 'false' : 'true');
setInteractive(next);
options.onChanged?.();
}
clearButton?.addEventListener('click', () => {
store.clear();
render();
});
closeButton?.addEventListener('click', () => setOpen(false));
panel.addEventListener('mouseenter', () => {
if (open) setInteractive(true);
});
panel.addEventListener('mouseleave', () => setInteractive(false));
applySide();
function record(entry: OverlayNotificationEntry): void {
store.record(entry);
if (open) render();
}
function toggle(): void {
setOpen(!open);
}
return {
record,
toggle,
open: () => setOpen(true),
close: () => setOpen(false),
isOpen: () => open,
syncSide: applySide,
};
}
@@ -0,0 +1,245 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createOverlayNotificationRenderer } from './overlay-notifications';
function createClassList() {
const tokens = new Set<string>();
return {
add: (...entries: string[]) => {
for (const entry of entries) tokens.add(entry);
},
remove: (...entries: string[]) => {
for (const entry of entries) tokens.delete(entry);
},
contains: (entry: string) => tokens.has(entry),
toggle: (entry: string, force?: boolean) => {
if (force === true) tokens.add(entry);
else if (force === false) tokens.delete(entry);
else if (tokens.has(entry)) tokens.delete(entry);
else tokens.add(entry);
},
};
}
type FakeElement = {
tagName: string;
className: string;
textContent: string;
type: string;
dataset: Record<string, string>;
children: FakeElement[];
classList: ReturnType<typeof createClassList>;
append: (...children: FakeElement[]) => void;
replaceChildren: (...children: FakeElement[]) => void;
remove: () => void;
setAttribute: (name: string, value: string) => void;
addEventListener: (type: string, listener: (event?: unknown) => void) => void;
dispatchEventType: (type: string, event?: unknown) => void;
};
function createFakeElement(tagName = 'div'): FakeElement {
const listeners = new Map<string, Array<(event?: unknown) => void>>();
const element: FakeElement = {
tagName: tagName.toUpperCase(),
className: '',
textContent: '',
type: '',
dataset: {},
children: [],
classList: createClassList(),
append: (...children) => {
for (const child of children) {
const existingIndex = element.children.indexOf(child);
if (existingIndex >= 0) {
element.children.splice(existingIndex, 1);
}
element.children.push(child);
}
},
replaceChildren: (...children) => {
element.children = [...children];
},
setAttribute: () => undefined,
remove: () => undefined,
addEventListener: (type, listener) => {
listeners.set(type, [...(listeners.get(type) ?? []), listener]);
},
dispatchEventType: (type, event) => {
for (const listener of listeners.get(type) ?? []) listener(event);
},
};
return element;
}
function findChildByClass(element: FakeElement, className: string): FakeElement | null {
if (element.className.split(/\s+/).includes(className)) {
return element;
}
for (const child of element.children) {
const match = findChildByClass(child, className);
if (match) return match;
}
return null;
}
function createHoverContext(stack: FakeElement, ignoreCalls: Array<{ ignore: boolean }>) {
return {
dom: {
overlay: { classList: createClassList() },
overlayNotificationStack: stack,
},
platform: {
shouldToggleMouseIgnore: true,
},
state: {
isOverSubtitle: false,
isOverSubtitleSidebar: false,
isOverOverlayNotification: false,
isOverNotificationHistory: false,
yomitanPopupVisible: false,
controllerSelectModalOpen: false,
controllerDebugModalOpen: false,
jimakuModalOpen: false,
youtubePickerModalOpen: false,
kikuModalOpen: false,
runtimeOptionsModalOpen: false,
subsyncModalOpen: false,
sessionHelpModalOpen: false,
},
};
}
function installDomGlobals(ignoreCalls: Array<{ ignore: boolean }>): () => void {
const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
Object.defineProperty(globalThis, 'document', {
configurable: true,
writable: true,
value: {
createElement: (tagName: string) => createFakeElement(tagName),
querySelectorAll: () => [],
},
});
Object.defineProperty(globalThis, 'window', {
configurable: true,
writable: true,
value: {
clearTimeout: () => undefined,
setTimeout: () => 1,
electronAPI: {
setIgnoreMouseEvents: (ignore: boolean) => {
ignoreCalls.push({ ignore });
},
},
},
});
return () => {
if (originalDocument) {
Object.defineProperty(globalThis, 'document', originalDocument);
} else {
delete (globalThis as { document?: unknown }).document;
}
if (originalWindow) {
Object.defineProperty(globalThis, 'window', originalWindow);
} else {
delete (globalThis as { window?: unknown }).window;
}
};
}
test('passive overlay notification hover stays click-through on macOS passthrough overlays', () => {
const stack = createFakeElement();
const ignoreCalls: Array<{ ignore: boolean }> = [];
const ctx = createHoverContext(stack, ignoreCalls);
const restore = installDomGlobals(ignoreCalls);
try {
const renderer = createOverlayNotificationRenderer(ctx as never);
renderer.show({
id: 'character-dictionary-auto-sync',
title: 'Character dictionary',
body: 'Building character dictionary...',
variant: 'progress',
persistent: true,
});
stack.dispatchEventType('mouseenter');
assert.equal(ctx.state.isOverOverlayNotification, false);
assert.deepEqual(ignoreCalls, []);
const card = stack.children[0];
const close = card ? findChildByClass(card, 'overlay-notification-close') : null;
if (!close) {
assert.fail('Expected overlay notification close button.');
}
close.dispatchEventType('mouseenter');
assert.equal(ctx.state.isOverOverlayNotification, false);
assert.deepEqual(ignoreCalls, []);
} finally {
restore();
}
});
test('overlay notification controls become interactive on hover', () => {
const stack = createFakeElement();
const ignoreCalls: Array<{ ignore: boolean }> = [];
const ctx = createHoverContext(stack, ignoreCalls);
const restore = installDomGlobals(ignoreCalls);
try {
const renderer = createOverlayNotificationRenderer(ctx as never);
renderer.show({
id: 'mined-card',
title: 'Card created',
body: 'Added sentence card',
actions: [{ id: 'open-anki-card', label: 'Open in Anki', noteId: 42 }],
persistent: true,
});
const card = stack.children[0];
const action = card ? findChildByClass(card, 'overlay-notification-action') : null;
if (!action) {
assert.fail('Expected overlay notification action.');
}
action.dispatchEventType('mouseenter');
assert.equal(ctx.state.isOverOverlayNotification, true);
assert.deepEqual(ignoreCalls, [{ ignore: false }]);
action.dispatchEventType('mouseleave');
assert.equal(ctx.state.isOverOverlayNotification, false);
assert.deepEqual(ignoreCalls, [{ ignore: false }, { ignore: true }]);
} finally {
restore();
}
});
test('action overlay notification stack hover keeps card controls interactive', () => {
const stack = createFakeElement();
const ignoreCalls: Array<{ ignore: boolean }> = [];
const ctx = createHoverContext(stack, ignoreCalls);
const restore = installDomGlobals(ignoreCalls);
try {
const renderer = createOverlayNotificationRenderer(ctx as never);
renderer.show({
id: 'anki-card-updated',
title: 'Anki Card Updated',
body: 'Updated card: 食べる',
persistent: true,
actions: [{ id: 'open-anki-card', label: 'Open in Anki', noteId: 42 }],
});
stack.dispatchEventType('mouseenter');
assert.equal(ctx.state.isOverOverlayNotification, true);
assert.deepEqual(ignoreCalls, [{ ignore: false }]);
} finally {
restore();
}
});
+415
View File
@@ -0,0 +1,415 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import {
createOverlayNotificationRenderer,
createOverlayNotificationStore,
handleOverlayNotificationEvent,
overlayNotificationPositionClass,
} from './overlay-notifications';
function createClassList(initialTokens: string[] = []) {
const tokens = new Set(initialTokens);
return {
add: (...entries: string[]) => {
for (const entry of entries) tokens.add(entry);
},
remove: (...entries: string[]) => {
for (const entry of entries) tokens.delete(entry);
},
contains: (entry: string) => tokens.has(entry),
toggle: (entry: string, force?: boolean) => {
if (force === true) tokens.add(entry);
else if (force === false) tokens.delete(entry);
else if (tokens.has(entry)) tokens.delete(entry);
else tokens.add(entry);
},
};
}
type FakeElement = {
tagName: string;
className: string;
textContent: string;
src: string;
alt: string;
type: string;
dataset: Record<string, string>;
children: FakeElement[];
classList: ReturnType<typeof createClassList>;
appendCalls: number;
replaceChildrenCalls: number;
append: (...children: FakeElement[]) => void;
replaceChildren: (...children: FakeElement[]) => void;
remove: () => void;
setAttribute: (name: string, value: string) => void;
getAttribute: (name: string) => string | null;
addEventListener: (type: string, listener: (event?: unknown) => void) => void;
dispatchEventType: (type: string, event?: unknown) => void;
};
function createFakeElement(tagName = 'div'): FakeElement {
const attributes = new Map<string, string>();
const listeners = new Map<string, Array<(event?: unknown) => void>>();
const element: FakeElement = {
tagName: tagName.toUpperCase(),
className: '',
textContent: '',
src: '',
alt: '',
type: '',
dataset: {},
children: [],
classList: createClassList(),
appendCalls: 0,
replaceChildrenCalls: 0,
append: (...children) => {
element.appendCalls += 1;
for (const child of children) {
const existingIndex = element.children.indexOf(child);
if (existingIndex >= 0) {
element.children.splice(existingIndex, 1);
}
element.children.push(child);
}
},
replaceChildren: (...children) => {
element.replaceChildrenCalls += 1;
element.children = [...children];
},
setAttribute: (name, value) => {
attributes.set(name, value);
},
getAttribute: (name) => attributes.get(name) ?? null,
remove: () => undefined,
addEventListener: (type, listener) => {
listeners.set(type, [...(listeners.get(type) ?? []), listener]);
},
dispatchEventType: (type, event) => {
for (const listener of listeners.get(type) ?? []) listener(event);
},
};
return element;
}
function findChildByClass(element: FakeElement, className: string): FakeElement | null {
if (element.className.split(/\s+/).includes(className)) {
return element;
}
for (const child of element.children) {
const match = findChildByClass(child, className);
if (match) return match;
}
return null;
}
const overlayNotificationCss = readFileSync(
path.join(__dirname, '..', 'renderer', 'style.css'),
'utf8',
);
test('overlay notification store caps transient notifications and keeps pinned jobs visible', () => {
const store = createOverlayNotificationStore({ maxVisible: 3 });
store.upsert({
id: 'character-dictionary-auto-sync',
title: 'Character dictionary',
body: 'Generating character dictionary',
persistent: true,
});
store.upsert({ id: 'one', title: 'One', body: 'First' });
store.upsert({ id: 'two', title: 'Two', body: 'Second' });
store.upsert({ id: 'three', title: 'Three', body: 'Third' });
assert.deepEqual(
store.visible().map((entry) => entry.id),
['character-dictionary-auto-sync', 'two', 'three'],
);
store.upsert({
id: 'character-dictionary-auto-sync',
title: 'Character dictionary',
body: 'Ready',
persistent: false,
});
assert.deepEqual(
store.visible().map((entry) => `${entry.id}:${entry.body}`),
['two:Second', 'three:Third', 'character-dictionary-auto-sync:Ready'],
);
});
test('overlay notification positions map to stack alignment classes', () => {
assert.equal(overlayNotificationPositionClass(undefined), 'position-top-right');
assert.equal(overlayNotificationPositionClass('top-left'), 'position-top-left');
assert.equal(overlayNotificationPositionClass('top'), 'position-top');
assert.equal(overlayNotificationPositionClass('top-right'), 'position-top-right');
});
test('overlay notification event handler dismisses notifications by id', () => {
const calls: string[] = [];
handleOverlayNotificationEvent(
{
show: (payload) => {
calls.push(`show:${payload.id ?? ''}:${payload.title}`);
return payload.id ?? '';
},
remove: (id) => {
calls.push(`remove:${id}`);
},
},
{ id: 'overlay-loading-status', dismiss: true },
);
assert.deepEqual(calls, ['remove:overlay-loading-status']);
});
test('overlay notification renderer shows thumbnail image from payload', () => {
const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
const stack = createFakeElement();
Object.defineProperty(globalThis, 'document', {
configurable: true,
writable: true,
value: {
createElement: (tagName: string) => createFakeElement(tagName),
},
});
try {
const renderer = createOverlayNotificationRenderer({
dom: {
overlayNotificationStack: stack,
},
state: {
isOverOverlayNotification: false,
},
} as never);
renderer.show({
title: 'Anki Card Updated',
body: 'Updated card: 食べる',
image: 'file:///tmp/subminer-notification-icon.png',
variant: 'success',
persistent: true,
});
const card = stack.children[0];
if (!card) {
assert.fail('Expected overlay notification card.');
}
const image = findChildByClass(card, 'overlay-notification-image');
if (!image) {
assert.fail('Expected overlay notification image.');
}
assert.equal(image.tagName, 'IMG');
assert.equal(image.src, 'file:///tmp/subminer-notification-icon.png');
assert.equal(image.alt, '');
} finally {
if (originalDocument) {
Object.defineProperty(globalThis, 'document', originalDocument);
} else {
delete (globalThis as { document?: unknown }).document;
}
}
});
test('overlay notification action buttons send action ids', () => {
const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const stack = createFakeElement();
const sentActions: Array<{ notificationId: string; actionId: string; noteId?: number }> = [];
Object.defineProperty(globalThis, 'document', {
configurable: true,
writable: true,
value: {
createElement: (tagName: string) => createFakeElement(tagName),
},
});
Object.defineProperty(globalThis, 'window', {
configurable: true,
writable: true,
value: {
clearTimeout: () => undefined,
setTimeout: () => {
return 1;
},
electronAPI: {
sendOverlayNotificationAction: (
notificationId: string,
actionId: string,
options?: { noteId?: number },
) => {
sentActions.push({ notificationId, actionId, noteId: options?.noteId });
},
},
},
});
try {
const renderer = createOverlayNotificationRenderer({
dom: {
overlayNotificationStack: stack,
},
state: {
isOverOverlayNotification: false,
},
} as never);
renderer.show({
id: 'subminer-update-available',
title: 'SubMiner update available',
body: 'SubMiner v0.15.0 is available',
persistent: true,
actions: [{ id: 'open-anki-card', label: 'Open in Anki', noteId: 42 }],
});
const card = stack.children[0];
if (!card) {
assert.fail('Expected overlay notification card.');
}
const button = findChildByClass(card, 'overlay-notification-action');
if (!button) {
assert.fail('Expected overlay notification action button.');
}
button.dispatchEventType('click');
assert.deepEqual(sentActions, [
{ notificationId: 'subminer-update-available', actionId: 'open-anki-card', noteId: 42 },
]);
} finally {
if (originalDocument) {
Object.defineProperty(globalThis, 'document', originalDocument);
} else {
delete (globalThis as { document?: unknown }).document;
}
if (originalWindow) {
Object.defineProperty(globalThis, 'window', originalWindow);
} else {
delete (globalThis as { window?: unknown }).window;
}
}
});
test('overlay notification renderer updates same-id progress without replacing the spinner', () => {
const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const stack = createFakeElement();
Object.defineProperty(globalThis, 'document', {
configurable: true,
writable: true,
value: {
createElement: (tagName: string) => createFakeElement(tagName),
},
});
Object.defineProperty(globalThis, 'window', {
configurable: true,
writable: true,
value: {
clearTimeout: () => undefined,
setTimeout: () => {
return 1;
},
},
});
try {
const renderer = createOverlayNotificationRenderer({
dom: {
overlayNotificationStack: stack,
},
state: {
isOverOverlayNotification: false,
},
} as never);
renderer.show({
id: 'subsync-status',
title: 'Subsync',
body: 'Subsync: syncing |',
variant: 'progress',
persistent: true,
});
const card = stack.children[0];
if (!card) {
assert.fail('Expected overlay notification card.');
}
assert.equal(stack.appendCalls, 1);
assert.equal(card.classList.contains('entering'), true);
const spinner = findChildByClass(card, 'overlay-notification-icon');
if (!spinner) {
assert.fail('Expected overlay notification spinner.');
}
const cardReplacements = card.replaceChildrenCalls;
renderer.show({
id: 'subsync-status',
title: 'Subsync',
body: 'Subsync: syncing /',
variant: 'progress',
persistent: true,
});
assert.equal(stack.children.length, 1);
assert.equal(stack.children[0], card);
assert.equal(stack.appendCalls, 1);
assert.equal(card.replaceChildrenCalls, cardReplacements);
assert.equal(findChildByClass(card, 'overlay-notification-icon'), spinner);
assert.equal(
findChildByClass(card, 'overlay-notification-body')?.textContent,
'Subsync: syncing /',
);
card.dispatchEventType('animationend', { animationName: 'overlay-notification-enter-right' });
assert.equal(card.classList.contains('entering'), false);
} finally {
if (originalDocument) {
Object.defineProperty(globalThis, 'document', originalDocument);
} else {
delete (globalThis as { document?: unknown }).document;
}
if (originalWindow) {
Object.defineProperty(globalThis, 'window', originalWindow);
} else {
delete (globalThis as { window?: unknown }).window;
}
}
});
test('overlay notification cards use larger display dimensions', () => {
assert.match(
overlayNotificationCss,
/\.overlay-notification-stack\s*\{[^}]*width:\s*min\(420px,\s*calc\(100vw - 32px\)\);/s,
);
assert.match(
overlayNotificationCss,
/\.overlay-notification-stack\s*\{[^}]*z-index:\s*2147483647\s*!important;/s,
);
assert.match(overlayNotificationCss, /\.overlay-notification-card\s*\{[^}]*min-height:\s*72px;/s);
assert.match(
overlayNotificationCss,
/\.overlay-notification-card\.has-image\s*\{[^}]*min-height:\s*88px;/s,
);
// The has-image card reserves a real grid track for the thumbnail so it
// cannot overlap the text, and the image shrinks to fit within that track.
assert.match(
overlayNotificationCss,
/\.overlay-notification-card\.has-image\s*\{[^}]*grid-template-columns:\s*minmax\(0,\s*100px\)\s+minmax\(0,\s*1fr\)\s+22px;/s,
);
assert.match(
overlayNotificationCss,
/\.overlay-notification-image\s*\{[^}]*max-width:\s*100px;/s,
);
assert.match(
overlayNotificationCss,
/\.overlay-notification-image\s*\{[^}]*aspect-ratio:\s*100 \/ 56;/s,
);
});
+434
View File
@@ -0,0 +1,434 @@
import type {
OverlayNotificationDismissPayload,
OverlayNotificationEventPayload,
OverlayNotificationPayload,
OverlayNotificationPosition,
OverlayNotificationVariant,
} from '../types';
import type { RendererContext } from './context';
import { syncOverlayMouseIgnoreState } from './overlay-mouse-ignore.js';
export const DEFAULT_OVERLAY_NOTIFICATION_TIMEOUT_MS = 3000;
export const DEFAULT_OVERLAY_NOTIFICATION_MAX_VISIBLE = 3;
export const DEFAULT_OVERLAY_NOTIFICATION_POSITION: OverlayNotificationPosition = 'top-right';
const OVERLAY_NOTIFICATION_POSITION_CLASSES = [
'position-top-left',
'position-top',
'position-top-right',
] as const;
const OVERLAY_NOTIFICATION_VARIANT_CLASSES = [
'info',
'progress',
'success',
'warning',
'error',
] as const;
// Matches the `.leaving` animation duration in style.css; the fallback timer guards
// against `animationend` never firing (e.g. element detached or reduced-motion).
const OVERLAY_NOTIFICATION_EXIT_FALLBACK_MS = 260;
export type OverlayNotificationEntry = Required<
Pick<OverlayNotificationPayload, 'id' | 'title' | 'persistent'>
> &
Omit<OverlayNotificationPayload, 'id' | 'title' | 'persistent'> & {
createdAt: number;
};
export type OverlayNotificationStoreOptions = {
maxVisible?: number;
now?: () => number;
};
export type OverlayNotificationController = {
show: (payload: OverlayNotificationPayload) => string;
remove: (id: string) => void;
};
export function createOverlayNotificationStore(options: OverlayNotificationStoreOptions = {}) {
const maxVisible = Math.max(1, options.maxVisible ?? DEFAULT_OVERLAY_NOTIFICATION_MAX_VISIBLE);
const now = options.now ?? (() => Date.now());
const entries: OverlayNotificationEntry[] = [];
let nextId = 0;
function visible(): OverlayNotificationEntry[] {
const pinned = entries.filter((entry) => entry.persistent);
const transientSlots = Math.max(0, maxVisible - pinned.length);
const transient =
transientSlots === 0
? []
: entries.filter((entry) => !entry.persistent).slice(-transientSlots);
return [...pinned, ...transient];
}
function pruneHiddenTransient(): void {
const visibleIds = new Set(visible().map((entry) => entry.id));
for (let index = entries.length - 1; index >= 0; index -= 1) {
const entry = entries[index];
if (!entry) continue;
if (!entry.persistent && !visibleIds.has(entry.id)) {
entries.splice(index, 1);
}
}
}
function upsert(payload: OverlayNotificationPayload): OverlayNotificationEntry {
const id = payload.id ?? `overlay-notification-${nextId++}`;
const existingIndex = entries.findIndex((entry) => entry.id === id);
if (existingIndex >= 0) {
entries.splice(existingIndex, 1);
}
const entry: OverlayNotificationEntry = {
...payload,
id,
title: payload.title,
persistent: Boolean(payload.persistent),
createdAt: now(),
};
entries.push(entry);
pruneHiddenTransient();
return entry;
}
function remove(id: string): void {
const index = entries.findIndex((entry) => entry.id === id);
if (index >= 0) {
entries.splice(index, 1);
}
}
return {
upsert,
remove,
visible,
};
}
export function overlayNotificationPositionClass(
position: OverlayNotificationPosition | undefined,
): string {
return `position-${position ?? DEFAULT_OVERLAY_NOTIFICATION_POSITION}`;
}
function isOverlayNotificationDismissPayload(
payload: OverlayNotificationEventPayload,
): payload is OverlayNotificationDismissPayload {
return 'dismiss' in payload && payload.dismiss === true;
}
export function handleOverlayNotificationEvent(
controller: OverlayNotificationController,
payload: OverlayNotificationEventPayload,
): string | null {
if (isOverlayNotificationDismissPayload(payload)) {
controller.remove(payload.id);
return null;
}
return controller.show(payload);
}
function normalizeVariant(
variant: OverlayNotificationVariant | undefined,
): OverlayNotificationVariant {
return variant ?? 'info';
}
function normalizeImageSource(image: string | undefined): string | null {
if (!image) return null;
const trimmed = image.trim();
return trimmed.length > 0 ? trimmed : null;
}
function setInteractiveState(ctx: RendererContext, value: boolean): void {
ctx.state.isOverOverlayNotification = value;
syncOverlayMouseIgnoreState(ctx);
}
function hasElementClass(element: Element | undefined, className: string): boolean {
if (!element) return false;
const legacyClassName = (element as { className?: unknown }).className;
return (
element.classList.contains(className) ||
(typeof legacyClassName === 'string' && legacyClassName.split(/\s+/).includes(className))
);
}
function isNotificationCardIcon(element: Element | undefined): boolean {
return hasElementClass(element, 'overlay-notification-icon');
}
function isNotificationCardContent(element: Element | undefined): element is HTMLElement {
return hasElementClass(element, 'overlay-notification-content');
}
function isNotificationCardCloseButton(element: Element | undefined): boolean {
return hasElementClass(element, 'overlay-notification-close');
}
function hasExplicitNotificationActions(entry: OverlayNotificationEntry): boolean {
return (entry.actions?.length ?? 0) > 0;
}
export function createOverlayNotificationRenderer(
ctx: RendererContext,
options: { onChanged?: () => void; onShow?: (entry: OverlayNotificationEntry) => void } = {},
) {
const store = createOverlayNotificationStore();
const timers = new Map<string, number>();
// Live card elements keyed by notification id so re-renders reuse them: the enter
// animation only plays for freshly created cards instead of replaying on every render.
const cards = new Map<string, HTMLElement>();
const leaving = new Set<string>();
let position: OverlayNotificationPosition = DEFAULT_OVERLAY_NOTIFICATION_POSITION;
function clearTimer(id: string): void {
const timer = timers.get(id);
if (timer !== undefined) {
window.clearTimeout(timer);
timers.delete(id);
}
}
function commitExit(id: string, card: HTMLElement): void {
if (!leaving.has(id)) return;
leaving.delete(id);
cards.delete(id);
card.remove();
if (cards.size === 0) {
ctx.dom.overlayNotificationStack.classList.add('hidden');
setInteractiveState(ctx, false);
}
options.onChanged?.();
}
function beginExit(id: string, card: HTMLElement): void {
if (leaving.has(id)) return;
leaving.add(id);
card.classList.remove('entering');
card.classList.add('leaving');
const finalize = () => {
window.clearTimeout(fallback);
commitExit(id, card);
};
const fallback = window.setTimeout(finalize, OVERLAY_NOTIFICATION_EXIT_FALLBACK_MS);
card.addEventListener(
'animationend',
(event) => {
if ((event as AnimationEvent).animationName?.startsWith('overlay-notification-leave')) {
finalize();
}
},
{ once: true },
);
}
function markEnterComplete(card: HTMLElement): void {
card.classList.remove('entering');
}
function watchEnterAnimation(card: HTMLElement): void {
if (typeof window === 'undefined') {
return;
}
const fallback = window.setTimeout(() => markEnterComplete(card), 320);
card.addEventListener(
'animationend',
(event) => {
if ((event as AnimationEvent).animationName?.startsWith('overlay-notification-enter')) {
window.clearTimeout(fallback);
markEnterComplete(card);
}
},
{ once: true },
);
}
function appendCardIfNeeded(card: HTMLElement): void {
if (Array.prototype.includes.call(ctx.dom.overlayNotificationStack.children, card)) {
return;
}
ctx.dom.overlayNotificationStack.append(card);
}
function bindInteractiveControlHover(element: HTMLElement): void {
element.addEventListener('mouseenter', () => setInteractiveState(ctx, true));
element.addEventListener('mouseleave', () => setInteractiveState(ctx, false));
}
function remove(id: string): void {
clearTimer(id);
store.remove(id);
const card = cards.get(id);
if (card) {
beginExit(id, card);
} else {
render();
}
}
function populateContent(content: HTMLElement, entry: OverlayNotificationEntry): void {
content.className = 'overlay-notification-content';
const title = document.createElement('div');
title.className = 'overlay-notification-title';
title.textContent = entry.title;
const children: HTMLElement[] = [title];
if (entry.body && entry.body.trim().length > 0) {
const body = document.createElement('div');
body.className = 'overlay-notification-body';
body.textContent = entry.body;
children.push(body);
}
if (entry.actions && entry.actions.length > 0) {
const actions = document.createElement('div');
actions.className = 'overlay-notification-actions';
for (const action of entry.actions) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'overlay-notification-action';
button.textContent = action.label;
bindInteractiveControlHover(button);
button.addEventListener('click', () => {
window.electronAPI.sendOverlayNotificationAction?.(entry.id, action.id, {
noteId: action.noteId,
});
remove(entry.id);
});
actions.append(button);
}
children.push(actions);
}
content.replaceChildren(...children);
}
function createContent(entry: OverlayNotificationEntry): HTMLElement {
const content = document.createElement('div');
populateContent(content, entry);
return content;
}
function populateCard(card: HTMLElement, entry: OverlayNotificationEntry): void {
const imageSource = normalizeImageSource(entry.image);
card.classList.add('overlay-notification-card');
for (const variant of OVERLAY_NOTIFICATION_VARIANT_CLASSES) {
card.classList.toggle(variant, variant === normalizeVariant(entry.variant));
}
card.classList.toggle('has-image', Boolean(imageSource));
card.dataset.notificationId = entry.id;
card.setAttribute('role', 'status');
const leadingNode = card.children[0];
const contentNode = card.children[1];
const closeNode = card.children[2];
if (
leadingNode &&
contentNode &&
closeNode &&
!imageSource &&
!entry.actions?.length &&
isNotificationCardIcon(leadingNode) &&
isNotificationCardContent(contentNode) &&
isNotificationCardCloseButton(closeNode)
) {
populateContent(contentNode, entry);
return;
}
const leadingEl = imageSource ? document.createElement('img') : document.createElement('span');
leadingEl.className = imageSource ? 'overlay-notification-image' : 'overlay-notification-icon';
leadingEl.setAttribute('aria-hidden', 'true');
if (imageSource) {
const image = leadingEl as HTMLImageElement;
image.src = imageSource;
image.alt = '';
image.decoding = 'async';
}
const closeButton = document.createElement('button');
closeButton.type = 'button';
closeButton.className = 'overlay-notification-close';
closeButton.setAttribute('aria-label', 'Dismiss notification');
closeButton.textContent = '×';
if (hasExplicitNotificationActions(entry)) {
bindInteractiveControlHover(closeButton);
}
closeButton.addEventListener('click', () => remove(entry.id));
card.replaceChildren(leadingEl, createContent(entry), closeButton);
}
function render(): void {
const visible = store.visible();
const visibleIds = new Set(visible.map((entry) => entry.id));
const hasInteractiveCard = visible.some(hasExplicitNotificationActions);
ctx.dom.overlayNotificationStack.classList.toggle(
'hidden',
visible.length === 0 && leaving.size === 0,
);
ctx.dom.overlayNotificationStack.classList.remove(...OVERLAY_NOTIFICATION_POSITION_CLASSES);
ctx.dom.overlayNotificationStack.classList.add(overlayNotificationPositionClass(position));
// Cards that vanished from the store without an explicit remove() (e.g. pruned when
// over the visible cap) still need to animate out.
for (const [id, card] of cards) {
if (!visibleIds.has(id)) {
beginExit(id, card);
}
}
for (const entry of visible) {
let card = cards.get(entry.id);
if (card && leaving.has(entry.id)) {
// The card was animating out but has been re-shown: cancel the exit.
leaving.delete(entry.id);
card.classList.remove('leaving');
}
if (!card) {
card = document.createElement('section');
card.classList.add('entering');
watchEnterAnimation(card);
cards.set(entry.id, card);
}
populateCard(card, entry);
appendCardIfNeeded(card);
}
if (visible.length === 0 && leaving.size === 0) {
setInteractiveState(ctx, false);
} else if (!hasInteractiveCard && ctx.state.isOverOverlayNotification) {
setInteractiveState(ctx, false);
}
options.onChanged?.();
}
ctx.dom.overlayNotificationStack.addEventListener('mouseenter', () => {
if (store.visible().some(hasExplicitNotificationActions)) {
setInteractiveState(ctx, true);
}
});
ctx.dom.overlayNotificationStack.addEventListener('mouseleave', () => {
setInteractiveState(ctx, false);
});
function show(payload: OverlayNotificationPayload): string {
const entry = store.upsert(payload);
position = entry.position ?? DEFAULT_OVERLAY_NOTIFICATION_POSITION;
options.onShow?.(entry);
clearTimer(entry.id);
if (!entry.persistent) {
const timeoutMs = Math.max(0, entry.timeoutMs ?? DEFAULT_OVERLAY_NOTIFICATION_TIMEOUT_MS);
timers.set(
entry.id,
window.setTimeout(() => remove(entry.id), timeoutMs),
);
}
render();
return entry.id;
}
return {
show,
remove,
};
}
+16
View File
@@ -58,6 +58,22 @@ test('renderer reports subtitle bounds immediately after initial subtitle layout
assert.ok(immediateMeasurementIndex < listenerIndex);
});
test('renderer wires subtitle pointer handlers before first subtitle paint', () => {
const primaryMouseEnterIndex = indexOfRequired(
"ctx.dom.subtitleContainer.addEventListener('mouseenter', mouseHandlers.handlePrimaryMouseEnter);",
);
const pointerTrackingIndex = indexOfRequired('mouseHandlers.setupPointerTracking();');
const initialRenderIndex = indexOfRequired('subtitleRenderer.renderSubtitle(initialSubtitle);');
const initialMeasurementIndex = indexOfRequired(
'positioning.applyYPercent(positioning.getCurrentYPercent());\n measurementReporter.emitNow();',
);
assert.ok(primaryMouseEnterIndex < initialRenderIndex);
assert.ok(pointerTrackingIndex < initialRenderIndex);
assert.ok(primaryMouseEnterIndex < initialMeasurementIndex);
assert.ok(pointerTrackingIndex < initialMeasurementIndex);
});
test('renderer reports subtitle bounds immediately after live subtitle layout', () => {
const liveRenderIndex = indexOfRequired('subtitleRenderer.renderSubtitle(data);');
const liveLayoutIndex = indexOfRequired(
+73 -15
View File
@@ -45,6 +45,12 @@ import { createYoutubeTrackPickerModal } from './modals/youtube-track-picker.js'
import { createPositioningController } from './positioning.js';
import { createOverlayContentMeasurementReporter } from './overlay-content-measurement.js';
import { syncOverlayMouseIgnoreState } from './overlay-mouse-ignore.js';
import {
createOverlayNotificationRenderer,
handleOverlayNotificationEvent,
overlayNotificationPositionClass,
} from './overlay-notifications.js';
import { createOverlayNotificationHistoryPanel } from './overlay-notification-history.js';
import { createRendererState } from './state.js';
import { createSubtitleRenderer } from './subtitle-render.js';
import { isYomitanPopupVisible, registerYomitanLookupListener } from './yomitan-popup.js';
@@ -112,6 +118,16 @@ function syncSettingsModalSubtitleSuppression(): void {
const subtitleRenderer = createSubtitleRenderer(ctx);
const measurementReporter = createOverlayContentMeasurementReporter(ctx);
const notificationHistory = createOverlayNotificationHistoryPanel(ctx, {
onChanged: () => measurementReporter.schedule(),
});
const overlayNotifications = createOverlayNotificationRenderer(ctx, {
onChanged: () => {
notificationHistory.syncSide();
measurementReporter.schedule();
},
onShow: (entry) => notificationHistory.record(entry),
});
const positioning = createPositioningController(ctx);
const runtimeOptionsModal = createRuntimeOptionsModal(ctx, {
modalStateReader: { isAnyModalOpen },
@@ -425,12 +441,30 @@ function restoreOverlayInteractionAfterError(): void {
}
}
const OVERLAY_TOAST_POSITION_CLASSES = [
'position-top-left',
'position-top',
'position-top-right',
] as const;
// Mirror the notification stack's current position onto a toast so error/status toasts honor the
// configured `notifications.overlayPosition` instead of always pinning to the top-right corner.
function applyConfiguredToastPosition(toast: HTMLElement): void {
const stackClasses = ctx.dom.overlayNotificationStack.classList;
const active =
OVERLAY_TOAST_POSITION_CLASSES.find((cls) => stackClasses.contains(cls)) ??
'position-top-right';
toast.classList.remove(...OVERLAY_TOAST_POSITION_CLASSES);
toast.classList.add(active);
}
function showOverlayErrorToast(message: string): void {
if (overlayErrorToastTimeout) {
clearTimeout(overlayErrorToastTimeout);
overlayErrorToastTimeout = null;
}
ctx.dom.overlayErrorToast.textContent = message;
applyConfiguredToastPosition(ctx.dom.overlayErrorToast);
ctx.dom.overlayErrorToast.classList.remove('hidden');
overlayErrorToastTimeout = setTimeout(() => {
ctx.dom.overlayErrorToast.classList.add('hidden');
@@ -601,6 +635,19 @@ async function init(): Promise<void> {
syncOverlayMouseIgnoreState(ctx);
}
// Seed the notification stack position from config before subscribing to history toggles, so the
// closed history panel starts on the same side it will slide in from.
try {
const overlayNotificationPosition = await window.electronAPI.getOverlayNotificationPosition();
ctx.dom.overlayNotificationStack.classList.remove(...OVERLAY_TOAST_POSITION_CLASSES);
ctx.dom.overlayNotificationStack.classList.add(
overlayNotificationPositionClass(overlayNotificationPosition),
);
notificationHistory.syncSide();
} catch {
// Non-fatal: keep the default position class from index.html.
}
window.electronAPI.onOverlayPointerRecoveryRequested(() => {
runGuarded('overlay:pointer-recovery', () => {
if (!ctx.platform.isMacOSPlatform || !ctx.platform.shouldToggleMouseIgnore) {
@@ -612,6 +659,16 @@ async function init(): Promise<void> {
mouseHandlers.restorePointerInteractionState();
});
});
window.electronAPI.onOverlayNotification((payload) => {
runGuarded('overlay:notification', () => {
handleOverlayNotificationEvent(overlayNotifications, payload);
});
});
window.electronAPI.onNotificationHistoryToggle(() => {
runGuarded('notification-history:toggle', () => {
notificationHistory.toggle();
});
});
await keyboardHandlers.setupMpvInputForwarding();
@@ -624,6 +681,22 @@ async function init(): Promise<void> {
);
measurementReporter.schedule();
ctx.dom.subtitleContainer.addEventListener('mouseenter', mouseHandlers.handlePrimaryMouseEnter);
ctx.dom.subtitleContainer.addEventListener('mouseleave', mouseHandlers.handlePrimaryMouseLeave);
ctx.dom.secondarySubContainer.addEventListener(
'mouseenter',
mouseHandlers.handleSecondaryMouseEnter,
);
ctx.dom.secondarySubContainer.addEventListener(
'mouseleave',
mouseHandlers.handleSecondaryMouseLeave,
);
mouseHandlers.setupResizeHandler();
mouseHandlers.setupPointerTracking();
mouseHandlers.setupSelectionObserver();
mouseHandlers.setupYomitanObserver();
window.electronAPI.onSubtitlePosition((position: SubtitlePosition | null) => {
runGuarded('subtitle-position:update', () => {
positioning.applyStoredSubtitlePosition(position, 'media-change');
@@ -678,21 +751,6 @@ async function init(): Promise<void> {
subtitleRenderer.renderSecondarySub(await window.electronAPI.getCurrentSecondarySub());
measurementReporter.schedule();
ctx.dom.subtitleContainer.addEventListener('mouseenter', mouseHandlers.handlePrimaryMouseEnter);
ctx.dom.subtitleContainer.addEventListener('mouseleave', mouseHandlers.handlePrimaryMouseLeave);
ctx.dom.secondarySubContainer.addEventListener(
'mouseenter',
mouseHandlers.handleSecondaryMouseEnter,
);
ctx.dom.secondarySubContainer.addEventListener(
'mouseleave',
mouseHandlers.handleSecondaryMouseLeave,
);
mouseHandlers.setupResizeHandler();
mouseHandlers.setupPointerTracking();
mouseHandlers.setupSelectionObserver();
mouseHandlers.setupYomitanObserver();
setupDragDropToMpvQueue();
window.addEventListener('resize', () => {
measurementReporter.schedule();
+6
View File
@@ -31,6 +31,9 @@ export type ChordAction =
export type RendererState = {
isOverSubtitle: boolean;
isOverSubtitleSidebar: boolean;
isOverOverlayNotification: boolean;
isOverNotificationHistory: boolean;
notificationHistoryOpen: boolean;
isDragging: boolean;
dragStartY: number;
startYPercent: number;
@@ -143,6 +146,9 @@ export function createRendererState(): RendererState {
return {
isOverSubtitle: false,
isOverSubtitleSidebar: false,
isOverOverlayNotification: false,
isOverNotificationHistory: false,
notificationHistoryOpen: false,
isDragging: false,
dragStartY: 0,
startYPercent: 0,
+651 -1
View File
@@ -146,6 +146,656 @@ body:focus-visible,
transform: translateY(0);
}
/* Follow the configured notification position (default stays top-right). */
.overlay-error-toast.position-top-left {
left: 16px;
right: auto;
}
.overlay-error-toast.position-top {
left: 50%;
right: auto;
transform: translate(-50%, -6px);
}
.overlay-error-toast.position-top:not(.hidden) {
transform: translate(-50%, 0);
}
.overlay-error-toast.position-top-right {
left: auto;
right: 16px;
}
.overlay-notification-stack {
position: absolute;
top: 16px;
width: min(420px, calc(100vw - 32px));
display: flex;
flex-direction: column;
gap: 8px;
pointer-events: auto;
z-index: 2147483647 !important;
}
.overlay-notification-stack.position-top-left {
left: 16px;
right: auto;
transform: none;
}
.overlay-notification-stack.position-top {
left: 50%;
right: auto;
transform: translateX(-50%);
}
.overlay-notification-stack.position-top-right {
left: auto;
right: 16px;
transform: none;
}
.overlay-notification-card {
/* Accent color is overridden per variant and drives the icon and border tint. */
--overlay-notification-accent: var(--ctp-blue);
position: relative;
display: grid;
grid-template-columns: 22px minmax(0, 1fr) 22px;
gap: 12px;
align-items: start;
min-height: 72px;
padding: 16px;
border-radius: 12px;
border: 1px solid color-mix(in srgb, var(--overlay-notification-accent) 45%, var(--ctp-surface1));
background: var(--ctp-base);
box-shadow: 0 12px 28px -12px rgba(24, 25, 38, 0.7);
color: var(--ctp-text);
overflow: hidden;
}
/* Direction-aware enter/exit — slide in from the stack's anchored edge, slide back out. */
.overlay-notification-card.entering {
animation: overlay-notification-enter-right 240ms cubic-bezier(0.21, 1.02, 0.73, 1) both;
}
.overlay-notification-card.leaving {
pointer-events: none;
animation: overlay-notification-leave-right 190ms cubic-bezier(0.55, 0.06, 0.68, 0.19) both;
}
.overlay-notification-stack.position-top-left .overlay-notification-card.entering {
animation-name: overlay-notification-enter-left;
}
.overlay-notification-stack.position-top-left .overlay-notification-card.leaving {
animation-name: overlay-notification-leave-left;
}
.overlay-notification-stack.position-top .overlay-notification-card.entering {
animation-name: overlay-notification-enter-top;
}
.overlay-notification-stack.position-top .overlay-notification-card.leaving {
animation-name: overlay-notification-leave-top;
}
.overlay-notification-card.info {
--overlay-notification-accent: var(--ctp-blue);
}
.overlay-notification-card.progress {
--overlay-notification-accent: var(--ctp-sky);
}
.overlay-notification-card.success {
--overlay-notification-accent: var(--ctp-green);
}
.overlay-notification-card.warning {
--overlay-notification-accent: var(--ctp-yellow);
}
.overlay-notification-card.error {
--overlay-notification-accent: var(--ctp-red);
}
.overlay-notification-card.has-image {
/* Reserve a real track for the thumbnail so it never overlaps the text.
minmax(0, 100px) lets the column shrink the image on narrow notifications
instead of letting it spill into the content column. */
grid-template-columns: minmax(0, 100px) minmax(0, 1fr) 22px;
min-height: 88px;
}
.overlay-notification-image {
width: 100%;
max-width: 100px;
aspect-ratio: 100 / 56;
height: auto;
align-self: center;
display: block;
border-radius: 7px;
border: 1px solid color-mix(in srgb, var(--overlay-notification-accent) 28%, var(--ctp-surface2));
background: var(--ctp-crust);
object-fit: cover;
}
.overlay-notification-icon {
width: 22px;
height: 22px;
align-self: center;
display: grid;
place-items: center;
border-radius: 7px;
background: color-mix(in srgb, var(--overlay-notification-accent) 16%, transparent);
color: var(--overlay-notification-accent);
font-size: 12px;
font-weight: 900;
line-height: 1;
}
.overlay-notification-card.info .overlay-notification-icon::before {
content: 'i';
font-family: Georgia, 'Times New Roman', serif;
font-style: italic;
}
.overlay-notification-card.success .overlay-notification-icon::before {
content: '\2713';
}
.overlay-notification-card.warning .overlay-notification-icon::before {
content: '!';
}
.overlay-notification-card.error .overlay-notification-icon::before {
content: '\2715';
font-size: 11px;
}
.overlay-notification-card.progress .overlay-notification-icon::before {
content: '';
width: 13px;
height: 13px;
border-radius: 50%;
border: 2px solid color-mix(in srgb, var(--overlay-notification-accent) 28%, transparent);
border-top-color: var(--overlay-notification-accent);
animation: overlay-notification-spin 0.75s linear infinite;
}
.overlay-notification-content {
min-width: 0;
padding-top: 1px;
}
.overlay-notification-title {
color: var(--ctp-text);
font-size: 13px;
font-weight: 700;
line-height: 1.3;
letter-spacing: 0.1px;
}
.overlay-notification-body {
margin-top: 4px;
color: var(--ctp-subtext0);
font-size: 12px;
font-weight: 500;
line-height: 1.4;
overflow-wrap: anywhere;
}
.overlay-notification-actions {
display: flex;
flex-wrap: wrap;
gap: 7px;
margin-top: 11px;
}
.overlay-notification-action {
min-height: 27px;
padding: 4px 11px;
border-radius: 7px;
border: 1px solid color-mix(in srgb, var(--overlay-notification-accent) 35%, var(--ctp-surface2));
background: color-mix(in srgb, var(--overlay-notification-accent) 12%, var(--ctp-surface0));
color: var(--ctp-text);
font: inherit;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition:
background 120ms ease,
border-color 120ms ease;
}
.overlay-notification-action:hover {
border-color: var(--overlay-notification-accent);
background: color-mix(in srgb, var(--overlay-notification-accent) 24%, var(--ctp-surface0));
}
.overlay-notification-close {
width: 22px;
height: 22px;
align-self: start;
border: none;
border-radius: 6px;
background: transparent;
color: var(--ctp-overlay1);
font: inherit;
font-size: 16px;
line-height: 1;
cursor: pointer;
transition:
background 120ms ease,
color 120ms ease;
}
.overlay-notification-close:hover {
background: color-mix(in srgb, var(--ctp-red) 18%, transparent);
color: var(--ctp-red);
}
@keyframes overlay-notification-enter-right {
from {
opacity: 0;
transform: translateX(28px) scale(0.96);
}
to {
opacity: 1;
transform: translateX(0) scale(1);
}
}
@keyframes overlay-notification-enter-left {
from {
opacity: 0;
transform: translateX(-28px) scale(0.96);
}
to {
opacity: 1;
transform: translateX(0) scale(1);
}
}
@keyframes overlay-notification-enter-top {
from {
opacity: 0;
transform: translateY(-16px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes overlay-notification-leave-right {
from {
opacity: 1;
transform: translateX(0) scale(1);
}
to {
opacity: 0;
transform: translateX(28px) scale(0.94);
}
}
@keyframes overlay-notification-leave-left {
from {
opacity: 1;
transform: translateX(0) scale(1);
}
to {
opacity: 0;
transform: translateX(-28px) scale(0.94);
}
}
@keyframes overlay-notification-leave-top {
from {
opacity: 1;
transform: translateY(0) scale(1);
}
to {
opacity: 0;
transform: translateY(-14px) scale(0.94);
}
}
@media (prefers-reduced-motion: reduce) {
.overlay-notification-card.entering,
.overlay-notification-card.leaving {
animation-duration: 1ms;
}
}
@keyframes overlay-notification-spin {
to {
transform: rotate(360deg);
}
}
/* Notification history panel — slides in from the same edge the notifications use. */
.notification-history {
--notification-history-width: min(380px, calc(100vw - 24px));
position: absolute;
top: 0;
bottom: 0;
width: var(--notification-history-width);
display: flex;
flex-direction: column;
background: color-mix(in srgb, var(--ctp-mantle) 94%, transparent);
border: 1px solid var(--ctp-surface0);
box-shadow: 0 18px 48px -18px rgba(24, 25, 38, 0.85);
color: var(--ctp-text);
pointer-events: auto;
z-index: 2147483646;
opacity: 0;
visibility: hidden;
transition:
transform 240ms cubic-bezier(0.21, 1.02, 0.73, 1),
opacity 200ms ease,
visibility 0s linear 240ms;
}
.notification-history.side-left {
left: 0;
right: auto;
border-left: none;
border-top-right-radius: 14px;
border-bottom-right-radius: 14px;
transform: translateX(-104%);
}
.notification-history.side-right {
left: auto;
right: 0;
border-right: none;
border-top-left-radius: 14px;
border-bottom-left-radius: 14px;
transform: translateX(104%);
}
.notification-history.open {
opacity: 1;
visibility: visible;
transform: translateX(0);
transition:
transform 260ms cubic-bezier(0.21, 1.02, 0.73, 1),
opacity 200ms ease,
visibility 0s linear 0s;
}
.notification-history-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 18px;
border-bottom: 1px solid var(--ctp-surface0);
background: color-mix(in srgb, var(--ctp-crust) 60%, transparent);
}
.notification-history-title {
font-size: 14px;
font-weight: 800;
letter-spacing: 0.2px;
color: var(--ctp-lavender);
}
.notification-history-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.notification-history-clear {
padding: 5px 12px;
border-radius: 8px;
border: 1px solid color-mix(in srgb, var(--ctp-mauve) 38%, var(--ctp-surface1));
background: color-mix(in srgb, var(--ctp-mauve) 14%, var(--ctp-surface0));
color: var(--ctp-text);
font: inherit;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition:
background 120ms ease,
border-color 120ms ease,
opacity 120ms ease;
}
.notification-history-clear:hover:not(:disabled) {
border-color: var(--ctp-mauve);
background: color-mix(in srgb, var(--ctp-mauve) 26%, var(--ctp-surface0));
}
.notification-history-clear:disabled {
opacity: 0.4;
cursor: default;
}
.notification-history-close {
width: 26px;
height: 26px;
display: grid;
place-items: center;
border: none;
border-radius: 7px;
background: transparent;
color: var(--ctp-overlay1);
font: inherit;
font-size: 18px;
line-height: 1;
cursor: pointer;
transition:
background 120ms ease,
color 120ms ease;
}
.notification-history-close:hover {
background: color-mix(in srgb, var(--ctp-red) 18%, transparent);
color: var(--ctp-red);
}
.notification-history-body {
position: relative;
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 12px;
scrollbar-width: thin;
scrollbar-color: var(--ctp-surface2) transparent;
}
.notification-history-body::-webkit-scrollbar {
width: 8px;
}
.notification-history-body::-webkit-scrollbar-thumb {
background: var(--ctp-surface1);
border-radius: 8px;
}
.notification-history-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 8px;
margin: 0;
padding: 0;
}
.notification-history-item {
--notification-history-accent: var(--ctp-blue);
position: relative;
display: grid;
grid-template-columns: 4px auto minmax(0, 1fr) 22px;
gap: 10px;
align-items: start;
padding: 11px 12px;
border-radius: 10px;
border: 1px solid var(--ctp-surface0);
background: var(--ctp-base);
}
.notification-history-item::before {
content: '';
align-self: stretch;
border-radius: 4px;
background: var(--notification-history-accent);
}
.notification-history-item.info {
--notification-history-accent: var(--ctp-blue);
}
.notification-history-item.progress {
--notification-history-accent: var(--ctp-sky);
}
.notification-history-item.success {
--notification-history-accent: var(--ctp-green);
}
.notification-history-item.warning {
--notification-history-accent: var(--ctp-yellow);
}
.notification-history-item.error {
--notification-history-accent: var(--ctp-red);
}
.notification-history-thumb {
width: 56px;
aspect-ratio: 100 / 56;
height: auto;
align-self: center;
border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--notification-history-accent) 28%, var(--ctp-surface2));
background: var(--ctp-crust);
object-fit: cover;
}
.notification-history-icon {
width: 10px;
height: 10px;
align-self: center;
border-radius: 50%;
background: var(--notification-history-accent);
}
.notification-history-content {
min-width: 0;
}
.notification-history-item-title {
font-size: 13px;
font-weight: 700;
line-height: 1.3;
color: var(--ctp-text);
}
.notification-history-item-body {
margin-top: 3px;
font-size: 12px;
font-weight: 500;
line-height: 1.4;
color: var(--ctp-subtext0);
overflow-wrap: anywhere;
}
.notification-history-time {
display: block;
margin-top: 5px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.3px;
color: var(--ctp-overlay1);
}
.notification-history-actions {
display: flex;
flex-wrap: wrap;
gap: 7px;
margin-top: 9px;
}
.notification-history-action {
min-height: 24px;
max-width: 100%;
padding: 4px 9px;
border: 1px solid color-mix(in srgb, var(--notification-history-accent) 38%, var(--ctp-surface2));
border-radius: 6px;
background: color-mix(in srgb, var(--notification-history-accent) 18%, var(--ctp-surface0));
color: var(--ctp-text);
font: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.2;
overflow-wrap: anywhere;
cursor: pointer;
transition:
background 120ms ease,
border-color 120ms ease,
color 120ms ease;
}
.notification-history-action:hover {
border-color: color-mix(in srgb, var(--notification-history-accent) 70%, var(--ctp-surface2));
background: color-mix(in srgb, var(--notification-history-accent) 28%, var(--ctp-surface0));
}
.notification-history-remove {
width: 22px;
height: 22px;
align-self: start;
border: none;
border-radius: 6px;
background: transparent;
color: var(--ctp-overlay1);
font: inherit;
font-size: 15px;
line-height: 1;
cursor: pointer;
transition:
background 120ms ease,
color 120ms ease;
}
.notification-history-remove:hover {
background: color-mix(in srgb, var(--ctp-red) 18%, transparent);
color: var(--ctp-red);
}
.notification-history-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 96px;
padding: 24px;
text-align: center;
font-size: 13px;
font-weight: 500;
color: var(--ctp-overlay0);
}
.notification-history-empty.hidden {
display: none;
}
@media (prefers-reduced-motion: reduce) {
.notification-history {
transition-duration: 1ms;
}
}
.modal {
position: absolute;
inset: 0;
@@ -1282,7 +1932,7 @@ iframe.yomitan-popup,
iframe[id^='yomitan-popup'],
[data-subminer-yomitan-popup-host='true'] {
pointer-events: auto !important;
z-index: 2147483647 !important;
z-index: 2147483645;
}
.kiku-info-text {
+4
View File
@@ -2,6 +2,8 @@ export type RendererDom = {
subtitleRoot: HTMLElement;
subtitleContainer: HTMLElement;
overlay: HTMLElement;
overlayNotificationStack: HTMLDivElement;
overlayNotificationHistory: HTMLElement;
controllerStatusToast: HTMLDivElement;
overlayErrorToast: HTMLDivElement;
secondarySubContainer: HTMLElement;
@@ -132,6 +134,8 @@ export function resolveRendererDom(): RendererDom {
subtitleRoot: getRequiredElement<HTMLElement>('subtitleRoot'),
subtitleContainer: getRequiredElement<HTMLElement>('subtitleContainer'),
overlay: getRequiredElement<HTMLElement>('overlay'),
overlayNotificationStack: getRequiredElement<HTMLDivElement>('overlayNotificationStack'),
overlayNotificationHistory: getRequiredElement<HTMLElement>('overlayNotificationHistory'),
controllerStatusToast: getRequiredElement<HTMLDivElement>('controllerStatusToast'),
overlayErrorToast: getRequiredElement<HTMLDivElement>('overlayErrorToast'),
secondarySubContainer: getRequiredElement<HTMLElement>('secondarySubContainer'),