mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-09 03:13:32 -07:00
feat(notifications): add Open in Anki action and in-place progress updat
- Add openNoteInBrowser to AnkiConnectClient via guiBrowse IPC - Add Open in Anki action button to mined-card overlay notifications and history entries - Fall back to a direct AnkiConnectClient when the live integration is unavailable - Embed notification images as base64 data URIs so history panel shows thumbnails - Update same-id progress notifications in place to avoid spinner flicker - Thread noteId through IPC overlay notification action payload
This commit is contained in:
@@ -11,7 +11,9 @@ breaking: true
|
||||
- Initialized the tray and visible overlay shell before deferred tokenization warmups finish on visible-overlay startup, while keeping playback paused until SubMiner reports autoplay readiness.
|
||||
- Kept playback feedback such as subtitle visibility, subtitle track, and subtitle delay text on overlay/OSD surfaces only; desktop/system notifications are reserved for real notifications like mined cards, errors, and updates.
|
||||
- Reused the active primary/secondary subtitle mode overlay notification while cycling modes so rapid toggles update one card instead of stacking duplicate feedback.
|
||||
- Fixed mined-card overlay notifications so `overlay` and `both` modes show the same generated card thumbnail as system notifications.
|
||||
- Updated repeated progress notifications such as subsync syncing in place so their spinner stays live instead of flickering on every tick.
|
||||
- Fixed mined-card overlay notifications so `overlay` and `both` modes show generated card thumbnails in both live cards and the notification history panel.
|
||||
- Added Open in Anki buttons to mined-card overlay notifications and their history entries, with a direct AnkiConnect fallback when the live integration is unavailable.
|
||||
- Added an Update button to overlay update-available notifications so users can start the app update flow from the notification.
|
||||
- Fixed sentence-card mining so the Ctrl+S flow shows only the Anki update progress notification instead of also stacking a generic SubMiner toast.
|
||||
- Fixed overlay notification layering so notification close/actions stay clickable above subtitle bars on Linux overlays.
|
||||
|
||||
@@ -87,6 +87,25 @@ test('AnkiConnectClient lists decks and note type fields', async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('AnkiConnectClient opens a note in the Anki browser', async () => {
|
||||
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
|
||||
client: { post: (url: string, body: { action: string; params: unknown }) => Promise<unknown> };
|
||||
};
|
||||
const calls: Array<{ action: string; params: unknown }> = [];
|
||||
client.client = {
|
||||
post: async (_url, body) => {
|
||||
calls.push({ action: body.action, params: body.params });
|
||||
return { data: { result: [], error: null } };
|
||||
},
|
||||
};
|
||||
|
||||
await (
|
||||
client as unknown as { openNoteInBrowser: (noteId: number) => Promise<void> }
|
||||
).openNoteInBrowser(12345);
|
||||
|
||||
assert.deepEqual(calls, [{ action: 'guiBrowse', params: { query: 'nid:12345' } }]);
|
||||
});
|
||||
|
||||
test('AnkiConnectClient derives field names from sampled notes in a deck', async () => {
|
||||
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
|
||||
client: { post: (url: string, body: { action: string; params: unknown }) => Promise<unknown> };
|
||||
|
||||
@@ -247,6 +247,13 @@ export class AnkiConnectClient {
|
||||
return (result as Record<string, unknown>[]) || [];
|
||||
}
|
||||
|
||||
async openNoteInBrowser(noteId: number): Promise<void> {
|
||||
if (!Number.isInteger(noteId) || noteId <= 0) {
|
||||
throw new Error('Invalid Anki note id');
|
||||
}
|
||||
await this.invoke('guiBrowse', { query: `nid:${noteId}` });
|
||||
}
|
||||
|
||||
async updateNoteFields(noteId: number, fields: Record<string, string>): Promise<void> {
|
||||
await this.invoke('updateNoteFields', {
|
||||
note: {
|
||||
|
||||
@@ -3,7 +3,6 @@ import assert from 'node:assert/strict';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { pathToFileURL } from 'url';
|
||||
import { AnkiIntegration } from './anki-integration';
|
||||
import { FieldGroupingMergeCollaborator } from './anki-integration/field-grouping-merge';
|
||||
import { AnkiConnectConfig } from './types';
|
||||
@@ -13,6 +12,7 @@ type TestOverlayNotificationPayload = {
|
||||
body?: string;
|
||||
image?: string;
|
||||
variant?: string;
|
||||
actions?: Array<{ id: string; label: string; noteId?: number }>;
|
||||
};
|
||||
|
||||
interface IntegrationTestContext {
|
||||
@@ -414,7 +414,7 @@ test('AnkiIntegration marks partial update notifications as failures in OSD mode
|
||||
assert.deepEqual(osdMessages, ['x Updated card: taberu (image failed)']);
|
||||
});
|
||||
|
||||
test('AnkiIntegration includes generated notification image on overlay mined-card notifications', async () => {
|
||||
test('AnkiIntegration embeds generated notification image on overlay mined-card notifications', async () => {
|
||||
const desktopNotifications: Array<{ title: string; body?: string; icon?: string }> = [];
|
||||
const overlayNotifications: TestOverlayNotificationPayload[] = [];
|
||||
const generatedFrom: Array<{ videoPath: string; timestamp: number }> = [];
|
||||
@@ -478,7 +478,13 @@ test('AnkiIntegration includes generated notification image on overlay mined-car
|
||||
assert.equal(overlayNotifications.length, 1);
|
||||
assert.equal(overlayNotifications[0]?.title, 'Anki Card Updated');
|
||||
assert.equal(overlayNotifications[0]?.body, 'Updated card: 食べる');
|
||||
assert.equal(overlayNotifications[0]?.image, pathToFileURL(notificationIconPath).toString());
|
||||
assert.equal(
|
||||
overlayNotifications[0]?.image,
|
||||
`data:image/png;base64,${Buffer.from('png').toString('base64')}`,
|
||||
);
|
||||
assert.deepEqual(overlayNotifications[0]?.actions, [
|
||||
{ id: 'open-anki-card', label: 'Open in Anki', noteId: 42 },
|
||||
]);
|
||||
assert.deepEqual(desktopNotifications, [
|
||||
{
|
||||
title: 'Anki Card Updated',
|
||||
@@ -489,6 +495,73 @@ test('AnkiIntegration includes generated notification image on overlay mined-car
|
||||
assert.deepEqual(cleanupPaths, [notificationIconPath]);
|
||||
});
|
||||
|
||||
test('AnkiIntegration keeps overlay notification image when temp icon write fails', async () => {
|
||||
const desktopNotifications: Array<{ title: string; body?: string; icon?: string }> = [];
|
||||
const overlayNotifications: TestOverlayNotificationPayload[] = [];
|
||||
const cleanupPaths: string[] = [];
|
||||
|
||||
const integration = new AnkiIntegration(
|
||||
{
|
||||
behavior: {
|
||||
notificationType: 'both',
|
||||
},
|
||||
},
|
||||
{} as never,
|
||||
{
|
||||
currentVideoPath: '/tmp/show.mkv',
|
||||
currentTimePos: 123.45,
|
||||
} as never,
|
||||
undefined,
|
||||
(title, options) => {
|
||||
desktopNotifications.push({ title, body: options.body, icon: options.icon });
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
{},
|
||||
undefined,
|
||||
(payload) => {
|
||||
overlayNotifications.push(payload as TestOverlayNotificationPayload);
|
||||
},
|
||||
);
|
||||
|
||||
(
|
||||
integration as unknown as {
|
||||
mediaGenerator: {
|
||||
generateNotificationIcon: () => Promise<Buffer>;
|
||||
writeNotificationIconToFile: () => string;
|
||||
scheduleNotificationIconCleanup: (filePath: string) => void;
|
||||
};
|
||||
}
|
||||
).mediaGenerator = {
|
||||
generateNotificationIcon: async () => Buffer.from('png'),
|
||||
writeNotificationIconToFile: () => {
|
||||
throw new Error('disk full');
|
||||
},
|
||||
scheduleNotificationIconCleanup: (filePath) => {
|
||||
cleanupPaths.push(filePath);
|
||||
},
|
||||
};
|
||||
|
||||
await (
|
||||
integration as unknown as {
|
||||
showNotification: (noteId: number, label: string | number) => Promise<void>;
|
||||
}
|
||||
).showNotification(42, '食べる');
|
||||
|
||||
assert.equal(
|
||||
overlayNotifications[0]?.image,
|
||||
`data:image/png;base64,${Buffer.from('png').toString('base64')}`,
|
||||
);
|
||||
assert.deepEqual(desktopNotifications, [
|
||||
{
|
||||
title: 'Anki Card Updated',
|
||||
body: 'Updated card: 食べる',
|
||||
icon: undefined,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(cleanupPaths, []);
|
||||
});
|
||||
|
||||
test('AnkiIntegration routes workflow status notifications through configured surfaces', async () => {
|
||||
const osdMessages: string[] = [];
|
||||
const desktopMessages: string[] = [];
|
||||
|
||||
+39
-13
@@ -20,7 +20,6 @@ import { AnkiConnectClient } from './anki-connect';
|
||||
import { SubtitleTimingTracker } from './subtitle-timing-tracker';
|
||||
import { MediaGenerator } from './media-generator';
|
||||
import path from 'path';
|
||||
import { pathToFileURL } from 'url';
|
||||
import {
|
||||
AnkiConnectConfig,
|
||||
KikuDuplicateCardInfo,
|
||||
@@ -30,6 +29,7 @@ import {
|
||||
} from './types/anki';
|
||||
import { AiConfig } from './types/integrations';
|
||||
import { MpvClient } from './types/runtime';
|
||||
import { OPEN_ANKI_CARD_ACTION_ID } from './types/notification';
|
||||
import type { NotificationType, OverlayNotificationPayload } from './types/notification';
|
||||
import type { NPlusOneMatchMode, SubtitleMiningContext } from './types/subtitle';
|
||||
import { DEFAULT_ANKI_CONNECT_CONFIG } from './config';
|
||||
@@ -121,8 +121,13 @@ function shouldPreferMediaTitleForMiscInfo(rawPath: string, filename: string): b
|
||||
);
|
||||
}
|
||||
|
||||
function toOverlayNotificationImageSource(filePath: string): string {
|
||||
return pathToFileURL(filePath).toString();
|
||||
function toOverlayNotificationImageSource(iconBuffer: Buffer): string {
|
||||
return `data:image/png;base64,${iconBuffer.toString('base64')}`;
|
||||
}
|
||||
|
||||
interface NotificationIcon {
|
||||
filePath?: string;
|
||||
overlayImageSource: string;
|
||||
}
|
||||
|
||||
export class AnkiIntegration {
|
||||
@@ -535,6 +540,10 @@ export class AnkiIntegration {
|
||||
return this.config.knownWords?.matchMode ?? DEFAULT_ANKI_CONNECT_CONFIG.knownWords.matchMode;
|
||||
}
|
||||
|
||||
async openNoteInAnki(noteId: number): Promise<void> {
|
||||
await this.client.openNoteInBrowser(noteId);
|
||||
}
|
||||
|
||||
private isKnownWordCacheEnabled(): boolean {
|
||||
return (
|
||||
this.config.knownWords?.highlightEnabled === true || this.config.nPlusOne?.enabled === true
|
||||
@@ -1092,9 +1101,9 @@ export class AnkiIntegration {
|
||||
const shouldShowSystemNotification =
|
||||
(type === 'system' || type === 'both' || type === 'osd-system') &&
|
||||
this.notificationCallback !== null;
|
||||
const notificationIconPath =
|
||||
const notificationIcon =
|
||||
shouldShowOverlayNotification || shouldShowSystemNotification
|
||||
? await this.generateNotificationIconPath(noteId)
|
||||
? await this.generateNotificationIcon(noteId, shouldShowSystemNotification)
|
||||
: undefined;
|
||||
|
||||
if (shouldShowOverlayNotification && this.overlayNotificationCallback) {
|
||||
@@ -1102,27 +1111,31 @@ export class AnkiIntegration {
|
||||
id: 'anki-update-progress',
|
||||
title: 'Anki Card Updated',
|
||||
body: message,
|
||||
...(notificationIconPath
|
||||
? { image: toOverlayNotificationImageSource(notificationIconPath) }
|
||||
: {}),
|
||||
...(notificationIcon ? { image: notificationIcon.overlayImageSource } : {}),
|
||||
variant: errorSuffix === undefined ? 'success' : 'error',
|
||||
persistent: false,
|
||||
actions: [{ id: OPEN_ANKI_CARD_ACTION_ID, label: 'Open in Anki', noteId }],
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldShowSystemNotification && this.notificationCallback) {
|
||||
this.notificationCallback('Anki Card Updated', {
|
||||
body: message,
|
||||
icon: notificationIconPath,
|
||||
icon: notificationIcon?.filePath,
|
||||
});
|
||||
}
|
||||
|
||||
if (notificationIconPath) {
|
||||
this.mediaGenerator.scheduleNotificationIconCleanup(notificationIconPath);
|
||||
if (notificationIcon) {
|
||||
if (notificationIcon.filePath) {
|
||||
this.mediaGenerator.scheduleNotificationIconCleanup(notificationIcon.filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async generateNotificationIconPath(noteId: number): Promise<string | undefined> {
|
||||
private async generateNotificationIcon(
|
||||
noteId: number,
|
||||
shouldWriteToFile: boolean,
|
||||
): Promise<NotificationIcon | undefined> {
|
||||
if (!this.mpvClient?.currentVideoPath) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -1138,7 +1151,20 @@ export class AnkiIntegration {
|
||||
timestamp,
|
||||
);
|
||||
if (iconBuffer && iconBuffer.length > 0) {
|
||||
return this.mediaGenerator.writeNotificationIconToFile(iconBuffer, noteId);
|
||||
const notificationIcon: NotificationIcon = {
|
||||
overlayImageSource: toOverlayNotificationImageSource(iconBuffer),
|
||||
};
|
||||
if (shouldWriteToFile) {
|
||||
try {
|
||||
notificationIcon.filePath = this.mediaGenerator.writeNotificationIconToFile(
|
||||
iconBuffer,
|
||||
noteId,
|
||||
);
|
||||
} catch (err) {
|
||||
log.warn('Failed to write notification icon:', (err as Error).message);
|
||||
}
|
||||
}
|
||||
return notificationIcon;
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('Failed to generate notification icon:', (err as Error).message);
|
||||
|
||||
@@ -1269,12 +1269,16 @@ test('registerIpcHandlers validates dispatchSessionAction payloads', async () =>
|
||||
|
||||
test('registerIpcHandlers forwards valid overlay notification actions', () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const actions: Array<{ notificationId: string; actionId: string }> = [];
|
||||
const actions: Array<{ notificationId: string; actionId: string; noteId?: number }> = [];
|
||||
registerIpcHandlers(
|
||||
createRegisterIpcDeps({
|
||||
handleOverlayNotificationAction: (notificationId: string, actionId: string) => {
|
||||
actions.push({ notificationId, actionId });
|
||||
},
|
||||
handleOverlayNotificationAction: ((
|
||||
notificationId: string,
|
||||
actionId: string,
|
||||
noteId?: number,
|
||||
) => {
|
||||
actions.push({ notificationId, actionId, noteId });
|
||||
}) as IpcServiceDeps['handleOverlayNotificationAction'],
|
||||
} as Partial<IpcServiceDeps>),
|
||||
registrar,
|
||||
);
|
||||
@@ -1285,10 +1289,19 @@ test('registerIpcHandlers forwards valid overlay notification actions', () => {
|
||||
actionHandler({}, null);
|
||||
actionHandler({}, { notificationId: '', actionId: 'install-update' });
|
||||
actionHandler({}, { notificationId: 'subminer-update-available', actionId: 42 });
|
||||
actionHandler(
|
||||
{},
|
||||
{ notificationId: 'anki-update-progress', actionId: 'open-anki-card', noteId: -1 },
|
||||
);
|
||||
actionHandler({}, { notificationId: 'subminer-update-available', actionId: 'install-update' });
|
||||
actionHandler(
|
||||
{},
|
||||
{ notificationId: 'anki-update-progress', actionId: 'open-anki-card', noteId: 42 },
|
||||
);
|
||||
|
||||
assert.deepEqual(actions, [
|
||||
{ notificationId: 'subminer-update-available', actionId: 'install-update' },
|
||||
{ notificationId: 'subminer-update-available', actionId: 'install-update', noteId: undefined },
|
||||
{ notificationId: 'anki-update-progress', actionId: 'open-anki-card', noteId: 42 },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ export interface IpcServiceDeps {
|
||||
handleOverlayNotificationAction?: (
|
||||
notificationId: string,
|
||||
actionId: string,
|
||||
noteId?: number,
|
||||
) => void | Promise<void>;
|
||||
openYomitanSettings: () => void;
|
||||
quitApp: () => void;
|
||||
@@ -230,14 +231,21 @@ function parseSubtitleMiningContext(payload: unknown): SubtitleMiningContext | n
|
||||
|
||||
function parseOverlayNotificationActionPayload(
|
||||
payload: unknown,
|
||||
): { notificationId: string; actionId: string } | null {
|
||||
): { notificationId: string; actionId: string; noteId?: number } | null {
|
||||
if (!payload || typeof payload !== 'object') return null;
|
||||
const record = payload as Record<string, unknown>;
|
||||
const notificationId = record.notificationId;
|
||||
const actionId = record.actionId;
|
||||
const noteId = record.noteId;
|
||||
if (typeof notificationId !== 'string' || notificationId.trim().length === 0) return null;
|
||||
if (typeof actionId !== 'string' || actionId.trim().length === 0) return null;
|
||||
return { notificationId, actionId };
|
||||
if (
|
||||
noteId !== undefined &&
|
||||
(typeof noteId !== 'number' || !Number.isInteger(noteId) || noteId <= 0)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { notificationId, actionId, ...(typeof noteId === 'number' ? { noteId } : {}) };
|
||||
}
|
||||
|
||||
export interface IpcDepsRuntimeOptions {
|
||||
@@ -262,6 +270,7 @@ export interface IpcDepsRuntimeOptions {
|
||||
handleOverlayNotificationAction?: (
|
||||
notificationId: string,
|
||||
actionId: string,
|
||||
noteId?: number,
|
||||
) => void | Promise<void>;
|
||||
openYomitanSettings: () => void;
|
||||
quitApp: () => void;
|
||||
@@ -501,7 +510,11 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
const parsedPayload = parseOverlayNotificationActionPayload(payload);
|
||||
if (!parsedPayload) return;
|
||||
void Promise.resolve(
|
||||
deps.handleOverlayNotificationAction?.(parsedPayload.notificationId, parsedPayload.actionId),
|
||||
deps.handleOverlayNotificationAction?.(
|
||||
parsedPayload.notificationId,
|
||||
parsedPayload.actionId,
|
||||
parsedPayload.noteId,
|
||||
),
|
||||
).catch((error) => {
|
||||
console.warn(
|
||||
'Failed to handle overlay notification action:',
|
||||
|
||||
+22
-1
@@ -145,6 +145,7 @@ import type {
|
||||
UpdateChannel,
|
||||
WindowGeometry,
|
||||
} from './types';
|
||||
import { OPEN_ANKI_CARD_ACTION_ID } from './types';
|
||||
import { AnkiIntegration } from './anki-integration';
|
||||
import { SubtitleTimingTracker } from './subtitle-timing-tracker';
|
||||
import { RuntimeOptionsManager } from './runtime-options';
|
||||
@@ -3480,6 +3481,17 @@ function dismissOverlayNotification(id: string): void {
|
||||
sendOverlayNotificationEvent({ id, dismiss: true });
|
||||
}
|
||||
|
||||
async function openAnkiCardFromNotification(noteId: number): Promise<void> {
|
||||
const activeIntegrationOpen = appState.ankiIntegration?.openNoteInAnki(noteId);
|
||||
if (activeIntegrationOpen) {
|
||||
await activeIntegrationOpen;
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackClient = new AnkiConnectClient(getResolvedConfig().ankiConnect.url);
|
||||
await fallbackClient.openNoteInBrowser(noteId);
|
||||
}
|
||||
|
||||
function toggleNotificationHistoryPanel(): void {
|
||||
broadcastToOverlayWindows(IPC_CHANNELS.event.notificationHistoryToggle);
|
||||
}
|
||||
@@ -6979,7 +6991,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
linuxOverlayInteractiveHint = interactive;
|
||||
applyLinuxOverlayInputShapeFromLatestMeasurement();
|
||||
},
|
||||
handleOverlayNotificationAction: (notificationId, actionId) => {
|
||||
handleOverlayNotificationAction: (notificationId, actionId, noteId) => {
|
||||
if (
|
||||
notificationId === UPDATE_AVAILABLE_NOTIFICATION_ID &&
|
||||
actionId === INSTALL_UPDATE_ACTION_ID
|
||||
@@ -6993,6 +7005,15 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
logger.warn('Failed to install update from overlay notification action:', error);
|
||||
});
|
||||
}
|
||||
if (actionId === OPEN_ANKI_CARD_ACTION_ID && noteId !== undefined) {
|
||||
void openAnkiCardFromNotification(noteId).catch((error) => {
|
||||
logger.warn('Failed to open Anki card from overlay notification action:', error);
|
||||
showConfiguredStatusNotification('Failed to open Anki card in Anki.', {
|
||||
id: 'open-anki-card-failed',
|
||||
variant: 'error',
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
onYoutubePickerResolve: (request) => youtubeFlowRuntime.resolveActivePicker(request),
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
|
||||
@@ -165,10 +165,17 @@ test('subtitle sidebar media path tag is assigned after prefetch succeeds', () =
|
||||
test('update overlay notification action triggers install flow', () => {
|
||||
const source = readMainSource();
|
||||
|
||||
assert.match(source, /handleOverlayNotificationAction:\s*\(notificationId,\s*actionId\)\s*=>/);
|
||||
assert.match(
|
||||
source,
|
||||
/handleOverlayNotificationAction:\s*\(notificationId,\s*actionId,\s*noteId\)\s*=>/,
|
||||
);
|
||||
assert.match(source, /notificationId === UPDATE_AVAILABLE_NOTIFICATION_ID/);
|
||||
assert.match(source, /actionId === INSTALL_UPDATE_ACTION_ID/);
|
||||
assert.match(source, /installWhenAvailable:\s*true/);
|
||||
assert.match(source, /actionId === OPEN_ANKI_CARD_ACTION_ID && noteId !== undefined/);
|
||||
assert.match(source, /appState\.ankiIntegration\?\.openNoteInAnki\(noteId\)/);
|
||||
assert.match(source, /new AnkiConnectClient\(getResolvedConfig\(\)\.ankiConnect\.url/);
|
||||
assert.match(source, /fallbackClient\.openNoteInBrowser\(noteId\)/);
|
||||
});
|
||||
|
||||
test('subtitle change re-prioritizes prefetch around live playback before tokenizing current line', () => {
|
||||
@@ -344,7 +351,10 @@ test('stats server Yomitan note creation honors configured Anki server override
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(addYomitanNoteBlock);
|
||||
assert.match(addYomitanNoteBlock, /const ankiConnectConfig = getResolvedConfig\(\)\.ankiConnect;/);
|
||||
assert.match(
|
||||
addYomitanNoteBlock,
|
||||
/const ankiConnectConfig = getResolvedConfig\(\)\.ankiConnect;/,
|
||||
);
|
||||
assert.match(addYomitanNoteBlock, /shouldForceOverrideYomitanAnkiServer\(ankiConnectConfig\)/);
|
||||
assert.doesNotMatch(addYomitanNoteBlock, /forceOverride:\s*true/);
|
||||
});
|
||||
|
||||
+10
-2
@@ -240,8 +240,16 @@ const electronAPI: ElectronAPI = {
|
||||
},
|
||||
onOverlayPointerRecoveryRequested: onOverlayPointerRecoveryRequestEvent,
|
||||
onOverlayNotification: onOverlayNotificationEvent,
|
||||
sendOverlayNotificationAction: (notificationId: string, actionId: string) => {
|
||||
ipcRenderer.send(IPC_CHANNELS.command.overlayNotificationAction, { notificationId, actionId });
|
||||
sendOverlayNotificationAction: (
|
||||
notificationId: string,
|
||||
actionId: string,
|
||||
options?: { noteId?: number },
|
||||
) => {
|
||||
ipcRenderer.send(IPC_CHANNELS.command.overlayNotificationAction, {
|
||||
notificationId,
|
||||
actionId,
|
||||
...(options?.noteId !== undefined ? { noteId: options.noteId } : {}),
|
||||
});
|
||||
},
|
||||
onNotificationHistoryToggle: onNotificationHistoryToggleEvent,
|
||||
|
||||
|
||||
@@ -130,6 +130,21 @@ test('history store defaults missing variant to info', () => {
|
||||
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;
|
||||
@@ -159,6 +174,59 @@ function createClassList(initialTokens: string[] = []) {
|
||||
};
|
||||
}
|
||||
|
||||
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]),
|
||||
@@ -198,6 +266,7 @@ function createPanelHarness(stackPositionClass: string) {
|
||||
|
||||
const controller = createOverlayNotificationHistoryPanel({
|
||||
dom: {
|
||||
overlay: createFakeElement(),
|
||||
overlayNotificationHistory: panel,
|
||||
overlayNotificationStack: stack,
|
||||
},
|
||||
@@ -235,3 +304,112 @@ test('history panel resyncs the closed side before first open', () => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { OverlayNotificationVariant } from '../types';
|
||||
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';
|
||||
@@ -19,6 +19,7 @@ export type OverlayNotificationHistoryEntry = {
|
||||
body?: string;
|
||||
image?: string;
|
||||
variant: OverlayNotificationVariant;
|
||||
actions?: OverlayNotificationAction[];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
@@ -57,6 +58,7 @@ export function createOverlayNotificationHistoryStore(
|
||||
body: entry.body,
|
||||
image: entry.image,
|
||||
variant: normalizeVariant(entry.variant),
|
||||
actions: entry.actions?.map((action) => ({ ...action })),
|
||||
createdAt: existing?.createdAt ?? timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
@@ -177,6 +179,24 @@ export function createOverlayNotificationHistoryPanel(
|
||||
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';
|
||||
|
||||
@@ -39,6 +39,7 @@ type FakeElement = {
|
||||
dataset: Record<string, string>;
|
||||
children: FakeElement[];
|
||||
classList: ReturnType<typeof createClassList>;
|
||||
replaceChildrenCalls: number;
|
||||
append: (...children: FakeElement[]) => void;
|
||||
replaceChildren: (...children: FakeElement[]) => void;
|
||||
remove: () => void;
|
||||
@@ -61,10 +62,18 @@ function createFakeElement(tagName = 'div'): FakeElement {
|
||||
dataset: {},
|
||||
children: [],
|
||||
classList: createClassList(),
|
||||
replaceChildrenCalls: 0,
|
||||
append: (...children) => {
|
||||
element.children.push(...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.replaceChildrenCalls += 1;
|
||||
element.children = [...children];
|
||||
},
|
||||
setAttribute: (name, value) => {
|
||||
@@ -210,7 +219,7 @@ 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 }> = [];
|
||||
const sentActions: Array<{ notificationId: string; actionId: string; noteId?: number }> = [];
|
||||
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
@@ -228,8 +237,12 @@ test('overlay notification action buttons send action ids', () => {
|
||||
return 1;
|
||||
},
|
||||
electronAPI: {
|
||||
sendOverlayNotificationAction: (notificationId: string, actionId: string) => {
|
||||
sentActions.push({ notificationId, actionId });
|
||||
sendOverlayNotificationAction: (
|
||||
notificationId: string,
|
||||
actionId: string,
|
||||
options?: { noteId?: number },
|
||||
) => {
|
||||
sentActions.push({ notificationId, actionId, noteId: options?.noteId });
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -250,7 +263,7 @@ test('overlay notification action buttons send action ids', () => {
|
||||
title: 'SubMiner update available',
|
||||
body: 'SubMiner v0.15.0 is available',
|
||||
persistent: true,
|
||||
actions: [{ id: 'install-update', label: 'Update' }],
|
||||
actions: [{ id: 'open-anki-card', label: 'Open in Anki', noteId: 42 }],
|
||||
});
|
||||
|
||||
const card = stack.children[0];
|
||||
@@ -265,7 +278,7 @@ test('overlay notification action buttons send action ids', () => {
|
||||
button.dispatchEventType('click');
|
||||
|
||||
assert.deepEqual(sentActions, [
|
||||
{ notificationId: 'subminer-update-available', actionId: 'install-update' },
|
||||
{ notificationId: 'subminer-update-available', actionId: 'open-anki-card', noteId: 42 },
|
||||
]);
|
||||
} finally {
|
||||
if (originalDocument) {
|
||||
@@ -281,6 +294,87 @@ test('overlay notification action buttons send action ids', () => {
|
||||
}
|
||||
});
|
||||
|
||||
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.');
|
||||
}
|
||||
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(card.replaceChildrenCalls, cardReplacements);
|
||||
assert.equal(findChildByClass(card, 'overlay-notification-icon'), spinner);
|
||||
assert.equal(
|
||||
findChildByClass(card, 'overlay-notification-body')?.textContent,
|
||||
'Subsync: syncing /',
|
||||
);
|
||||
} 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,
|
||||
|
||||
@@ -143,6 +143,27 @@ function setInteractiveState(ctx: RendererContext, value: boolean): void {
|
||||
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');
|
||||
}
|
||||
|
||||
export function createOverlayNotificationRenderer(
|
||||
ctx: RendererContext,
|
||||
options: { onChanged?: () => void; onShow?: (entry: OverlayNotificationEntry) => void } = {},
|
||||
@@ -207,39 +228,19 @@ export function createOverlayNotificationRenderer(
|
||||
}
|
||||
}
|
||||
|
||||
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 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 content = document.createElement('div');
|
||||
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;
|
||||
content.append(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;
|
||||
content.append(body);
|
||||
children.push(body);
|
||||
}
|
||||
|
||||
if (entry.actions && entry.actions.length > 0) {
|
||||
@@ -251,12 +252,59 @@ export function createOverlayNotificationRenderer(
|
||||
button.className = 'overlay-notification-action';
|
||||
button.textContent = action.label;
|
||||
button.addEventListener('click', () => {
|
||||
window.electronAPI.sendOverlayNotificationAction?.(entry.id, action.id);
|
||||
window.electronAPI.sendOverlayNotificationAction?.(entry.id, action.id, {
|
||||
noteId: action.noteId,
|
||||
});
|
||||
remove(entry.id);
|
||||
});
|
||||
actions.append(button);
|
||||
}
|
||||
content.append(actions);
|
||||
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');
|
||||
@@ -266,7 +314,7 @@ export function createOverlayNotificationRenderer(
|
||||
closeButton.textContent = '×';
|
||||
closeButton.addEventListener('click', () => remove(entry.id));
|
||||
|
||||
card.replaceChildren(leadingEl, content, closeButton);
|
||||
card.replaceChildren(leadingEl, createContent(entry), closeButton);
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
|
||||
@@ -719,6 +719,38 @@ body:focus-visible,
|
||||
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;
|
||||
|
||||
@@ -14,9 +14,12 @@ export type OverlayNotificationPosition = (typeof OVERLAY_NOTIFICATION_POSITION_
|
||||
|
||||
export type OverlayNotificationVariant = 'info' | 'success' | 'warning' | 'error' | 'progress';
|
||||
|
||||
export const OPEN_ANKI_CARD_ACTION_ID = 'open-anki-card';
|
||||
|
||||
export interface OverlayNotificationAction {
|
||||
id: string;
|
||||
label: string;
|
||||
noteId?: number;
|
||||
}
|
||||
|
||||
export interface OverlayNotificationPayload {
|
||||
|
||||
+10
-2
@@ -41,7 +41,11 @@ import type {
|
||||
RuntimeOptionState,
|
||||
RuntimeOptionValue,
|
||||
} from './runtime-options';
|
||||
import type { OverlayNotificationEventPayload, OverlayNotificationPosition } from './notification';
|
||||
import type {
|
||||
OverlayNotificationAction,
|
||||
OverlayNotificationEventPayload,
|
||||
OverlayNotificationPosition,
|
||||
} from './notification';
|
||||
|
||||
export interface WindowGeometry {
|
||||
x: number;
|
||||
@@ -407,7 +411,11 @@ export interface ElectronAPI {
|
||||
onSubtitle: (callback: (data: SubtitleData) => void) => void;
|
||||
onOverlayPointerRecoveryRequested: (callback: () => void) => void;
|
||||
onOverlayNotification: (callback: (payload: OverlayNotificationEventPayload) => void) => void;
|
||||
sendOverlayNotificationAction?: (notificationId: string, actionId: string) => void;
|
||||
sendOverlayNotificationAction?: (
|
||||
notificationId: string,
|
||||
actionId: string,
|
||||
options?: Pick<OverlayNotificationAction, 'noteId'>,
|
||||
) => void;
|
||||
onNotificationHistoryToggle: (callback: () => void) => void;
|
||||
onVisibility: (callback: (visible: boolean) => void) => void;
|
||||
onSubtitlePosition: (callback: (position: SubtitlePosition | null) => void) => void;
|
||||
|
||||
Reference in New Issue
Block a user