feat(accessibility): harden chat and keyboard interactions
Stream capability-authorized chat reads with cancellation, virtualize viewer rendering, centralize modal focus management, and add keyboard-accessible queue and VOD menus. Expose command palette combobox state and keep the document language synchronized with the selected locale.
This commit is contained in:
+11
-6
@@ -9,7 +9,7 @@
|
||||
<link rel="stylesheet" href="./workspace.css">
|
||||
</head>
|
||||
<body class="theme-twitch">
|
||||
<div class="modal-overlay" id="updateModal" role="dialog" aria-modal="true" aria-labelledby="updateModalTitle" onclick="handleUpdateModalOverlayClick(event)">
|
||||
<div class="modal-overlay" id="updateModal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="updateModalTitle" onclick="handleUpdateModalOverlayClick(event)">
|
||||
<div class="modal update-modal">
|
||||
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="dismissUpdateModal()">x</button>
|
||||
<div class="update-modal-eyebrow" id="updateModalEyebrow">Updates</div>
|
||||
@@ -48,7 +48,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Clip Dialog Modal -->
|
||||
<div class="modal-overlay" id="clipModal" role="dialog" aria-modal="true" aria-labelledby="clipDialogTitle">
|
||||
<div class="modal-overlay" id="clipModal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="clipDialogTitle">
|
||||
<div class="modal clip-modal">
|
||||
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="closeClipDialog()">x</button>
|
||||
<h2 class="clip-modal-title" id="clipDialogTitle">VOD zuschneiden</h2>
|
||||
@@ -116,7 +116,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Events Viewer Modal -->
|
||||
<div class="modal-overlay" id="eventsViewerModal" role="dialog" aria-modal="true" aria-labelledby="eventsViewerTitle">
|
||||
<div class="modal-overlay" id="eventsViewerModal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="eventsViewerTitle">
|
||||
<div class="modal viewer-modal viewer-modal-events">
|
||||
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="closeEventsViewer()">x</button>
|
||||
<h2 id="eventsViewerTitle" class="viewer-modal-title"></h2>
|
||||
@@ -126,7 +126,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Chat Replay Viewer Modal -->
|
||||
<div class="modal-overlay" id="chatViewerModal" role="dialog" aria-modal="true" aria-labelledby="chatViewerTitle">
|
||||
<div class="modal-overlay" id="chatViewerModal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="chatViewerTitle">
|
||||
<div class="modal viewer-modal viewer-modal-chat">
|
||||
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="closeChatViewer()">x</button>
|
||||
<h2 id="chatViewerTitle" class="viewer-modal-title"></h2>
|
||||
@@ -139,7 +139,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Template Guide Modal -->
|
||||
<div class="modal-overlay" id="templateGuideModal" role="dialog" aria-modal="true" aria-labelledby="templateGuideTitle">
|
||||
<div class="modal-overlay" id="templateGuideModal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="templateGuideTitle">
|
||||
<div class="modal template-guide-modal">
|
||||
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="closeTemplateGuide()">x</button>
|
||||
<h2 id="templateGuideTitle">Template Guide</h2>
|
||||
@@ -1058,7 +1058,7 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="commandPaletteModal" role="dialog" aria-modal="true" aria-labelledby="commandPaletteTitle">
|
||||
<div class="modal-overlay" id="commandPaletteModal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="commandPaletteTitle">
|
||||
<div class="modal command-palette">
|
||||
<h2 id="commandPaletteTitle" class="cp-title">Befehlspalette</h2>
|
||||
<input
|
||||
@@ -1069,6 +1069,10 @@
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
aria-label="Befehlspalette"
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
aria-controls="commandPaletteList"
|
||||
aria-expanded="false"
|
||||
/>
|
||||
<ul id="commandPaletteList" class="cp-list" role="listbox" aria-label="Befehlsergebnisse"></ul>
|
||||
<p class="cp-hint" id="commandPaletteHint">Up/Down zum Navigieren, Enter zum Ausführen, Esc zum Schließen</p>
|
||||
@@ -1079,6 +1083,7 @@
|
||||
<script src="../dist/renderer-locale-en.js"></script>
|
||||
<script src="../dist/renderer-texts.js"></script>
|
||||
<script src="../dist/renderer-shared.js"></script>
|
||||
<script src="../dist/renderer-accessibility.js"></script>
|
||||
<script src="../dist/renderer-settings.js"></script>
|
||||
<script src="../dist/renderer-streamers.js"></script>
|
||||
<script src="../dist/renderer-queue.js"></script>
|
||||
|
||||
+23
-42
@@ -57,6 +57,7 @@ import { commitQueueMutation, persistStateChange } from './main/domain/persisten
|
||||
import { resolveSecretInputUpdate } from './main/domain/secret-input';
|
||||
import { createSecretStore, type SecretStore } from './main/domain/secret-store';
|
||||
import { createElectronSecureStorage } from './main/infra/secure-storage';
|
||||
import { readChatFile } from './main/domain/chat-reader';
|
||||
import {
|
||||
setDebugLogFn, initToolDirs,
|
||||
getStreamlinkPath, getStreamlinkCommand, getFFmpegPath, getFFprobePath,
|
||||
@@ -7668,6 +7669,7 @@ ipcMain.handle('cancel-download', async (event) => {
|
||||
const fileCapabilities = new FileCapabilityStore();
|
||||
const VIDEO_FILE_EXTENSIONS = ['mp4', 'm4v', 'mov', 'webm', 'mkv', 'ts', 'avi'];
|
||||
const knownRendererPaths = new Map<FileCapabilityPurpose, Set<string>>();
|
||||
const activeChatReadControllers = new Map<number, Map<string, AbortController>>();
|
||||
|
||||
function issueFileCapability(event: IpcMainInvokeEvent, purpose: FileCapabilityPurpose, filePath: string, kind: 'input-file' | 'output-file' | 'directory', extensions: string[] = [], ttlMs?: number): FileCapabilityReference {
|
||||
return fileCapabilities.issue({ ownerId: event.sender.id, purpose, path: filePath, kind, extensions, ttlMs });
|
||||
@@ -8073,52 +8075,31 @@ ipcMain.handle('run-storage-cleanup', (event, options?: { dryRun?: boolean }): C
|
||||
// Read a chat-replay (.chat.json) or live-chat (.chat.jsonl) file and
|
||||
// return a normalized message list the renderer can display directly.
|
||||
// Caps at 50k messages to stop a runaway file from killing the renderer.
|
||||
ipcMain.handle('read-chat-file', (event, capability: string): { success: boolean; error?: string; format?: 'replay' | 'live'; messages?: Array<Record<string, unknown>>; truncated?: boolean; total?: number } => {
|
||||
ipcMain.on('cancel-chat-read', (event, requestId: string) => {
|
||||
if (!isTrustedRendererEvent(event) || typeof requestId !== 'string') return;
|
||||
activeChatReadControllers.get(event.sender.id)?.get(requestId)?.abort();
|
||||
});
|
||||
|
||||
ipcMain.handle('read-chat-file', async (event, capability: string, requestId?: string) => {
|
||||
const filePath = resolveFileCapability(event, capability, 'chat-input', true);
|
||||
if (!filePath) return { success: false, error: 'File access denied' };
|
||||
|
||||
const MAX_MESSAGES = 50000;
|
||||
const controller = new AbortController();
|
||||
const validRequestId = typeof requestId === 'string' && requestId.length > 0 && requestId.length <= 128
|
||||
? requestId
|
||||
: null;
|
||||
if (validRequestId) {
|
||||
const controllers = activeChatReadControllers.get(event.sender.id) ?? new Map<string, AbortController>();
|
||||
controllers.set(validRequestId, controller);
|
||||
activeChatReadControllers.set(event.sender.id, controllers);
|
||||
}
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf-8');
|
||||
if (filePath.toLowerCase().endsWith('.jsonl')) {
|
||||
// JSON Lines (live chat): one object per line, first line may be header
|
||||
const messages: Array<Record<string, unknown>> = [];
|
||||
let truncated = false;
|
||||
const lines = raw.split('\n');
|
||||
let total = 0;
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const obj = JSON.parse(trimmed);
|
||||
if (obj && typeof obj === 'object' && obj.type !== 'header') {
|
||||
total++;
|
||||
if (messages.length < MAX_MESSAGES) messages.push(obj);
|
||||
else truncated = true;
|
||||
return await readChatFile(filePath, { signal: controller.signal });
|
||||
} finally {
|
||||
if (validRequestId) {
|
||||
const controllers = activeChatReadControllers.get(event.sender.id);
|
||||
controllers?.delete(validRequestId);
|
||||
if (controllers?.size === 0) activeChatReadControllers.delete(event.sender.id);
|
||||
}
|
||||
} catch { /* skip bad lines */ }
|
||||
}
|
||||
return { success: true, format: 'live', messages, truncated, total };
|
||||
}
|
||||
|
||||
// .chat.json (VOD replay) — single object with messages array
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.messages)) {
|
||||
return { success: false, error: 'Unsupported chat file format' };
|
||||
}
|
||||
const total = parsed.messages.length;
|
||||
const messages = parsed.messages.length > MAX_MESSAGES
|
||||
? parsed.messages.slice(0, MAX_MESSAGES)
|
||||
: parsed.messages;
|
||||
return {
|
||||
success: true,
|
||||
format: 'replay',
|
||||
messages,
|
||||
truncated: total > MAX_MESSAGES,
|
||||
total
|
||||
};
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) };
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { readChatFile } from './chat-reader';
|
||||
|
||||
describe('chat reader', () => {
|
||||
const directories: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('streams a large live chat without retaining more than the configured message limit', async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'tvm-chat-reader-'));
|
||||
directories.push(directory);
|
||||
const filePath = join(directory, 'stream.chat.jsonl');
|
||||
const lines = Array.from({ length: 4_000 }, (_, index) => JSON.stringify({ type: 'msg', u: `viewer-${index}`, msg: `message-${index}` }));
|
||||
writeFileSync(filePath, lines.join('\n'), 'utf8');
|
||||
|
||||
let yielded = false;
|
||||
const resultPromise = readChatFile(filePath, { maxMessages: 120, yieldEveryChunks: 1 });
|
||||
void new Promise<void>((resolve) => setImmediate(() => {
|
||||
yielded = true;
|
||||
resolve();
|
||||
}));
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(yielded).toBe(true);
|
||||
expect(result).toMatchObject({ success: true, format: 'live', total: 4_000, truncated: true });
|
||||
if (!result.success) throw new Error(result.error || 'Chat reader failed');
|
||||
expect(result.messages).toHaveLength(120);
|
||||
expect(result.messages?.[0]).toMatchObject({ u: 'viewer-0', msg: 'message-0' });
|
||||
expect(result.messages?.[119]).toMatchObject({ u: 'viewer-119', msg: 'message-119' });
|
||||
});
|
||||
|
||||
it('stops an active stream when the caller cancels the read', async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'tvm-chat-reader-'));
|
||||
directories.push(directory);
|
||||
const filePath = join(directory, 'stream.chat.jsonl');
|
||||
writeFileSync(filePath, Array.from({ length: 30_000 }, (_, index) => JSON.stringify({ type: 'msg', msg: String(index) })).join('\n'), 'utf8');
|
||||
const controller = new AbortController();
|
||||
|
||||
const reading = readChatFile(filePath, { signal: controller.signal, yieldEveryChunks: 1 });
|
||||
controller.abort();
|
||||
|
||||
await expect(reading).resolves.toEqual({ success: false, cancelled: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
import { createReadStream } from 'node:fs';
|
||||
|
||||
export interface ChatReadSuccess {
|
||||
success: true;
|
||||
format: 'replay' | 'live';
|
||||
messages: Array<Record<string, unknown>>;
|
||||
truncated: boolean;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ChatReadFailure {
|
||||
success: false;
|
||||
error?: string;
|
||||
cancelled?: boolean;
|
||||
}
|
||||
|
||||
export type ChatReadResult = ChatReadSuccess | ChatReadFailure;
|
||||
|
||||
export interface ChatReadOptions {
|
||||
maxMessages?: number;
|
||||
signal?: AbortSignal;
|
||||
yieldEveryChunks?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_MESSAGES = 50_000;
|
||||
const MAX_BUFFERED_ENTRY_CHARS = 512 * 1024;
|
||||
|
||||
function normalizeMaxMessages(value: number | undefined): number {
|
||||
if (!Number.isInteger(value) || value === undefined || value < 1) return DEFAULT_MAX_MESSAGES;
|
||||
return Math.min(value, DEFAULT_MAX_MESSAGES);
|
||||
}
|
||||
|
||||
function normalizeYieldEveryChunks(value: number | undefined): number {
|
||||
if (!Number.isInteger(value) || value === undefined || value < 1) return 8;
|
||||
return value;
|
||||
}
|
||||
|
||||
function yieldToEventLoop(): Promise<void> {
|
||||
return new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
function addMessage(messages: Array<Record<string, unknown>>, candidate: unknown, maxMessages: number): void {
|
||||
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return;
|
||||
if ((candidate as { type?: unknown }).type === 'header') return;
|
||||
if (messages.length < maxMessages) messages.push(candidate as Record<string, unknown>);
|
||||
}
|
||||
|
||||
function findJsonValueEnd(input: string): number | null {
|
||||
const first = input[0];
|
||||
if (!first) return null;
|
||||
if (first === '"') {
|
||||
let escaped = false;
|
||||
for (let index = 1; index < input.length; index++) {
|
||||
const char = input[index];
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (char === '\\') {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (char === '"') return index + 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (first !== '{' && first !== '[') {
|
||||
const separator = input.search(/[\],]/);
|
||||
return separator < 0 ? null : separator;
|
||||
}
|
||||
const closing = first === '{' ? '}' : ']';
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
for (let index = 0; index < input.length; index++) {
|
||||
const char = input[index];
|
||||
if (inString) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (char === '\\') {
|
||||
escaped = true;
|
||||
} else if (char === '"') {
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (char === '"') {
|
||||
inString = true;
|
||||
continue;
|
||||
}
|
||||
if (char === first) depth++;
|
||||
if (char === closing) {
|
||||
depth--;
|
||||
if (depth === 0) return index + 1;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readLiveChat(filePath: string, maxMessages: number, signal: AbortSignal | undefined, yieldEveryChunks: number): Promise<ChatReadResult> {
|
||||
const messages: Array<Record<string, unknown>> = [];
|
||||
const stream = createReadStream(filePath, { encoding: 'utf8', highWaterMark: 64 * 1024 });
|
||||
const abort = () => stream.destroy();
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
let total = 0;
|
||||
let chunkCount = 0;
|
||||
let carry = '';
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
if (signal?.aborted) return { success: false, cancelled: true };
|
||||
carry += chunk;
|
||||
if (carry.length > MAX_BUFFERED_ENTRY_CHARS && !carry.includes('\n')) throw new Error('Chat line exceeds read limit');
|
||||
let newlineIndex = carry.indexOf('\n');
|
||||
while (newlineIndex >= 0) {
|
||||
const line = carry.slice(0, newlineIndex).trim();
|
||||
carry = carry.slice(newlineIndex + 1);
|
||||
if (line) {
|
||||
try {
|
||||
const candidate = JSON.parse(line);
|
||||
if (candidate && typeof candidate === 'object' && !Array.isArray(candidate) && (candidate as { type?: unknown }).type !== 'header') {
|
||||
total++;
|
||||
addMessage(messages, candidate, maxMessages);
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
newlineIndex = carry.indexOf('\n');
|
||||
}
|
||||
chunkCount++;
|
||||
if (chunkCount % yieldEveryChunks === 0) await yieldToEventLoop();
|
||||
}
|
||||
if (signal?.aborted) return { success: false, cancelled: true };
|
||||
const finalLine = carry.trim();
|
||||
if (finalLine) {
|
||||
try {
|
||||
const candidate = JSON.parse(finalLine);
|
||||
if (candidate && typeof candidate === 'object' && !Array.isArray(candidate) && (candidate as { type?: unknown }).type !== 'header') {
|
||||
total++;
|
||||
addMessage(messages, candidate, maxMessages);
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
return { success: true, format: 'live', messages, truncated: total > messages.length, total };
|
||||
} catch (error) {
|
||||
if (signal?.aborted) return { success: false, cancelled: true };
|
||||
return { success: false, error: String(error) };
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', abort);
|
||||
stream.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
async function readReplayChat(filePath: string, maxMessages: number, signal: AbortSignal | undefined, yieldEveryChunks: number): Promise<ChatReadResult> {
|
||||
const messages: Array<Record<string, unknown>> = [];
|
||||
const stream = createReadStream(filePath, { encoding: 'utf8', highWaterMark: 64 * 1024 });
|
||||
const abort = () => stream.destroy();
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
let total = 0;
|
||||
let chunkCount = 0;
|
||||
let beforeMessages = '';
|
||||
let entries = '';
|
||||
let foundMessages = false;
|
||||
let closed = false;
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
if (signal?.aborted) return { success: false, cancelled: true };
|
||||
if (!foundMessages) {
|
||||
beforeMessages += chunk;
|
||||
const match = /"messages"\s*:\s*\[/.exec(beforeMessages);
|
||||
if (!match || match.index === undefined) {
|
||||
beforeMessages = beforeMessages.slice(-128);
|
||||
continue;
|
||||
}
|
||||
foundMessages = true;
|
||||
entries = beforeMessages.slice(match.index + match[0].length);
|
||||
beforeMessages = '';
|
||||
} else {
|
||||
entries += chunk;
|
||||
}
|
||||
if (entries.length > MAX_BUFFERED_ENTRY_CHARS) throw new Error('Chat entry exceeds read limit');
|
||||
while (entries) {
|
||||
const leading = /^\s*,?\s*/.exec(entries)?.[0] ?? '';
|
||||
entries = entries.slice(leading.length);
|
||||
if (!entries) break;
|
||||
if (entries[0] === ']') {
|
||||
closed = true;
|
||||
entries = '';
|
||||
break;
|
||||
}
|
||||
const valueEnd = findJsonValueEnd(entries);
|
||||
if (valueEnd === null) break;
|
||||
const value = entries.slice(0, valueEnd);
|
||||
entries = entries.slice(valueEnd);
|
||||
try {
|
||||
const candidate = JSON.parse(value);
|
||||
if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) {
|
||||
total++;
|
||||
addMessage(messages, candidate, maxMessages);
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
chunkCount++;
|
||||
if (chunkCount % yieldEveryChunks === 0) await yieldToEventLoop();
|
||||
if (closed) break;
|
||||
}
|
||||
if (signal?.aborted) return { success: false, cancelled: true };
|
||||
if (!foundMessages || !closed) return { success: false, error: 'Unsupported chat file format' };
|
||||
return { success: true, format: 'replay', messages, truncated: total > messages.length, total };
|
||||
} catch (error) {
|
||||
if (signal?.aborted) return { success: false, cancelled: true };
|
||||
return { success: false, error: String(error) };
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', abort);
|
||||
stream.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export async function readChatFile(filePath: string, options: ChatReadOptions = {}): Promise<ChatReadResult> {
|
||||
if (options.signal?.aborted) return { success: false, cancelled: true };
|
||||
const maxMessages = normalizeMaxMessages(options.maxMessages);
|
||||
const yieldEveryChunks = normalizeYieldEveryChunks(options.yieldEveryChunks);
|
||||
return filePath.toLowerCase().endsWith('.jsonl')
|
||||
? readLiveChat(filePath, maxMessages, options.signal, yieldEveryChunks)
|
||||
: readReplayChat(filePath, maxMessages, options.signal, yieldEveryChunks);
|
||||
}
|
||||
+13
-4
@@ -1,6 +1,8 @@
|
||||
import { contextBridge, ipcRenderer, webUtils } from 'electron';
|
||||
import { CustomClip, MergeGroupItem, MergeGroup, QueueItem, DownloadProgress } from './types';
|
||||
|
||||
let chatReadSequence = 0;
|
||||
|
||||
// Types
|
||||
interface RuntimeMetricsSnapshot {
|
||||
cacheHits: number;
|
||||
@@ -164,11 +166,18 @@ contextBridge.exposeInMainWorld('api', {
|
||||
},
|
||||
searchArchive: (filter: Record<string, unknown>) => ipcRenderer.invoke('search-archive', filter),
|
||||
runStorageCleanup: (options?: { dryRun?: boolean }) => ipcRenderer.invoke('run-storage-cleanup', options),
|
||||
readChatFile: async (filePath: string) => {
|
||||
readChatFile: async (filePath: string, signal?: AbortSignal) => {
|
||||
const capability = await ipcRenderer.invoke('authorize-managed-path', 'chat-input', filePath);
|
||||
return capability
|
||||
? ipcRenderer.invoke('read-chat-file', capability.token)
|
||||
: { success: false, error: 'File access denied' };
|
||||
if (!capability) return { success: false, error: 'File access denied' };
|
||||
if (signal?.aborted) return { success: false, cancelled: true };
|
||||
const requestId = `chat-${Date.now()}-${++chatReadSequence}`;
|
||||
const cancel = () => ipcRenderer.send('cancel-chat-read', requestId);
|
||||
signal?.addEventListener('abort', cancel, { once: true });
|
||||
try {
|
||||
return await ipcRenderer.invoke('read-chat-file', capability.token, requestId);
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', cancel);
|
||||
}
|
||||
},
|
||||
getAutomationStatus: () => ipcRenderer.invoke('get-automation-status'),
|
||||
triggerAutoVodScan: () => ipcRenderer.invoke('trigger-auto-vod-scan'),
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import { transpileModule, ModuleKind, ScriptTarget } from 'typescript';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
interface RendererAccessibilityApi {
|
||||
RenderGeneration: new () => { next(): number; isCurrent(generation: number): boolean; cancel(): void };
|
||||
getVirtualRange(scrollTop: number, viewportHeight: number, itemCount: number, rowHeight: number, overscan: number): { start: number; end: number };
|
||||
getNextFocusIndex(activeIndex: number, count: number, shiftKey: boolean): number;
|
||||
getNextMenuIndex(activeIndex: number, count: number, key: string): number | null;
|
||||
setDocumentLanguage(language: string): string;
|
||||
}
|
||||
|
||||
function loadAccessibility(documentElement: { lang: string } = { lang: '' }): RendererAccessibilityApi {
|
||||
const source = readFileSync(join(__dirname, 'renderer-accessibility.ts'), 'utf8');
|
||||
const context = {
|
||||
document: { documentElement, addEventListener: () => undefined },
|
||||
requestAnimationFrame: (callback: () => void) => callback(),
|
||||
HTMLElement: class {},
|
||||
Element: class {},
|
||||
Node: class {},
|
||||
};
|
||||
const output = transpileModule(source, { compilerOptions: { module: ModuleKind.None, target: ScriptTarget.ES2022 } }).outputText;
|
||||
runInNewContext(output, context);
|
||||
return (context as unknown as { RendererAccessibility: RendererAccessibilityApi }).RendererAccessibility;
|
||||
}
|
||||
|
||||
describe('renderer accessibility helpers', () => {
|
||||
it('replaces stale chat work after rapid filters and close cancellation', () => {
|
||||
const { RenderGeneration } = loadAccessibility();
|
||||
const generations = new RenderGeneration();
|
||||
const initial = generations.next();
|
||||
const rapidFilter = generations.next();
|
||||
generations.cancel();
|
||||
|
||||
expect(generations.isCurrent(initial)).toBe(false);
|
||||
expect(generations.isCurrent(rapidFilter)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a large chat render window bounded to the visible rows', () => {
|
||||
const { getVirtualRange } = loadAccessibility();
|
||||
expect(getVirtualRange(29_000, 580, 50_000, 29, 12)).toEqual({ start: 988, end: 1_032 });
|
||||
});
|
||||
|
||||
it('wraps dialog tab order at the first and last focusable controls', () => {
|
||||
const { getNextFocusIndex } = loadAccessibility();
|
||||
expect(getNextFocusIndex(2, 3, false)).toBe(0);
|
||||
expect(getNextFocusIndex(0, 3, true)).toBe(2);
|
||||
});
|
||||
|
||||
it('moves keyboard menus with arrows, Home, End and Escape semantics', () => {
|
||||
const { getNextMenuIndex } = loadAccessibility();
|
||||
expect(getNextMenuIndex(1, 4, 'ArrowDown')).toBe(2);
|
||||
expect(getNextMenuIndex(0, 4, 'ArrowUp')).toBe(3);
|
||||
expect(getNextMenuIndex(2, 4, 'Home')).toBe(0);
|
||||
expect(getNextMenuIndex(1, 4, 'End')).toBe(3);
|
||||
expect(getNextMenuIndex(1, 4, 'Escape')).toBeNull();
|
||||
});
|
||||
|
||||
it('updates the document language when the application language changes', () => {
|
||||
const documentElement = { lang: 'en' };
|
||||
const { setDocumentLanguage } = loadAccessibility(documentElement);
|
||||
expect(setDocumentLanguage('de')).toBe('de');
|
||||
expect(documentElement.lang).toBe('de');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
interface RendererDialogOptions {
|
||||
initialFocus?: HTMLElement | string | null;
|
||||
onEscape?: () => void;
|
||||
}
|
||||
|
||||
interface RendererOpenDialogState {
|
||||
returnFocus: HTMLElement | null;
|
||||
onEscape?: () => void;
|
||||
}
|
||||
|
||||
const RendererAccessibility = (() => {
|
||||
const dialogStack: string[] = [];
|
||||
const dialogStates = new Map<string, RendererOpenDialogState>();
|
||||
const focusSelector = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
class RenderGeneration {
|
||||
private value = 0;
|
||||
|
||||
next(): number {
|
||||
this.value += 1;
|
||||
return this.value;
|
||||
}
|
||||
|
||||
isCurrent(generation: number): boolean {
|
||||
return generation === this.value;
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.next();
|
||||
}
|
||||
}
|
||||
|
||||
function getVirtualRange(scrollTop: number, viewportHeight: number, itemCount: number, rowHeight: number, overscan: number): { start: number; end: number } {
|
||||
if (itemCount <= 0 || rowHeight <= 0) return { start: 0, end: 0 };
|
||||
const firstVisible = Math.max(0, Math.floor(scrollTop / rowHeight));
|
||||
const visibleCount = Math.max(1, Math.ceil(viewportHeight / rowHeight));
|
||||
return {
|
||||
start: Math.max(0, firstVisible - Math.max(0, overscan)),
|
||||
end: Math.min(itemCount, firstVisible + visibleCount + Math.max(0, overscan))
|
||||
};
|
||||
}
|
||||
|
||||
function getNextFocusIndex(activeIndex: number, count: number, shiftKey: boolean): number {
|
||||
if (count < 1) return -1;
|
||||
if (shiftKey) return activeIndex <= 0 ? count - 1 : activeIndex - 1;
|
||||
return activeIndex >= count - 1 ? 0 : activeIndex + 1;
|
||||
}
|
||||
|
||||
function getNextMenuIndex(activeIndex: number, count: number, key: string): number | null {
|
||||
if (count < 1 || key === 'Escape') return null;
|
||||
if (key === 'Home') return 0;
|
||||
if (key === 'End') return count - 1;
|
||||
if (key === 'ArrowDown') return activeIndex >= count - 1 ? 0 : activeIndex + 1;
|
||||
if (key === 'ArrowUp') return activeIndex <= 0 ? count - 1 : activeIndex - 1;
|
||||
return activeIndex;
|
||||
}
|
||||
|
||||
function setDocumentLanguage(language: string): string {
|
||||
const normalized = language === 'en' ? 'en' : 'de';
|
||||
document.documentElement.lang = normalized;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function getFocusable(dialog: HTMLElement): HTMLElement[] {
|
||||
return Array.from(dialog.querySelectorAll<HTMLElement>(focusSelector)).filter((element) => element.getAttribute('aria-hidden') !== 'true');
|
||||
}
|
||||
|
||||
function getInitialFocus(dialog: HTMLElement, initialFocus: RendererDialogOptions['initialFocus']): HTMLElement | null {
|
||||
if (typeof initialFocus === 'string') return dialog.querySelector<HTMLElement>(initialFocus);
|
||||
if (initialFocus && dialog.contains(initialFocus)) return initialFocus;
|
||||
return getFocusable(dialog)[0] ?? null;
|
||||
}
|
||||
|
||||
function syncBackgroundInertness(): void {
|
||||
const shell = document.querySelector<HTMLElement>('.workspace-shell');
|
||||
if (shell) shell.inert = dialogStack.length > 0;
|
||||
}
|
||||
|
||||
function isDialogOpen(id: string): boolean {
|
||||
return dialogStack.includes(id);
|
||||
}
|
||||
|
||||
function openDialog(id: string, options: RendererDialogOptions = {}): void {
|
||||
const dialog = document.getElementById(id);
|
||||
if (!(dialog instanceof HTMLElement)) return;
|
||||
const existing = dialogStack.indexOf(id);
|
||||
if (existing >= 0) {
|
||||
dialogStack.splice(existing, 1);
|
||||
} else {
|
||||
const active = document.activeElement;
|
||||
dialogStates.set(id, {
|
||||
returnFocus: active instanceof HTMLElement && active !== document.body ? active : null,
|
||||
onEscape: options.onEscape
|
||||
});
|
||||
}
|
||||
dialogStack.push(id);
|
||||
dialog.classList.add('show');
|
||||
dialog.setAttribute('aria-hidden', 'false');
|
||||
syncBackgroundInertness();
|
||||
requestAnimationFrame(() => getInitialFocus(dialog, options.initialFocus)?.focus());
|
||||
}
|
||||
|
||||
function closeDialog(id: string): void {
|
||||
const dialog = document.getElementById(id);
|
||||
if (!(dialog instanceof HTMLElement)) return;
|
||||
const index = dialogStack.indexOf(id);
|
||||
if (index < 0) return;
|
||||
const wasTopmost = index === dialogStack.length - 1;
|
||||
const state = dialogStates.get(id);
|
||||
dialogStack.splice(index, 1);
|
||||
dialogStates.delete(id);
|
||||
dialog.classList.remove('show');
|
||||
dialog.setAttribute('aria-hidden', 'true');
|
||||
syncBackgroundInertness();
|
||||
if (wasTopmost && state?.returnFocus?.isConnected) requestAnimationFrame(() => state.returnFocus?.focus());
|
||||
}
|
||||
|
||||
function closeTopmostDialog(): boolean {
|
||||
const id = dialogStack.at(-1);
|
||||
if (!id) return false;
|
||||
const state = dialogStates.get(id);
|
||||
if (state?.onEscape) state.onEscape();
|
||||
else closeDialog(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
function installMenuKeyboardNavigation(menu: HTMLElement, close: () => void): void {
|
||||
menu.addEventListener('keydown', (event) => {
|
||||
const items = Array.from(menu.querySelectorAll<HTMLElement>('[role="menuitem"]')).filter((item) => item.getAttribute('aria-disabled') !== 'true' && !(item instanceof HTMLButtonElement && item.disabled));
|
||||
const activeIndex = items.indexOf(document.activeElement as HTMLElement);
|
||||
const nextIndex = getNextMenuIndex(activeIndex < 0 ? 0 : activeIndex, items.length, event.key);
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
close();
|
||||
return;
|
||||
}
|
||||
if (nextIndex === activeIndex || nextIndex === null) return;
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Home' || event.key === 'End') {
|
||||
event.preventDefault();
|
||||
items[nextIndex]?.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function focusFirstMenuItem(menu: HTMLElement): void {
|
||||
menu.querySelector<HTMLElement>('[role="menuitem"]:not([aria-disabled="true"]):not([disabled])')?.focus();
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
const id = dialogStack.at(-1);
|
||||
if (!id) return;
|
||||
const dialog = document.getElementById(id);
|
||||
if (!(dialog instanceof HTMLElement)) return;
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
closeTopmostDialog();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
const focusable = getFocusable(dialog);
|
||||
if (focusable.length === 0) {
|
||||
event.preventDefault();
|
||||
dialog.focus();
|
||||
return;
|
||||
}
|
||||
const activeIndex = focusable.indexOf(document.activeElement as HTMLElement);
|
||||
const nextIndex = getNextFocusIndex(activeIndex, focusable.length, event.shiftKey);
|
||||
if (activeIndex < 0 || nextIndex !== activeIndex + (event.shiftKey ? -1 : 1)) {
|
||||
event.preventDefault();
|
||||
focusable[nextIndex]?.focus();
|
||||
}
|
||||
}, true);
|
||||
|
||||
return {
|
||||
RenderGeneration,
|
||||
getVirtualRange,
|
||||
getNextFocusIndex,
|
||||
getNextMenuIndex,
|
||||
setDocumentLanguage,
|
||||
isDialogOpen,
|
||||
openDialog,
|
||||
closeDialog,
|
||||
closeTopmostDialog,
|
||||
installMenuKeyboardNavigation,
|
||||
focusFirstMenuItem,
|
||||
};
|
||||
})();
|
||||
|
||||
Object.assign(globalThis, { RendererAccessibility });
|
||||
@@ -99,6 +99,7 @@ interface PaletteCommand {
|
||||
clearList(list);
|
||||
STORE.filtered.forEach((cmd, idx) => {
|
||||
const li = document.createElement('li');
|
||||
li.id = `commandPaletteOption-${idx}`;
|
||||
li.className = 'cp-item' + (idx === STORE.activeIndex ? ' cp-active' : '');
|
||||
li.dataset.cmdId = cmd.id;
|
||||
li.setAttribute('role', 'option');
|
||||
@@ -124,6 +125,8 @@ interface PaletteCommand {
|
||||
|
||||
list.appendChild(li);
|
||||
});
|
||||
const input = getInput();
|
||||
if (input) input.setAttribute('aria-activedescendant', STORE.filtered[STORE.activeIndex] ? `commandPaletteOption-${STORE.activeIndex}` : '');
|
||||
}
|
||||
|
||||
function applyFilter(query: string) {
|
||||
@@ -133,9 +136,7 @@ interface PaletteCommand {
|
||||
} else {
|
||||
STORE.filtered = STORE.commands.filter(c => c.keywords.includes(q));
|
||||
}
|
||||
if (STORE.activeIndex >= STORE.filtered.length) {
|
||||
STORE.activeIndex = STORE.filtered.length > 0 ? STORE.filtered.length - 1 : 0;
|
||||
}
|
||||
STORE.activeIndex = 0;
|
||||
render();
|
||||
}
|
||||
|
||||
@@ -158,15 +159,17 @@ interface PaletteCommand {
|
||||
STORE.filtered = STORE.commands.slice();
|
||||
STORE.activeIndex = 0;
|
||||
input.value = '';
|
||||
modal.classList.add('show');
|
||||
requestAnimationFrame(() => input.focus());
|
||||
render();
|
||||
input.setAttribute('aria-expanded', 'true');
|
||||
RendererAccessibility.openDialog('commandPaletteModal', { initialFocus: input, onEscape: close });
|
||||
}
|
||||
|
||||
function close() {
|
||||
const modal = getModal();
|
||||
if (!modal) return;
|
||||
modal.classList.remove('show');
|
||||
getInput()?.setAttribute('aria-expanded', 'false');
|
||||
getInput()?.removeAttribute('aria-activedescendant');
|
||||
RendererAccessibility.closeDialog('commandPaletteModal');
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
|
||||
+5
-18
@@ -61,7 +61,6 @@ let cutterScrubSeekInFlight = false;
|
||||
let cutterScrubResumePlayback = false;
|
||||
let cutterScrubGeneration = 0;
|
||||
let cutterDiscardResolver: ((discard: boolean) => void) | null = null;
|
||||
let cutterDiscardReturnFocus: HTMLElement | null = null;
|
||||
const cutterMaximumCuts = 64;
|
||||
const cutterFrameTolerance = 1e-8;
|
||||
|
||||
@@ -905,17 +904,10 @@ async function loadCutterFromPath(file: FileCapabilityReference): Promise<void>
|
||||
}
|
||||
|
||||
function resolveCutterDiscard(discard: boolean): void {
|
||||
const modal = byId('cutterDiscardModal');
|
||||
modal.classList.remove('show');
|
||||
modal.setAttribute('aria-hidden', 'true');
|
||||
const shell = document.querySelector<HTMLElement>('.workspace-shell');
|
||||
if (shell) shell.inert = false;
|
||||
RendererAccessibility.closeDialog('cutterDiscardModal');
|
||||
const resolver = cutterDiscardResolver;
|
||||
const returnFocus = cutterDiscardReturnFocus;
|
||||
cutterDiscardResolver = null;
|
||||
cutterDiscardReturnFocus = null;
|
||||
resolver?.(discard);
|
||||
if (returnFocus?.isConnected) requestAnimationFrame(() => returnFocus.focus());
|
||||
}
|
||||
|
||||
function handleCutterDiscardOverlayClick(event: MouseEvent): void {
|
||||
@@ -938,15 +930,10 @@ function trapCutterDiscardFocus(event: KeyboardEvent): void {
|
||||
function confirmCutterReplacement(file: FileCapabilityReference): Promise<boolean> {
|
||||
if (!cutterFile || !cutterEditorState || cutterFile.token === file.token) return Promise.resolve(true);
|
||||
if (cutterDiscardResolver) resolveCutterDiscard(false);
|
||||
const modal = byId('cutterDiscardModal');
|
||||
cutterDiscardReturnFocus = document.activeElement instanceof HTMLElement && document.activeElement !== document.body
|
||||
? document.activeElement
|
||||
: null;
|
||||
const shell = document.querySelector<HTMLElement>('.workspace-shell');
|
||||
if (shell) shell.inert = true;
|
||||
modal.classList.add('show');
|
||||
modal.setAttribute('aria-hidden', 'false');
|
||||
requestAnimationFrame(() => byId<HTMLButtonElement>('cutterDiscardCancelBtn').focus());
|
||||
RendererAccessibility.openDialog('cutterDiscardModal', {
|
||||
initialFocus: byId<HTMLButtonElement>('cutterDiscardCancelBtn'),
|
||||
onEscape: () => resolveCutterDiscard(false)
|
||||
});
|
||||
return new Promise((resolve) => { cutterDiscardResolver = resolve; });
|
||||
}
|
||||
|
||||
|
||||
Vendored
+1
-1
@@ -437,7 +437,7 @@ interface ApiBridge {
|
||||
limit?: number;
|
||||
}): Promise<ArchiveSearchResult>;
|
||||
runStorageCleanup(options?: { dryRun?: boolean }): Promise<CleanupReport>;
|
||||
readChatFile(filePath: string): Promise<{ success: boolean; error?: string; format?: 'replay' | 'live'; messages?: Array<Record<string, unknown>>; truncated?: boolean; total?: number }>;
|
||||
readChatFile(filePath: string, signal?: AbortSignal): Promise<{ success: boolean; error?: string; cancelled?: boolean; format?: 'replay' | 'live'; messages?: Array<Record<string, unknown>>; truncated?: boolean; total?: number }>;
|
||||
getAutomationStatus(): Promise<{
|
||||
autoRecord: { watching: number; lastRunAt: number; nextRunAt: number; lastTriggeredCount: number; inFlight: boolean };
|
||||
autoVod: { watching: number; lastRunAt: number; nextRunAt: number; lastQueuedCount: number; inFlight: boolean };
|
||||
|
||||
+31
-13
@@ -157,11 +157,15 @@ async function retryQueueItem(id: string): Promise<void> {
|
||||
|
||||
let queueContextMenuInitialized = false;
|
||||
let activeQueueContextMenu: HTMLElement | null = null;
|
||||
let activeQueueContextMenuInvoker: HTMLElement | null = null;
|
||||
|
||||
function closeQueueContextMenu(): void {
|
||||
function closeQueueContextMenu(restoreFocus = false): void {
|
||||
if (!activeQueueContextMenu) return;
|
||||
activeQueueContextMenu.remove();
|
||||
activeQueueContextMenu = null;
|
||||
const invoker = activeQueueContextMenuInvoker;
|
||||
activeQueueContextMenuInvoker = null;
|
||||
if (restoreFocus && invoker?.isConnected) invoker.focus();
|
||||
}
|
||||
|
||||
function initQueueContextMenu(): void {
|
||||
@@ -177,26 +181,42 @@ function initQueueContextMenu(): void {
|
||||
const item = queue.find((i) => i.id === id);
|
||||
if (!item) return;
|
||||
e.preventDefault();
|
||||
showQueueContextMenu(e.clientX, e.clientY, item);
|
||||
showQueueContextMenu(e.clientX, e.clientY, item, e.target as HTMLElement);
|
||||
});
|
||||
list.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key !== 'ContextMenu' && !(e.shiftKey && e.key === 'F10')) return;
|
||||
const target = e.target as HTMLElement;
|
||||
const itemEl = target.closest('.queue-item') as HTMLElement | null;
|
||||
const id = itemEl?.dataset.id;
|
||||
const item = id ? queue.find((candidate) => candidate.id === id) : null;
|
||||
if (!item || !itemEl) return;
|
||||
e.preventDefault();
|
||||
const rect = itemEl.getBoundingClientRect();
|
||||
showQueueContextMenu(rect.left + 12, rect.top + 12, item, target);
|
||||
});
|
||||
}
|
||||
|
||||
function showQueueContextMenu(x: number, y: number, item: QueueItem): void {
|
||||
function showQueueContextMenu(x: number, y: number, item: QueueItem, invoker: HTMLElement | null): void {
|
||||
closeQueueContextMenu();
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'context-menu';
|
||||
menu.setAttribute('role', 'menu');
|
||||
|
||||
let cleanup = (restoreFocus = false): void => closeQueueContextMenu(restoreFocus);
|
||||
const makeItem = (label: string, onClick: () => void, disabled = false): HTMLElement => {
|
||||
const el = document.createElement('div');
|
||||
const el = document.createElement('button');
|
||||
el.type = 'button';
|
||||
el.textContent = label;
|
||||
el.className = 'context-menu-item' + (disabled ? ' disabled' : '');
|
||||
el.setAttribute('role', 'menuitem');
|
||||
if (disabled) el.setAttribute('aria-disabled', 'true');
|
||||
if (disabled) {
|
||||
el.setAttribute('aria-disabled', 'true');
|
||||
el.disabled = true;
|
||||
}
|
||||
if (!disabled) {
|
||||
el.addEventListener('click', () => {
|
||||
try { onClick(); } finally { closeQueueContextMenu(); }
|
||||
try { onClick(); } finally { cleanup(); }
|
||||
});
|
||||
}
|
||||
return el;
|
||||
@@ -260,6 +280,7 @@ function showQueueContextMenu(x: number, y: number, item: QueueItem): void {
|
||||
|
||||
document.body.appendChild(menu);
|
||||
activeQueueContextMenu = menu;
|
||||
activeQueueContextMenuInvoker = invoker;
|
||||
|
||||
const rect = menu.getBoundingClientRect();
|
||||
let left = x;
|
||||
@@ -274,19 +295,16 @@ function showQueueContextMenu(x: number, y: number, item: QueueItem): void {
|
||||
if (ev.target instanceof Node && activeQueueContextMenu.contains(ev.target)) return;
|
||||
cleanup();
|
||||
};
|
||||
const dismissOnEscape = (ev: KeyboardEvent) => {
|
||||
if (ev.key === 'Escape') cleanup();
|
||||
};
|
||||
const dismissOnScroll = () => cleanup();
|
||||
const cleanup = (): void => {
|
||||
closeQueueContextMenu();
|
||||
cleanup = (restoreFocus = false): void => {
|
||||
closeQueueContextMenu(restoreFocus);
|
||||
document.removeEventListener('mousedown', dismissOnClick, true);
|
||||
document.removeEventListener('keydown', dismissOnEscape, true);
|
||||
document.removeEventListener('scroll', dismissOnScroll, true);
|
||||
};
|
||||
document.addEventListener('mousedown', dismissOnClick, true);
|
||||
document.addEventListener('keydown', dismissOnEscape, true);
|
||||
document.addEventListener('scroll', dismissOnScroll, true);
|
||||
RendererAccessibility.installMenuKeyboardNavigation(menu, () => cleanup(true));
|
||||
RendererAccessibility.focusFirstMenuItem(menu);
|
||||
}
|
||||
|
||||
async function moveQueueItemTo(id: string, where: 'top' | 'bottom'): Promise<void> {
|
||||
|
||||
+27
-22
@@ -1064,15 +1064,23 @@ function initVodGridSelectionDelegation(): void {
|
||||
const ctx = readVodCardContext(card);
|
||||
if (!ctx) return;
|
||||
e.preventDefault();
|
||||
showVodContextMenu(e.clientX, e.clientY, ctx);
|
||||
showVodContextMenu(e.clientX, e.clientY, ctx, card);
|
||||
});
|
||||
|
||||
grid.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (!target) return;
|
||||
const card = target.closest('.vod-card') as HTMLElement | null;
|
||||
if (!card || card !== target) return;
|
||||
if (e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10')) {
|
||||
const ctx = readVodCardContext(card);
|
||||
if (!ctx) return;
|
||||
e.preventDefault();
|
||||
const rect = card.getBoundingClientRect();
|
||||
showVodContextMenu(rect.left + 12, rect.top + 12, ctx, card);
|
||||
return;
|
||||
}
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
e.preventDefault();
|
||||
toggleVodCardSelection(card);
|
||||
});
|
||||
@@ -1096,14 +1104,18 @@ function toggleVodCardSelection(card: HTMLElement): void {
|
||||
}
|
||||
|
||||
let activeVodContextMenu: HTMLElement | null = null;
|
||||
let activeVodContextMenuInvoker: HTMLElement | null = null;
|
||||
|
||||
function closeVodContextMenu(): void {
|
||||
function closeVodContextMenu(restoreFocus = false): void {
|
||||
if (!activeVodContextMenu) return;
|
||||
activeVodContextMenu.remove();
|
||||
activeVodContextMenu = null;
|
||||
const invoker = activeVodContextMenuInvoker;
|
||||
activeVodContextMenuInvoker = null;
|
||||
if (restoreFocus && invoker?.isConnected) invoker.focus();
|
||||
}
|
||||
|
||||
function showVodContextMenu(x: number, y: number, ctx: VodCardContext): void {
|
||||
function showVodContextMenu(x: number, y: number, ctx: VodCardContext, invoker: HTMLElement | null): void {
|
||||
closeVodContextMenu();
|
||||
|
||||
const menu = document.createElement('div');
|
||||
@@ -1117,13 +1129,15 @@ function showVodContextMenu(x: number, y: number, ctx: VodCardContext): void {
|
||||
);
|
||||
const isMarkedDownloaded = downloadedIds.has(ctx.id);
|
||||
|
||||
let cleanup = (restoreFocus = false): void => closeVodContextMenu(restoreFocus);
|
||||
const makeItem = (label: string, onClick: () => void): HTMLElement => {
|
||||
const el = document.createElement('div');
|
||||
const el = document.createElement('button');
|
||||
el.type = 'button';
|
||||
el.textContent = label;
|
||||
el.className = 'context-menu-item';
|
||||
el.setAttribute('role', 'menuitem');
|
||||
el.addEventListener('click', () => {
|
||||
try { onClick(); } finally { closeVodContextMenu(); }
|
||||
try { onClick(); } finally { cleanup(); }
|
||||
});
|
||||
return el;
|
||||
};
|
||||
@@ -1151,6 +1165,7 @@ function showVodContextMenu(x: number, y: number, ctx: VodCardContext): void {
|
||||
|
||||
document.body.appendChild(menu);
|
||||
activeVodContextMenu = menu;
|
||||
activeVodContextMenuInvoker = invoker;
|
||||
|
||||
// Reposition if it would clip off the viewport
|
||||
const rect = menu.getBoundingClientRect();
|
||||
@@ -1161,31 +1176,21 @@ function showVodContextMenu(x: number, y: number, ctx: VodCardContext): void {
|
||||
menu.style.left = `${left}px`;
|
||||
menu.style.top = `${top}px`;
|
||||
|
||||
// Close on click anywhere else / Escape / scroll
|
||||
const dismissOnClick = (ev: MouseEvent) => {
|
||||
if (!activeVodContextMenu) return;
|
||||
if (ev.target instanceof Node && activeVodContextMenu.contains(ev.target)) return;
|
||||
closeVodContextMenu();
|
||||
document.removeEventListener('mousedown', dismissOnClick, true);
|
||||
document.removeEventListener('keydown', dismissOnEscape, true);
|
||||
document.removeEventListener('scroll', dismissOnScroll, true);
|
||||
cleanup();
|
||||
};
|
||||
const dismissOnEscape = (ev: KeyboardEvent) => {
|
||||
if (ev.key !== 'Escape') return;
|
||||
closeVodContextMenu();
|
||||
const dismissOnScroll = () => cleanup();
|
||||
cleanup = (restoreFocus = false): void => {
|
||||
closeVodContextMenu(restoreFocus);
|
||||
document.removeEventListener('mousedown', dismissOnClick, true);
|
||||
document.removeEventListener('keydown', dismissOnEscape, true);
|
||||
document.removeEventListener('scroll', dismissOnScroll, true);
|
||||
};
|
||||
const dismissOnScroll = () => {
|
||||
closeVodContextMenu();
|
||||
document.removeEventListener('mousedown', dismissOnClick, true);
|
||||
document.removeEventListener('keydown', dismissOnEscape, true);
|
||||
document.removeEventListener('scroll', dismissOnScroll, true);
|
||||
};
|
||||
document.addEventListener('mousedown', dismissOnClick, true);
|
||||
document.addEventListener('keydown', dismissOnEscape, true);
|
||||
document.addEventListener('scroll', dismissOnScroll, true);
|
||||
RendererAccessibility.installMenuKeyboardNavigation(menu, () => cleanup(true));
|
||||
RendererAccessibility.focusFirstMenuItem(menu);
|
||||
}
|
||||
|
||||
async function toggleVodDownloadedMark(vodId: string, mark: boolean): Promise<void> {
|
||||
|
||||
@@ -67,6 +67,7 @@ function setAriaLabel(id: string, value: string): void {
|
||||
|
||||
function setLanguage(lang: string): LanguageCode {
|
||||
currentLanguage = lang === 'en' ? 'en' : 'de';
|
||||
RendererAccessibility.setDocumentLanguage(currentLanguage);
|
||||
UI_TEXT = UI_TEXTS[currentLanguage];
|
||||
applyLanguageToStaticUI();
|
||||
return currentLanguage;
|
||||
|
||||
@@ -420,12 +420,12 @@ function refreshUpdateModalTexts(): void {
|
||||
function openUpdateModal(info?: UpdateInfo): void {
|
||||
rememberUpdateInfo(info);
|
||||
updateChangelogExpanded = false;
|
||||
byId('updateModal').classList.add('show');
|
||||
RendererAccessibility.openDialog('updateModal', { onEscape: dismissUpdateModal });
|
||||
refreshUpdateModalTexts();
|
||||
}
|
||||
|
||||
function dismissUpdateModal(): void {
|
||||
byId('updateModal').classList.remove('show');
|
||||
RendererAccessibility.closeDialog('updateModal');
|
||||
}
|
||||
|
||||
function skipUpdateVersion(): void {
|
||||
|
||||
+113
-73
@@ -309,13 +309,12 @@ interface EventLogEntry {
|
||||
}
|
||||
|
||||
async function openEventsViewer(filePath: string, title: string): Promise<void> {
|
||||
const modal = byId('eventsViewerModal');
|
||||
const list = byId('eventsViewerList');
|
||||
const status = byId('eventsViewerStatus');
|
||||
byId('eventsViewerTitle').textContent = title || UI_TEXT.queue.viewEvents;
|
||||
list.replaceChildren();
|
||||
status.textContent = UI_TEXT.queue.viewChatLoading;
|
||||
modal.classList.add('show');
|
||||
RendererAccessibility.openDialog('eventsViewerModal', { onEscape: closeEventsViewer });
|
||||
|
||||
const result = await window.api.readChatFile(filePath);
|
||||
if (!result.success || !Array.isArray(result.messages)) {
|
||||
@@ -328,7 +327,7 @@ async function openEventsViewer(filePath: string, title: string): Promise<void>
|
||||
}
|
||||
|
||||
function closeEventsViewer(): void {
|
||||
byId('eventsViewerModal').classList.remove('show');
|
||||
RendererAccessibility.closeDialog('eventsViewerModal');
|
||||
}
|
||||
|
||||
function formatEventTime(iso?: string): string {
|
||||
@@ -411,8 +410,17 @@ interface ChatViewerMessage {
|
||||
|
||||
let chatViewerMessages: ChatViewerMessage[] = [];
|
||||
let chatViewerFormat: 'replay' | 'live' = 'replay';
|
||||
let chatViewerSessionGeneration = 0;
|
||||
let chatViewerReadAbort: AbortController | null = null;
|
||||
const chatViewerRenderGeneration = new RendererAccessibility.RenderGeneration();
|
||||
const CHAT_VIEWER_ROW_HEIGHT = 29;
|
||||
const CHAT_VIEWER_OVERSCAN = 12;
|
||||
|
||||
async function openChatViewer(filePath: string, title: string): Promise<void> {
|
||||
const sessionGeneration = ++chatViewerSessionGeneration;
|
||||
chatViewerReadAbort?.abort();
|
||||
const abortController = new AbortController();
|
||||
chatViewerReadAbort = abortController;
|
||||
const modal = byId('chatViewerModal');
|
||||
const list = byId('chatViewerList');
|
||||
const status = byId('chatViewerStatus');
|
||||
@@ -421,10 +429,12 @@ async function openChatViewer(filePath: string, title: string): Promise<void> {
|
||||
list.replaceChildren();
|
||||
filterInput.value = '';
|
||||
status.textContent = UI_TEXT.queue.viewChatLoading;
|
||||
modal.classList.add('show');
|
||||
RendererAccessibility.openDialog('chatViewerModal', { initialFocus: filterInput, onEscape: closeChatViewer });
|
||||
|
||||
const result = await window.api.readChatFile(filePath);
|
||||
const result = await window.api.readChatFile(filePath, abortController.signal);
|
||||
if (sessionGeneration !== chatViewerSessionGeneration || abortController.signal.aborted || !modal.classList.contains('show')) return;
|
||||
if (!result.success || !Array.isArray(result.messages)) {
|
||||
if (result.cancelled) return;
|
||||
status.textContent = UI_TEXT.queue.viewChatFailed + (result.error ? `: ${result.error}` : '');
|
||||
return;
|
||||
}
|
||||
@@ -433,26 +443,114 @@ async function openChatViewer(filePath: string, title: string): Promise<void> {
|
||||
chatViewerFormat = result.format === 'live' ? 'live' : 'replay';
|
||||
status.textContent = UI_TEXT.queue.viewChatCount.replace('{count}', String(result.total ?? chatViewerMessages.length))
|
||||
+ (result.truncated ? UI_TEXT.queue.viewChatTruncatedSuffix : '');
|
||||
renderChatViewerList(chatViewerMessages);
|
||||
onChatViewerFilterChange();
|
||||
}
|
||||
|
||||
function closeChatViewer(): void {
|
||||
byId('chatViewerModal').classList.remove('show');
|
||||
chatViewerSessionGeneration++;
|
||||
chatViewerReadAbort?.abort();
|
||||
chatViewerReadAbort = null;
|
||||
chatViewerRenderGeneration.cancel();
|
||||
RendererAccessibility.closeDialog('chatViewerModal');
|
||||
chatViewerMessages = [];
|
||||
}
|
||||
|
||||
function onChatViewerFilterChange(): void {
|
||||
const generation = chatViewerRenderGeneration.next();
|
||||
const list = byId('chatViewerList');
|
||||
list.scrollTop = 0;
|
||||
list.replaceChildren();
|
||||
const filter = byId<HTMLInputElement>('chatViewerFilter').value.trim().toLowerCase();
|
||||
if (!filter) {
|
||||
renderChatViewerList(chatViewerMessages);
|
||||
renderChatViewerList(chatViewerMessages, generation);
|
||||
return;
|
||||
}
|
||||
const filtered = chatViewerMessages.filter((m) => {
|
||||
list.setAttribute('aria-busy', 'true');
|
||||
const filtered: ChatViewerMessage[] = [];
|
||||
let index = 0;
|
||||
const filterChunk = (): void => {
|
||||
if (!chatViewerRenderGeneration.isCurrent(generation) || !RendererAccessibility.isDialogOpen('chatViewerModal')) return;
|
||||
const deadline = performance.now() + 8;
|
||||
while (index < chatViewerMessages.length && performance.now() < deadline) {
|
||||
const m = chatViewerMessages[index++];
|
||||
const u = (m.u || m.user || m.login || '').toLowerCase();
|
||||
const text = (m.msg || m.text || '').toLowerCase();
|
||||
return u.includes(filter) || text.includes(filter);
|
||||
if (u.includes(filter) || text.includes(filter)) filtered.push(m);
|
||||
}
|
||||
if (index < chatViewerMessages.length) {
|
||||
window.setTimeout(filterChunk, 0);
|
||||
return;
|
||||
}
|
||||
list.removeAttribute('aria-busy');
|
||||
renderChatViewerList(filtered, generation);
|
||||
};
|
||||
filterChunk();
|
||||
}
|
||||
|
||||
function createChatViewerRow(m: ChatViewerMessage): HTMLElement {
|
||||
const row = document.createElement('div');
|
||||
const isMessageType = m.type === 'msg' || !m.type;
|
||||
row.className = 'chat-viewer-row' + (!isMessageType ? ' is-system' : '');
|
||||
|
||||
if (!isMessageType) {
|
||||
const tag = document.createElement('span');
|
||||
tag.className = 'chat-viewer-tag';
|
||||
tag.textContent = m.type || 'event';
|
||||
row.appendChild(tag);
|
||||
}
|
||||
|
||||
const time = formatChatTimeMarker(m);
|
||||
if (time) {
|
||||
const timeElement = document.createElement('span');
|
||||
timeElement.className = 'chat-viewer-time';
|
||||
timeElement.textContent = time;
|
||||
row.appendChild(timeElement);
|
||||
}
|
||||
|
||||
const user = m.u || m.user || m.login || '';
|
||||
if (user) {
|
||||
const userElement = document.createElement('span');
|
||||
userElement.className = 'chat-viewer-user';
|
||||
if (m.color) userElement.style.color = m.color;
|
||||
userElement.textContent = `${user}:`;
|
||||
row.appendChild(userElement);
|
||||
}
|
||||
|
||||
const message = document.createElement('span');
|
||||
message.textContent = ' ' + (m.msg || m.text || '');
|
||||
row.appendChild(message);
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderChatViewerList(messages: ChatViewerMessage[], generation: number): void {
|
||||
if (!chatViewerRenderGeneration.isCurrent(generation) || !RendererAccessibility.isDialogOpen('chatViewerModal')) return;
|
||||
const list = byId('chatViewerList');
|
||||
list.replaceChildren();
|
||||
const canvas = document.createElement('div');
|
||||
canvas.className = 'chat-viewer-virtual-canvas';
|
||||
canvas.style.height = `${messages.length * CHAT_VIEWER_ROW_HEIGHT}px`;
|
||||
const rows = document.createElement('div');
|
||||
rows.className = 'chat-viewer-virtual-rows';
|
||||
canvas.appendChild(rows);
|
||||
list.appendChild(canvas);
|
||||
let scheduled = false;
|
||||
const renderVisibleRows = (): void => {
|
||||
if (!chatViewerRenderGeneration.isCurrent(generation) || !RendererAccessibility.isDialogOpen('chatViewerModal')) return;
|
||||
const range = RendererAccessibility.getVirtualRange(list.scrollTop, list.clientHeight, messages.length, CHAT_VIEWER_ROW_HEIGHT, CHAT_VIEWER_OVERSCAN);
|
||||
rows.style.transform = `translateY(${range.start * CHAT_VIEWER_ROW_HEIGHT}px)`;
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (let index = range.start; index < range.end; index++) fragment.appendChild(createChatViewerRow(messages[index]));
|
||||
rows.replaceChildren(fragment);
|
||||
};
|
||||
list.addEventListener('scroll', () => {
|
||||
if (scheduled) return;
|
||||
scheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
scheduled = false;
|
||||
renderVisibleRows();
|
||||
});
|
||||
renderChatViewerList(filtered);
|
||||
}, { passive: true });
|
||||
renderVisibleRows();
|
||||
}
|
||||
|
||||
function formatChatTimeMarker(m: ChatViewerMessage): string {
|
||||
@@ -475,64 +573,6 @@ function formatChatTimeMarker(m: ChatViewerMessage): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
function renderChatViewerList(messages: ChatViewerMessage[]): void {
|
||||
const list = byId('chatViewerList');
|
||||
list.replaceChildren();
|
||||
// Render in chunks to keep main thread responsive on big files.
|
||||
const CHUNK = 500;
|
||||
let idx = 0;
|
||||
const renderChunk = (): void => {
|
||||
if (idx >= messages.length) return;
|
||||
const fragment = document.createDocumentFragment();
|
||||
const end = Math.min(idx + CHUNK, messages.length);
|
||||
for (let i = idx; i < end; i++) {
|
||||
const m = messages[i];
|
||||
const isMessageType = m.type === 'msg' || !m.type;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'chat-viewer-row' + (!isMessageType ? ' is-system' : '');
|
||||
|
||||
// System events (subs, raids, deletions) lead with a faint tag.
|
||||
if (!isMessageType) {
|
||||
const tag = document.createElement('span');
|
||||
tag.className = 'chat-viewer-tag';
|
||||
tag.textContent = m.type || 'event';
|
||||
row.appendChild(tag);
|
||||
}
|
||||
|
||||
const time = formatChatTimeMarker(m);
|
||||
if (time) {
|
||||
const tSpan = document.createElement('span');
|
||||
tSpan.className = 'chat-viewer-time';
|
||||
tSpan.textContent = time;
|
||||
row.appendChild(tSpan);
|
||||
}
|
||||
|
||||
const user = m.u || m.user || m.login || '';
|
||||
if (user) {
|
||||
const uSpan = document.createElement('span');
|
||||
uSpan.className = 'chat-viewer-user';
|
||||
// Per-user IRC color overrides the default accent colour
|
||||
// supplied by .chat-viewer-user; the class also sets weight.
|
||||
if (m.color) uSpan.style.color = m.color;
|
||||
uSpan.textContent = `${user}:`;
|
||||
row.appendChild(uSpan);
|
||||
}
|
||||
|
||||
const msgSpan = document.createElement('span');
|
||||
msgSpan.textContent = ' ' + (m.msg || m.text || '');
|
||||
row.appendChild(msgSpan);
|
||||
|
||||
fragment.appendChild(row);
|
||||
}
|
||||
list.appendChild(fragment);
|
||||
idx = end;
|
||||
if (idx < messages.length) {
|
||||
window.setTimeout(renderChunk, 0);
|
||||
}
|
||||
};
|
||||
renderChunk();
|
||||
}
|
||||
|
||||
function closeTopmostOpenModal(): boolean {
|
||||
// Try each known modal in priority order
|
||||
const cutterDiscardModal = document.getElementById('cutterDiscardModal');
|
||||
@@ -1336,13 +1376,13 @@ function refreshTemplateGuideTexts(): void {
|
||||
|
||||
function openTemplateGuide(source: TemplateGuideSource = 'vod'): void {
|
||||
templateGuideSource = source;
|
||||
byId('templateGuideModal').classList.add('show');
|
||||
RendererAccessibility.openDialog('templateGuideModal', { onEscape: closeTemplateGuide });
|
||||
refreshTemplateGuideTexts();
|
||||
setTemplateGuidePreset(source);
|
||||
}
|
||||
|
||||
function closeTemplateGuide(): void {
|
||||
byId('templateGuideModal').classList.remove('show');
|
||||
RendererAccessibility.closeDialog('templateGuideModal');
|
||||
}
|
||||
|
||||
function setTemplateGuidePreset(source: TemplateGuideSource): void {
|
||||
@@ -1392,11 +1432,11 @@ function openClipDialog(url: string, title: string, date: string, streamer: stri
|
||||
|
||||
updateClipDuration();
|
||||
updateFilenameExamples();
|
||||
byId('clipModal').classList.add('show');
|
||||
RendererAccessibility.openDialog('clipModal', { onEscape: closeClipDialog });
|
||||
}
|
||||
|
||||
function closeClipDialog(): void {
|
||||
byId('clipModal').classList.remove('show');
|
||||
RendererAccessibility.closeDialog('clipModal');
|
||||
clipDialogData = null;
|
||||
}
|
||||
|
||||
|
||||
@@ -4914,6 +4914,7 @@ input[type="number"]::-webkit-outer-spin-button {
|
||||
per-message colour for the username (driven by Twitch's IRC color
|
||||
metadata). */
|
||||
.chat-viewer-row {
|
||||
min-height: 29px;
|
||||
padding: 4px 8px;
|
||||
line-height: 1.55;
|
||||
border-radius: 4px;
|
||||
@@ -4922,6 +4923,17 @@ input[type="number"]::-webkit-outer-spin-button {
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.chat-viewer-virtual-canvas {
|
||||
position: relative;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.chat-viewer-virtual-rows {
|
||||
position: absolute;
|
||||
inset: 0 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chat-viewer-row:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
@@ -5615,6 +5627,11 @@ input[type="number"]::-webkit-outer-spin-button {
|
||||
}
|
||||
|
||||
.context-menu-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
@@ -5627,6 +5644,11 @@ input[type="number"]::-webkit-outer-spin-button {
|
||||
background: rgba(145, 70, 255, 0.15);
|
||||
}
|
||||
|
||||
.context-menu-item:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.context-menu-item.disabled {
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.55;
|
||||
|
||||
Reference in New Issue
Block a user