mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-05 19:21:35 -07:00
feat(notifications): add overlay notifications with position config (#110)
This commit is contained in:
@@ -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('');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user