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:
Sucukdeluxe
2026-08-12 01:42:42 +02:00
parent aa06d486be
commit 9c17269a54
16 changed files with 790 additions and 190 deletions
+11 -6
View File
@@ -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
View File
@@ -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) };
}
});
+49
View File
@@ -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 });
});
});
+224
View File
@@ -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
View File
@@ -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'),
+67
View File
@@ -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');
});
});
+189
View File
@@ -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 });
+9 -6
View File
@@ -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
View File
@@ -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; });
}
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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> {
+1
View File
@@ -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;
+2 -2
View File
@@ -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
View File
@@ -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;
}
+22
View File
@@ -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;