mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-09 15:13:32 -07:00
fix(notifications): show thumbnail image in overlay mined-card notificat
- Share generated notification icon between overlay and system notification paths - Add `image` field to OverlayNotificationPayload; render as IMG with has-image layout - Widen overlay stack to 420px; enlarge card padding and min-height for image variant - Show OSD message after successful anilist retry when attempt key already handled
This commit is contained in:
@@ -7,5 +7,6 @@ breaking: true
|
|||||||
- Routed startup tokenization and subtitle annotation status through the configured notification surfaces; the bundled mpv plugin now only emits startup OSD messages for `osd` and `osd-system`.
|
- Routed startup tokenization and subtitle annotation status through the configured notification surfaces; the bundled mpv plugin now only emits startup OSD messages for `osd` and `osd-system`.
|
||||||
- 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.
|
- 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.
|
- 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.
|
||||||
- Changed `both` notification routing to mean overlay + system; users who used `both` for mpv OSD + system notifications should set `notificationType` to `osd-system` in `config.jsonc`.
|
- Changed `both` notification routing to mean overlay + system; users who used `both` for mpv OSD + system notifications should set `notificationType` to `osd-system` in `config.jsonc`.
|
||||||
- Kept `osd` and `osd-system` as config-file-only legacy notification values; Settings normally offers only overlay, system, both, and none, while still showing an already configured legacy value as selected.
|
- Kept `osd` and `osd-system` as config-file-only legacy notification values; Settings normally offers only overlay, system, both, and none, while still showing an already configured legacy value as selected.
|
||||||
|
|||||||
@@ -223,6 +223,8 @@ Animated AVIF requires an AV1 encoder (`libaom-av1`, `libsvtav1`, or `librav1e`)
|
|||||||
|
|
||||||
`both` now means overlay + system notification. `osd` and `osd-system` are legacy config-file-only values; set `notificationType` to `"osd-system"` in `config.jsonc` if you previously used `both` and want to keep mpv OSD + system notifications. The Settings window shows `osd` or `osd-system` when already configured, but only offers `overlay`, `system`, `both`, and `none` as normal choices.
|
`both` now means overlay + system notification. `osd` and `osd-system` are legacy config-file-only values; set `notificationType` to `"osd-system"` in `config.jsonc` if you previously used `both` and want to keep mpv OSD + system notifications. The Settings window shows `osd` or `osd-system` when already configured, but only offers `overlay`, `system`, `both`, and `none` as normal choices.
|
||||||
|
|
||||||
|
When media is available, mined-card overlay and system notifications include the same current-frame thumbnail.
|
||||||
|
|
||||||
`overwriteAudio` applies to automatic card updates and duplicate-card enrichment. Manual clipboard subtitle updates (`Ctrl/Cmd+C`, then `Ctrl/Cmd+V`) always replace generated sentence audio, while leaving the word audio field unchanged.
|
`overwriteAudio` applies to automatic card updates and duplicate-card enrichment. Manual clipboard subtitle updates (`Ctrl/Cmd+C`, then `Ctrl/Cmd+V`) always replace generated sentence audio, while leaving the word audio field unchanged.
|
||||||
|
|
||||||
## AI Translation
|
## AI Translation
|
||||||
|
|||||||
@@ -3,10 +3,18 @@ import assert from 'node:assert/strict';
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import { pathToFileURL } from 'url';
|
||||||
import { AnkiIntegration } from './anki-integration';
|
import { AnkiIntegration } from './anki-integration';
|
||||||
import { FieldGroupingMergeCollaborator } from './anki-integration/field-grouping-merge';
|
import { FieldGroupingMergeCollaborator } from './anki-integration/field-grouping-merge';
|
||||||
import { AnkiConnectConfig } from './types';
|
import { AnkiConnectConfig } from './types';
|
||||||
|
|
||||||
|
type TestOverlayNotificationPayload = {
|
||||||
|
title: string;
|
||||||
|
body?: string;
|
||||||
|
image?: string;
|
||||||
|
variant?: string;
|
||||||
|
};
|
||||||
|
|
||||||
interface IntegrationTestContext {
|
interface IntegrationTestContext {
|
||||||
integration: AnkiIntegration;
|
integration: AnkiIntegration;
|
||||||
calls: {
|
calls: {
|
||||||
@@ -406,6 +414,81 @@ test('AnkiIntegration marks partial update notifications as failures in OSD mode
|
|||||||
assert.deepEqual(osdMessages, ['x Updated card: taberu (image failed)']);
|
assert.deepEqual(osdMessages, ['x Updated card: taberu (image failed)']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('AnkiIntegration includes 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 }> = [];
|
||||||
|
const cleanupPaths: string[] = [];
|
||||||
|
const notificationIconPath = path.join(os.tmpdir(), 'subminer-notification-icon.png');
|
||||||
|
|
||||||
|
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: (videoPath: string, timestamp: number) => Promise<Buffer>;
|
||||||
|
writeNotificationIconToFile: (iconBuffer: Buffer, noteId: number) => string;
|
||||||
|
scheduleNotificationIconCleanup: (filePath: string) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).mediaGenerator = {
|
||||||
|
generateNotificationIcon: async (videoPath, timestamp) => {
|
||||||
|
generatedFrom.push({ videoPath, timestamp });
|
||||||
|
return Buffer.from('png');
|
||||||
|
},
|
||||||
|
writeNotificationIconToFile: (iconBuffer, noteId) => {
|
||||||
|
assert.equal(iconBuffer.toString(), 'png');
|
||||||
|
assert.equal(noteId, 42);
|
||||||
|
return notificationIconPath;
|
||||||
|
},
|
||||||
|
scheduleNotificationIconCleanup: (filePath) => {
|
||||||
|
cleanupPaths.push(filePath);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await (
|
||||||
|
integration as unknown as {
|
||||||
|
showNotification: (noteId: number, label: string | number) => Promise<void>;
|
||||||
|
}
|
||||||
|
).showNotification(42, '食べる');
|
||||||
|
|
||||||
|
assert.deepEqual(generatedFrom, [{ videoPath: '/tmp/show.mkv', timestamp: 123.45 }]);
|
||||||
|
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.deepEqual(desktopNotifications, [
|
||||||
|
{
|
||||||
|
title: 'Anki Card Updated',
|
||||||
|
body: 'Updated card: 食べる',
|
||||||
|
icon: notificationIconPath,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert.deepEqual(cleanupPaths, [notificationIconPath]);
|
||||||
|
});
|
||||||
|
|
||||||
test('AnkiIntegration routes workflow status notifications through configured surfaces', async () => {
|
test('AnkiIntegration routes workflow status notifications through configured surfaces', async () => {
|
||||||
const osdMessages: string[] = [];
|
const osdMessages: string[] = [];
|
||||||
const desktopMessages: string[] = [];
|
const desktopMessages: string[] = [];
|
||||||
|
|||||||
+38
-25
@@ -20,6 +20,7 @@ import { AnkiConnectClient } from './anki-connect';
|
|||||||
import { SubtitleTimingTracker } from './subtitle-timing-tracker';
|
import { SubtitleTimingTracker } from './subtitle-timing-tracker';
|
||||||
import { MediaGenerator } from './media-generator';
|
import { MediaGenerator } from './media-generator';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { pathToFileURL } from 'url';
|
||||||
import {
|
import {
|
||||||
AnkiConnectConfig,
|
AnkiConnectConfig,
|
||||||
KikuDuplicateCardInfo,
|
KikuDuplicateCardInfo,
|
||||||
@@ -120,6 +121,10 @@ function shouldPreferMediaTitleForMiscInfo(rawPath: string, filename: string): b
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toOverlayNotificationImageSource(filePath: string): string {
|
||||||
|
return pathToFileURL(filePath).toString();
|
||||||
|
}
|
||||||
|
|
||||||
export class AnkiIntegration {
|
export class AnkiIntegration {
|
||||||
private client: AnkiConnectClient;
|
private client: AnkiConnectClient;
|
||||||
private mediaGenerator: MediaGenerator;
|
private mediaGenerator: MediaGenerator;
|
||||||
@@ -1082,29 +1087,49 @@ export class AnkiIntegration {
|
|||||||
this.clearUpdateProgress();
|
this.clearUpdateProgress();
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((type === 'overlay' || type === 'both') && this.overlayNotificationCallback) {
|
const shouldShowOverlayNotification =
|
||||||
|
(type === 'overlay' || type === 'both') && this.overlayNotificationCallback !== null;
|
||||||
|
const shouldShowSystemNotification =
|
||||||
|
(type === 'system' || type === 'both' || type === 'osd-system') &&
|
||||||
|
this.notificationCallback !== null;
|
||||||
|
const notificationIconPath =
|
||||||
|
shouldShowOverlayNotification || shouldShowSystemNotification
|
||||||
|
? await this.generateNotificationIconPath(noteId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (shouldShowOverlayNotification && this.overlayNotificationCallback) {
|
||||||
this.overlayNotificationCallback({
|
this.overlayNotificationCallback({
|
||||||
id: 'anki-update-progress',
|
id: 'anki-update-progress',
|
||||||
title: 'Anki Card Updated',
|
title: 'Anki Card Updated',
|
||||||
body: message,
|
body: message,
|
||||||
|
...(notificationIconPath
|
||||||
|
? { image: toOverlayNotificationImageSource(notificationIconPath) }
|
||||||
|
: {}),
|
||||||
variant: errorSuffix === undefined ? 'success' : 'error',
|
variant: errorSuffix === undefined ? 'success' : 'error',
|
||||||
persistent: false,
|
persistent: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (shouldShowSystemNotification && this.notificationCallback) {
|
||||||
(type === 'system' || type === 'both' || type === 'osd-system') &&
|
this.notificationCallback('Anki Card Updated', {
|
||||||
this.notificationCallback
|
body: message,
|
||||||
) {
|
icon: notificationIconPath,
|
||||||
let notificationIconPath: string | undefined;
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notificationIconPath) {
|
||||||
|
this.mediaGenerator.scheduleNotificationIconCleanup(notificationIconPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateNotificationIconPath(noteId: number): Promise<string | undefined> {
|
||||||
|
if (!this.mpvClient?.currentVideoPath) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.mpvClient && this.mpvClient.currentVideoPath) {
|
|
||||||
try {
|
try {
|
||||||
const timestamp = this.mpvClient.currentTimePos || 0;
|
const timestamp = this.mpvClient.currentTimePos || 0;
|
||||||
const notificationIconSource = await resolveMediaGenerationInputPath(
|
const notificationIconSource = await resolveMediaGenerationInputPath(this.mpvClient, 'video');
|
||||||
this.mpvClient,
|
|
||||||
'video',
|
|
||||||
);
|
|
||||||
if (!notificationIconSource) {
|
if (!notificationIconSource) {
|
||||||
throw new Error('No media source available for notification icon');
|
throw new Error('No media source available for notification icon');
|
||||||
}
|
}
|
||||||
@@ -1113,25 +1138,13 @@ export class AnkiIntegration {
|
|||||||
timestamp,
|
timestamp,
|
||||||
);
|
);
|
||||||
if (iconBuffer && iconBuffer.length > 0) {
|
if (iconBuffer && iconBuffer.length > 0) {
|
||||||
notificationIconPath = this.mediaGenerator.writeNotificationIconToFile(
|
return this.mediaGenerator.writeNotificationIconToFile(iconBuffer, noteId);
|
||||||
iconBuffer,
|
|
||||||
noteId,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.warn('Failed to generate notification icon:', (err as Error).message);
|
log.warn('Failed to generate notification icon:', (err as Error).message);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
this.notificationCallback('Anki Card Updated', {
|
return undefined;
|
||||||
body: message,
|
|
||||||
icon: notificationIconPath,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (notificationIconPath) {
|
|
||||||
this.mediaGenerator.scheduleNotificationIconCleanup(notificationIconPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private showUpdateResult(message: string, success: boolean): void {
|
private showUpdateResult(message: string, success: boolean): void {
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ test('createMaybeRunAnilistPostWatchUpdateHandler skips youtube playback entirel
|
|||||||
assert.deepEqual(calls, []);
|
assert.deepEqual(calls, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('createMaybeRunAnilistPostWatchUpdateHandler does not live-update after retry already handled current attempt key', async () => {
|
test('createMaybeRunAnilistPostWatchUpdateHandler notifies when retry already handled current attempt key', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const attemptedKeys = new Set<string>();
|
const attemptedKeys = new Set<string>();
|
||||||
const mediaKey = '/tmp/video.mkv';
|
const mediaKey = '/tmp/video.mkv';
|
||||||
@@ -378,5 +378,5 @@ test('createMaybeRunAnilistPostWatchUpdateHandler does not live-update after ret
|
|||||||
assert.equal(calls.includes('update'), false);
|
assert.equal(calls.includes('update'), false);
|
||||||
assert.equal(calls.includes('enqueue'), false);
|
assert.equal(calls.includes('enqueue'), false);
|
||||||
assert.equal(calls.includes('mark-failure'), false);
|
assert.equal(calls.includes('mark-failure'), false);
|
||||||
assert.deepEqual(calls, ['inflight:true', 'process-retry', 'inflight:false']);
|
assert.deepEqual(calls, ['inflight:true', 'process-retry', 'osd:retry ok', 'inflight:false']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -194,8 +194,11 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await deps.processNextAnilistRetryUpdate();
|
const retryResult = await deps.processNextAnilistRetryUpdate();
|
||||||
if (deps.hasAttemptedUpdateKey(attemptKey)) {
|
if (deps.hasAttemptedUpdateKey(attemptKey)) {
|
||||||
|
if (retryResult.ok) {
|
||||||
|
deps.showMpvOsd(retryResult.message);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,94 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
createOverlayNotificationRenderer,
|
||||||
createOverlayNotificationStore,
|
createOverlayNotificationStore,
|
||||||
handleOverlayNotificationEvent,
|
handleOverlayNotificationEvent,
|
||||||
overlayNotificationPositionClass,
|
overlayNotificationPositionClass,
|
||||||
} from './overlay-notifications';
|
} 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>;
|
||||||
|
append: (...children: FakeElement[]) => void;
|
||||||
|
replaceChildren: (...children: FakeElement[]) => void;
|
||||||
|
setAttribute: (name: string, value: string) => void;
|
||||||
|
getAttribute: (name: string) => string | null;
|
||||||
|
addEventListener: (type: string, listener: (event?: unknown) => void) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createFakeElement(tagName = 'div'): FakeElement {
|
||||||
|
const attributes = new Map<string, string>();
|
||||||
|
const element: FakeElement = {
|
||||||
|
tagName: tagName.toUpperCase(),
|
||||||
|
className: '',
|
||||||
|
textContent: '',
|
||||||
|
src: '',
|
||||||
|
alt: '',
|
||||||
|
type: '',
|
||||||
|
dataset: {},
|
||||||
|
children: [],
|
||||||
|
classList: createClassList(),
|
||||||
|
append: (...children) => {
|
||||||
|
element.children.push(...children);
|
||||||
|
},
|
||||||
|
replaceChildren: (...children) => {
|
||||||
|
element.children = [...children];
|
||||||
|
},
|
||||||
|
setAttribute: (name, value) => {
|
||||||
|
attributes.set(name, value);
|
||||||
|
},
|
||||||
|
getAttribute: (name) => attributes.get(name) ?? null,
|
||||||
|
addEventListener: () => undefined,
|
||||||
|
};
|
||||||
|
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(process.cwd(), 'src/renderer/style.css'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
test('overlay notification store caps transient notifications and keeps pinned jobs visible', () => {
|
test('overlay notification store caps transient notifications and keeps pinned jobs visible', () => {
|
||||||
const store = createOverlayNotificationStore({ maxVisible: 3 });
|
const store = createOverlayNotificationStore({ maxVisible: 3 });
|
||||||
|
|
||||||
@@ -63,3 +145,68 @@ test('overlay notification event handler dismisses notifications by id', () => {
|
|||||||
|
|
||||||
assert.deepEqual(calls, ['remove:overlay-loading-status']);
|
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 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-card\s*\{[^}]*min-height:\s*76px;/s);
|
||||||
|
assert.match(
|
||||||
|
overlayNotificationCss,
|
||||||
|
/\.overlay-notification-card\.has-image\s*\{[^}]*min-height:\s*88px;/s,
|
||||||
|
);
|
||||||
|
assert.match(overlayNotificationCss, /\.overlay-notification-image\s*\{[^}]*width:\s*56px;/s);
|
||||||
|
assert.match(overlayNotificationCss, /\.overlay-notification-image\s*\{[^}]*height:\s*56px;/s);
|
||||||
|
});
|
||||||
|
|||||||
@@ -122,6 +122,12 @@ function normalizeVariant(
|
|||||||
return variant ?? 'info';
|
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 {
|
function setInteractiveState(ctx: RendererContext, value: boolean): void {
|
||||||
ctx.state.isOverOverlayNotification = value;
|
ctx.state.isOverOverlayNotification = value;
|
||||||
syncOverlayMouseIgnoreState(ctx);
|
syncOverlayMouseIgnoreState(ctx);
|
||||||
@@ -157,14 +163,23 @@ export function createOverlayNotificationRenderer(
|
|||||||
ctx.dom.overlayNotificationStack.classList.add(overlayNotificationPositionClass(position));
|
ctx.dom.overlayNotificationStack.classList.add(overlayNotificationPositionClass(position));
|
||||||
|
|
||||||
for (const entry of visible) {
|
for (const entry of visible) {
|
||||||
|
const imageSource = normalizeImageSource(entry.image);
|
||||||
const card = document.createElement('section');
|
const card = document.createElement('section');
|
||||||
card.className = `overlay-notification-card ${normalizeVariant(entry.variant)}`;
|
card.className = `overlay-notification-card ${normalizeVariant(entry.variant)}${
|
||||||
|
imageSource ? ' has-image' : ''
|
||||||
|
}`;
|
||||||
card.dataset.notificationId = entry.id;
|
card.dataset.notificationId = entry.id;
|
||||||
card.setAttribute('role', 'status');
|
card.setAttribute('role', 'status');
|
||||||
|
|
||||||
const icon = document.createElement('span');
|
const leading = imageSource ? document.createElement('img') : document.createElement('span');
|
||||||
icon.className = 'overlay-notification-icon';
|
leading.className = imageSource ? 'overlay-notification-image' : 'overlay-notification-icon';
|
||||||
icon.setAttribute('aria-hidden', 'true');
|
leading.setAttribute('aria-hidden', 'true');
|
||||||
|
if (imageSource) {
|
||||||
|
const image = leading as HTMLImageElement;
|
||||||
|
image.src = imageSource;
|
||||||
|
image.alt = '';
|
||||||
|
image.decoding = 'async';
|
||||||
|
}
|
||||||
|
|
||||||
const content = document.createElement('div');
|
const content = document.createElement('div');
|
||||||
content.className = 'overlay-notification-content';
|
content.className = 'overlay-notification-content';
|
||||||
@@ -205,7 +220,7 @@ export function createOverlayNotificationRenderer(
|
|||||||
closeButton.textContent = '×';
|
closeButton.textContent = '×';
|
||||||
closeButton.addEventListener('click', () => remove(entry.id));
|
closeButton.addEventListener('click', () => remove(entry.id));
|
||||||
|
|
||||||
card.append(icon, content, closeButton);
|
card.append(leading, content, closeButton);
|
||||||
ctx.dom.overlayNotificationStack.append(card);
|
ctx.dom.overlayNotificationStack.append(card);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+20
-3
@@ -149,7 +149,7 @@ body:focus-visible,
|
|||||||
.overlay-notification-stack {
|
.overlay-notification-stack {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 16px;
|
top: 16px;
|
||||||
width: min(360px, calc(100vw - 32px));
|
width: min(420px, calc(100vw - 32px));
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -184,8 +184,8 @@ body:focus-visible,
|
|||||||
grid-template-columns: 22px minmax(0, 1fr) 22px;
|
grid-template-columns: 22px minmax(0, 1fr) 22px;
|
||||||
gap: 11px;
|
gap: 11px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
min-height: 64px;
|
min-height: 76px;
|
||||||
padding: 13px 14px 13px 17px;
|
padding: 16px 16px 16px 20px;
|
||||||
border-radius: 11px;
|
border-radius: 11px;
|
||||||
border: 1px solid color-mix(in srgb, var(--overlay-notification-accent) 24%, var(--ctp-surface1));
|
border: 1px solid color-mix(in srgb, var(--overlay-notification-accent) 24%, var(--ctp-surface1));
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
@@ -233,6 +233,23 @@ body:focus-visible,
|
|||||||
--overlay-notification-accent: var(--ctp-red);
|
--overlay-notification-accent: var(--ctp-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.overlay-notification-card.has-image {
|
||||||
|
grid-template-columns: 56px minmax(0, 1fr) 22px;
|
||||||
|
min-height: 88px;
|
||||||
|
padding-left: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-notification-image {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
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 {
|
.overlay-notification-icon {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export interface OverlayNotificationPayload {
|
|||||||
id?: string;
|
id?: string;
|
||||||
title: string;
|
title: string;
|
||||||
body?: string;
|
body?: string;
|
||||||
|
image?: string;
|
||||||
variant?: OverlayNotificationVariant;
|
variant?: OverlayNotificationVariant;
|
||||||
position?: OverlayNotificationPosition;
|
position?: OverlayNotificationPosition;
|
||||||
persistent?: boolean;
|
persistent?: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user