fix(accessibility): stabilize virtual viewer interactions

Use cancellable, generation-aware reads and removable virtual scrolling for chat and event viewers. Constrain virtual rows to fixed single-line heights, clean busy state, stop Escape propagation, and cover dialog, menu, palette, and virtual list interactions.
This commit is contained in:
Sucukdeluxe
2026-08-12 01:55:50 +02:00
parent 9c17269a54
commit 348c37cbdb
4 changed files with 581 additions and 72 deletions
@@ -0,0 +1,382 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { runInNewContext } from 'node:vm';
import { ModuleKind, ScriptTarget, transpileModule } from 'typescript';
import { describe, expect, it } from 'vitest';
type Listener = (event: FakeEvent) => void;
class FakeEvent {
defaultPrevented = false;
immediatePropagationStopped = false;
constructor(
readonly type: string,
readonly key = '',
readonly shiftKey = false,
readonly ctrlKey = false,
readonly metaKey = false,
readonly altKey = false
) { }
preventDefault(): void {
this.defaultPrevented = true;
}
stopImmediatePropagation(): void {
this.immediatePropagationStopped = true;
}
}
class FakeClassList {
private readonly values = new Set<string>();
add(...tokens: string[]): void {
tokens.forEach((token) => this.values.add(token));
}
remove(...tokens: string[]): void {
tokens.forEach((token) => this.values.delete(token));
}
contains(token: string): boolean {
return this.values.has(token);
}
}
class FakeDocument {
readonly documentElement = { lang: '' };
readonly body: FakeElement;
readonly elements = new Map<string, FakeElement>();
readonly listeners = new Map<string, Listener[]>();
activeElement: FakeElement;
readyState = 'complete';
constructor() {
this.body = new FakeElement('body', this);
this.activeElement = this.body;
}
addEventListener(type: string, listener: Listener): void {
const listeners = this.listeners.get(type) ?? [];
listeners.push(listener);
this.listeners.set(type, listeners);
}
dispatch(event: FakeEvent): void {
for (const listener of this.listeners.get(event.type) ?? []) {
if (event.immediatePropagationStopped) break;
listener(event);
}
}
createElement(tagName: string): FakeElement {
return tagName === 'button' ? new FakeButton(this) : new FakeElement(tagName, this);
}
createDocumentFragment(): FakeElement {
return new FakeElement('fragment', this);
}
getElementById(id: string): FakeElement | null {
return this.elements.get(id) ?? null;
}
querySelector(selector: string): FakeElement | null {
if (selector === '.workspace-shell') return this.elements.get('workspace-shell') ?? null;
return null;
}
register(element: FakeElement, id: string): FakeElement {
element.id = id;
this.elements.set(id, element);
return element;
}
}
class FakeElement {
id = '';
value = '';
textContent = '';
className = '';
tabIndex = -1;
inert = false;
disabled = false;
isConnected = true;
clientHeight = 0;
scrollTop = 0;
readonly children: FakeElement[] = [];
readonly classList = new FakeClassList();
readonly attributes = new Map<string, string>();
readonly dataset: Record<string, string> = {};
readonly style: Record<string, string> = {};
readonly listeners = new Map<string, Listener[]>();
parentElement: FakeElement | null = null;
constructor(readonly tagName: string, protected readonly document: FakeDocument) { }
get firstChild(): FakeElement | null {
return this.children[0] ?? null;
}
appendChild(child: FakeElement): FakeElement {
if (child.tagName === 'fragment') {
child.children.slice().forEach((entry) => this.appendChild(entry));
return child;
}
child.parentElement = this;
this.children.push(child);
return child;
}
append(...children: FakeElement[]): void {
children.forEach((child) => this.appendChild(child));
}
removeChild(child: FakeElement): FakeElement {
const index = this.children.indexOf(child);
if (index >= 0) this.children.splice(index, 1);
child.parentElement = null;
return child;
}
replaceChildren(...children: FakeElement[]): void {
this.children.splice(0).forEach((child) => { child.parentElement = null; });
children.forEach((child) => this.appendChild(child));
}
remove(): void {
this.parentElement?.removeChild(this);
this.isConnected = false;
}
contains(node: FakeElement): boolean {
return node === this || this.children.some((child) => child.contains(node));
}
setAttribute(name: string, value: string): void {
this.attributes.set(name, value);
}
getAttribute(name: string): string | null {
return this.attributes.get(name) ?? null;
}
removeAttribute(name: string): void {
this.attributes.delete(name);
}
addEventListener(type: string, listener: Listener): void {
const listeners = this.listeners.get(type) ?? [];
listeners.push(listener);
this.listeners.set(type, listeners);
}
removeEventListener(type: string, listener: Listener): void {
const listeners = this.listeners.get(type) ?? [];
this.listeners.set(type, listeners.filter((candidate) => candidate !== listener));
}
dispatch(event: FakeEvent): void {
for (const listener of this.listeners.get(event.type) ?? []) {
if (event.immediatePropagationStopped) break;
listener(event);
}
}
listenerCount(type: string): number {
return (this.listeners.get(type) ?? []).length;
}
focus(): void {
this.document.activeElement = this;
}
querySelectorAll(selector: string): FakeElement[] {
const all = this.descendants();
if (selector.includes('[role="menuitem"]')) return all.filter((element) => element.getAttribute('role') === 'menuitem');
if (selector.includes('button') || selector.includes('input') || selector.includes('[tabindex]')) return all.filter((element) => element.tabIndex >= 0 || element.tagName === 'button' || element.tagName === 'input');
return [];
}
querySelector(selector: string): FakeElement | null {
if (selector.startsWith('#')) return this.descendants().find((element) => element.id === selector.slice(1)) ?? null;
return this.querySelectorAll(selector)[0] ?? null;
}
private descendants(): FakeElement[] {
return this.children.flatMap((child) => [child, ...child.descendants()]);
}
}
class FakeButton extends FakeElement {
type = 'button';
constructor(document: FakeDocument) {
super('button', document);
}
}
interface AccessibilityApi {
openDialog(id: string, options?: { initialFocus?: FakeElement; onEscape?: () => void }): void;
closeDialog(id: string): void;
installMenuKeyboardNavigation(menu: FakeElement, close: () => void): void;
focusFirstMenuItem(menu: FakeElement): void;
createFixedHeightVirtualList(list: FakeElement, options: { itemCount: () => number; rowHeight: number; overscan: number; render: (range: { start: number; end: number }) => void }): { dispose(): void };
setBusy(element: FakeElement, busy: boolean): void;
}
interface Runtime {
document: FakeDocument;
context: Record<string, unknown>;
accessibility: AccessibilityApi;
}
function compile(sourcePath: string): string {
return transpileModule(readFileSync(join(__dirname, sourcePath), 'utf8'), {
compilerOptions: { module: ModuleKind.None, target: ScriptTarget.ES2022 }
}).outputText;
}
function createRuntime(): Runtime {
const document = new FakeDocument();
const context: Record<string, unknown> = {
document,
HTMLElement: FakeElement,
HTMLButtonElement: FakeButton,
Element: FakeElement,
Node: FakeElement,
requestAnimationFrame: (callback: () => void) => callback(),
setTimeout: (callback: () => void) => callback(),
performance: { now: () => 0 },
console,
};
context.window = context;
runInNewContext(compile('renderer-accessibility.ts'), context);
return {
document,
context,
accessibility: (context as unknown as { RendererAccessibility: AccessibilityApi }).RendererAccessibility,
};
}
function attachDialog(runtime: Runtime, id: string): { dialog: FakeElement; first: FakeButton; last: FakeButton } {
const dialog = runtime.document.register(new FakeElement('div', runtime.document), id);
const first = new FakeButton(runtime.document);
const last = new FakeButton(runtime.document);
dialog.append(first, last);
return { dialog, first, last };
}
describe('renderer accessibility integration', () => {
it('keeps dialog focus, inert background and Escape restoration in one interaction path', () => {
const runtime = createRuntime();
const shell = runtime.document.register(new FakeElement('main', runtime.document), 'workspace-shell');
const trigger = new FakeButton(runtime.document);
runtime.document.activeElement = trigger;
const { dialog, first, last } = attachDialog(runtime, 'dialog');
runtime.accessibility.openDialog('dialog', { initialFocus: first });
expect(dialog.classList.contains('show')).toBe(true);
expect(dialog.getAttribute('aria-hidden')).toBe('false');
expect(shell.inert).toBe(true);
expect(runtime.document.activeElement).toBe(first);
runtime.document.activeElement = last;
const tab = new FakeEvent('keydown', 'Tab');
runtime.document.dispatch(tab);
expect(tab.defaultPrevented).toBe(true);
expect(runtime.document.activeElement).toBe(first);
const escape = new FakeEvent('keydown', 'Escape');
runtime.document.dispatch(escape);
expect(escape.immediatePropagationStopped).toBe(true);
expect(dialog.classList.contains('show')).toBe(false);
expect(shell.inert).toBe(false);
expect(runtime.document.activeElement).toBe(trigger);
});
it('navigates production menu elements and removes its only scroll listener on disposal', () => {
const runtime = createRuntime();
const menu = new FakeElement('div', runtime.document);
const first = new FakeButton(runtime.document);
const second = new FakeButton(runtime.document);
const third = new FakeButton(runtime.document);
[first, second, third].forEach((item) => {
item.setAttribute('role', 'menuitem');
menu.appendChild(item);
});
let closes = 0;
runtime.accessibility.installMenuKeyboardNavigation(menu, () => { closes++; });
runtime.accessibility.focusFirstMenuItem(menu);
expect(runtime.document.activeElement).toBe(first);
menu.dispatch(new FakeEvent('keydown', 'ArrowDown'));
expect(runtime.document.activeElement).toBe(second);
menu.dispatch(new FakeEvent('keydown', 'End'));
expect(runtime.document.activeElement).toBe(third);
menu.dispatch(new FakeEvent('keydown', 'Escape'));
expect(closes).toBe(1);
const list = new FakeElement('div', runtime.document);
list.clientHeight = 58;
const ranges: Array<{ start: number; end: number }> = [];
const virtual = runtime.accessibility.createFixedHeightVirtualList(list, {
itemCount: () => 50_000,
rowHeight: 29,
overscan: 12,
render: (range) => ranges.push(range),
});
expect(list.listenerCount('scroll')).toBe(1);
list.scrollTop = 290;
list.dispatch(new FakeEvent('scroll'));
expect(ranges.at(-1)).toEqual({ start: 0, end: 24 });
virtual.dispose();
expect(list.listenerCount('scroll')).toBe(0);
});
it('drives the production command palette combobox and clears busy state', () => {
const runtime = createRuntime();
const shell = runtime.document.register(new FakeElement('main', runtime.document), 'workspace-shell');
const trigger = new FakeButton(runtime.document);
runtime.document.activeElement = trigger;
const modal = runtime.document.register(new FakeElement('div', runtime.document), 'commandPaletteModal');
const input = runtime.document.register(new FakeElement('input', runtime.document), 'commandPaletteInput');
const list = runtime.document.register(new FakeElement('ul', runtime.document), 'commandPaletteList');
modal.append(input, list);
runtime.context.UI_TEXT = {
static: {
commandPaletteCommands: {
vods: { label: 'VODs', keywords: 'vod' },
clips: { label: 'Clips', keywords: 'clip' },
cutter: { label: 'Cutter', keywords: 'cut' },
merge: { label: 'Merge', keywords: 'merge' },
stats: { label: 'Stats', keywords: 'stats' },
archive: { label: 'Archive', keywords: 'archive' },
settings: { label: 'Settings', keywords: 'settings' },
},
commandPaletteOpenHint: 'Open',
commandPaletteStreamerHint: 'Streamer',
}
};
runtime.context.showTab = () => undefined;
runInNewContext(compile('renderer-command-palette.ts'), runtime.context);
runtime.document.dispatch(new FakeEvent('keydown', 'k', false, true));
expect(modal.classList.contains('show')).toBe(true);
expect(shell.inert).toBe(true);
expect(input.getAttribute('aria-expanded')).toBe('true');
expect(input.getAttribute('aria-activedescendant')).toBe('commandPaletteOption-0');
runtime.document.dispatch(new FakeEvent('keydown', 'ArrowDown'));
expect(input.getAttribute('aria-activedescendant')).toBe('commandPaletteOption-1');
const escape = new FakeEvent('keydown', 'Escape');
runtime.document.dispatch(escape);
expect(escape.immediatePropagationStopped).toBe(true);
expect(modal.classList.contains('show')).toBe(false);
expect(shell.inert).toBe(false);
expect(runtime.document.activeElement).toBe(trigger);
runtime.accessibility.setBusy(list, true);
runtime.accessibility.setBusy(list, false);
expect(list.getAttribute('aria-busy')).toBeNull();
});
});
+42
View File
@@ -8,6 +8,13 @@ interface RendererOpenDialogState {
onEscape?: () => void; onEscape?: () => void;
} }
interface RendererFixedHeightVirtualListOptions {
itemCount: () => number;
rowHeight: number;
overscan: number;
render: (range: { start: number; end: number }) => void;
}
const RendererAccessibility = (() => { const RendererAccessibility = (() => {
const dialogStack: string[] = []; const dialogStack: string[] = [];
const dialogStates = new Map<string, RendererOpenDialogState>(); const dialogStates = new Map<string, RendererOpenDialogState>();
@@ -61,6 +68,38 @@ const RendererAccessibility = (() => {
return normalized; return normalized;
} }
function setBusy(element: HTMLElement, busy: boolean): void {
if (busy) element.setAttribute('aria-busy', 'true');
else element.removeAttribute('aria-busy');
}
function createFixedHeightVirtualList(list: HTMLElement, options: RendererFixedHeightVirtualListOptions): { dispose(): void; render(): void } {
let disposed = false;
let scheduled = false;
const render = (): void => {
if (disposed) return;
options.render(getVirtualRange(list.scrollTop, list.clientHeight, options.itemCount(), options.rowHeight, options.overscan));
};
const onScroll = (): void => {
if (scheduled || disposed) return;
scheduled = true;
requestAnimationFrame(() => {
scheduled = false;
render();
});
};
list.addEventListener('scroll', onScroll, { passive: true });
render();
return {
dispose: () => {
if (disposed) return;
disposed = true;
list.removeEventListener('scroll', onScroll);
},
render,
};
}
function getFocusable(dialog: HTMLElement): HTMLElement[] { function getFocusable(dialog: HTMLElement): HTMLElement[] {
return Array.from(dialog.querySelectorAll<HTMLElement>(focusSelector)).filter((element) => element.getAttribute('aria-hidden') !== 'true'); return Array.from(dialog.querySelectorAll<HTMLElement>(focusSelector)).filter((element) => element.getAttribute('aria-hidden') !== 'true');
} }
@@ -153,6 +192,7 @@ const RendererAccessibility = (() => {
if (!(dialog instanceof HTMLElement)) return; if (!(dialog instanceof HTMLElement)) return;
if (event.key === 'Escape') { if (event.key === 'Escape') {
event.preventDefault(); event.preventDefault();
event.stopImmediatePropagation();
closeTopmostDialog(); closeTopmostDialog();
return; return;
} }
@@ -177,6 +217,8 @@ const RendererAccessibility = (() => {
getNextFocusIndex, getNextFocusIndex,
getNextMenuIndex, getNextMenuIndex,
setDocumentLanguage, setDocumentLanguage,
setBusy,
createFixedHeightVirtualList,
isDialogOpen, isDialogOpen,
openDialog, openDialog,
closeDialog, closeDialog,
+113 -60
View File
@@ -308,26 +308,56 @@ interface EventLogEntry {
part?: number; part?: number;
} }
let eventsViewerMessages: EventLogEntry[] = [];
let eventsViewerSessionGeneration = 0;
let eventsViewerReadAbort: AbortController | null = null;
let eventsViewerVirtualList: ReturnType<typeof RendererAccessibility.createFixedHeightVirtualList> | null = null;
const eventsViewerRenderGeneration = new RendererAccessibility.RenderGeneration();
const EVENT_VIEWER_ROW_HEIGHT = 36;
const EVENT_VIEWER_OVERSCAN = 12;
function disposeEventsViewerVirtualList(): void {
eventsViewerVirtualList?.dispose();
eventsViewerVirtualList = null;
}
async function openEventsViewer(filePath: string, title: string): Promise<void> { async function openEventsViewer(filePath: string, title: string): Promise<void> {
const sessionGeneration = ++eventsViewerSessionGeneration;
eventsViewerReadAbort?.abort();
eventsViewerRenderGeneration.cancel();
const abortController = new AbortController();
eventsViewerReadAbort = abortController;
const list = byId('eventsViewerList'); const list = byId('eventsViewerList');
const status = byId('eventsViewerStatus'); const status = byId('eventsViewerStatus');
byId('eventsViewerTitle').textContent = title || UI_TEXT.queue.viewEvents; byId('eventsViewerTitle').textContent = title || UI_TEXT.queue.viewEvents;
disposeEventsViewerVirtualList();
list.replaceChildren(); list.replaceChildren();
RendererAccessibility.setBusy(list, true);
status.textContent = UI_TEXT.queue.viewChatLoading; status.textContent = UI_TEXT.queue.viewChatLoading;
RendererAccessibility.openDialog('eventsViewerModal', { onEscape: closeEventsViewer }); RendererAccessibility.openDialog('eventsViewerModal', { onEscape: closeEventsViewer });
const result = await window.api.readChatFile(filePath); const result = await window.api.readChatFile(filePath, abortController.signal);
if (sessionGeneration !== eventsViewerSessionGeneration || abortController.signal.aborted || !RendererAccessibility.isDialogOpen('eventsViewerModal')) return;
RendererAccessibility.setBusy(list, false);
if (!result.success || !Array.isArray(result.messages)) { if (!result.success || !Array.isArray(result.messages)) {
if (result.cancelled) return;
status.textContent = UI_TEXT.queue.viewChatFailed + (result.error ? `: ${result.error}` : ''); status.textContent = UI_TEXT.queue.viewChatFailed + (result.error ? `: ${result.error}` : '');
return; return;
} }
const events = result.messages as EventLogEntry[]; eventsViewerMessages = result.messages as EventLogEntry[];
status.textContent = UI_TEXT.queue.viewEventsCount.replace('{count}', String(events.length)); status.textContent = UI_TEXT.queue.viewEventsCount.replace('{count}', String(result.total ?? eventsViewerMessages.length));
renderEventsList(events); renderEventsList(eventsViewerMessages, eventsViewerRenderGeneration.next());
} }
function closeEventsViewer(): void { function closeEventsViewer(): void {
eventsViewerSessionGeneration++;
eventsViewerReadAbort?.abort();
eventsViewerReadAbort = null;
eventsViewerRenderGeneration.cancel();
disposeEventsViewerVirtualList();
RendererAccessibility.setBusy(byId('eventsViewerList'), false);
RendererAccessibility.closeDialog('eventsViewerModal'); RendererAccessibility.closeDialog('eventsViewerModal');
eventsViewerMessages = [];
} }
function formatEventTime(iso?: string): string { function formatEventTime(iso?: string): string {
@@ -338,8 +368,43 @@ function formatEventTime(iso?: string): string {
} catch { return iso; } } catch { return iso; }
} }
function renderEventsList(events: EventLogEntry[]): void { function getEventViewerDetail(event: EventLogEntry): string {
if (event.type === 'recording_start') return `${UI_TEXT.queue.eventStartedAs}: "${event.title || '-'}" — ${event.game || '-'}`;
if (event.type === 'recording_end') {
const duration = typeof event.durationSeconds === 'number'
? `${Math.floor(event.durationSeconds / 3600)}h ${Math.floor((event.durationSeconds % 3600) / 60)}m ${event.durationSeconds % 60}s`
: '?';
const outcome = event.success ? '✓' : '✗';
return `${outcome} ${UI_TEXT.queue.eventEndedAfter}: ${duration}${event.error ? `${event.error}` : ''}`;
}
if (event.type === 'recording_resume') return (UI_TEXT.queue.eventRecordingResume || 'Resume started — part {part}').replace('{part}', String(event.part || '?'));
if (event.type === 'title_change') return UI_TEXT.queue.eventTitleFromTo.replace('{from}', `"${event.from || '-'}"`).replace('{to}', `"${event.to || '-'}"`);
if (event.type === 'game_change') return UI_TEXT.queue.eventGameFromTo.replace('{from}', event.from || '-').replace('{to}', event.to || '-');
return JSON.stringify(event);
}
function createEventViewerRow(event: EventLogEntry): HTMLElement {
const row = document.createElement('div');
row.className = 'event-viewer-row';
const time = document.createElement('span');
time.className = 'event-viewer-time';
time.textContent = formatEventTime(event.t);
const tag = document.createElement('span');
tag.className = 'event-viewer-tag';
if (event.type) tag.dataset.type = event.type;
tag.textContent = event.type || 'event';
const detail = document.createElement('div');
detail.className = 'event-viewer-detail';
detail.textContent = getEventViewerDetail(event);
row.title = `${time.textContent} ${tag.textContent} ${detail.textContent}`.trim();
row.append(time, tag, detail);
return row;
}
function renderEventsList(events: EventLogEntry[], generation: number): void {
if (!eventsViewerRenderGeneration.isCurrent(generation) || !RendererAccessibility.isDialogOpen('eventsViewerModal')) return;
const list = byId('eventsViewerList'); const list = byId('eventsViewerList');
disposeEventsViewerVirtualList();
list.replaceChildren(); list.replaceChildren();
if (events.length === 0) { if (events.length === 0) {
const empty = document.createElement('div'); const empty = document.createElement('div');
@@ -348,48 +413,25 @@ function renderEventsList(events: EventLogEntry[]): void {
list.appendChild(empty); list.appendChild(empty);
return; return;
} }
const canvas = document.createElement('div');
for (const ev of events) { canvas.className = 'event-viewer-virtual-canvas';
const row = document.createElement('div'); canvas.style.height = `${events.length * EVENT_VIEWER_ROW_HEIGHT}px`;
row.className = 'event-viewer-row'; const rows = document.createElement('div');
rows.className = 'event-viewer-virtual-rows';
const time = document.createElement('span'); canvas.appendChild(rows);
time.className = 'event-viewer-time'; list.appendChild(canvas);
time.textContent = formatEventTime(ev.t); eventsViewerVirtualList = RendererAccessibility.createFixedHeightVirtualList(list, {
row.appendChild(time); itemCount: () => events.length,
rowHeight: EVENT_VIEWER_ROW_HEIGHT,
const tag = document.createElement('span'); overscan: EVENT_VIEWER_OVERSCAN,
tag.className = 'event-viewer-tag'; render: (range) => {
// Per-type tag colour comes from CSS via a data-type attribute if (!eventsViewerRenderGeneration.isCurrent(generation) || !RendererAccessibility.isDialogOpen('eventsViewerModal')) return;
// selector — keeps the type->colour mapping with the rest of the rows.style.transform = `translateY(${range.start * EVENT_VIEWER_ROW_HEIGHT}px)`;
// visual styling instead of inline in the renderer. const fragment = document.createDocumentFragment();
if (ev.type) tag.dataset.type = ev.type; for (let index = range.start; index < range.end; index++) fragment.appendChild(createEventViewerRow(events[index]));
tag.textContent = ev.type || 'event'; rows.replaceChildren(fragment);
row.appendChild(tag);
const detail = document.createElement('div');
detail.className = 'event-viewer-detail';
if (ev.type === 'recording_start') {
detail.textContent = `${UI_TEXT.queue.eventStartedAs}: "${ev.title || '-'}" — ${ev.game || '-'}`;
} else if (ev.type === 'recording_end') {
const dur = typeof ev.durationSeconds === 'number'
? `${Math.floor(ev.durationSeconds / 3600)}h ${Math.floor((ev.durationSeconds % 3600) / 60)}m ${ev.durationSeconds % 60}s`
: '?';
const ok = ev.success ? '✓' : '✗';
detail.textContent = `${ok} ${UI_TEXT.queue.eventEndedAfter}: ${dur}${ev.error ? `${ev.error}` : ''}`;
} else if (ev.type === 'recording_resume') {
detail.textContent = (UI_TEXT.queue.eventRecordingResume || 'Resume started — part {part}').replace('{part}', String(ev.part || '?'));
} else if (ev.type === 'title_change') {
detail.textContent = `${UI_TEXT.queue.eventTitleFromTo.replace('{from}', `"${ev.from || '-'}"`).replace('{to}', `"${ev.to || '-'}"`)}`;
} else if (ev.type === 'game_change') {
detail.textContent = `${UI_TEXT.queue.eventGameFromTo.replace('{from}', ev.from || '-').replace('{to}', ev.to || '-')}`;
} else {
detail.textContent = JSON.stringify(ev);
}
row.appendChild(detail);
list.appendChild(row);
} }
});
} }
interface ChatViewerMessage { interface ChatViewerMessage {
@@ -412,13 +454,20 @@ let chatViewerMessages: ChatViewerMessage[] = [];
let chatViewerFormat: 'replay' | 'live' = 'replay'; let chatViewerFormat: 'replay' | 'live' = 'replay';
let chatViewerSessionGeneration = 0; let chatViewerSessionGeneration = 0;
let chatViewerReadAbort: AbortController | null = null; let chatViewerReadAbort: AbortController | null = null;
let chatViewerVirtualList: ReturnType<typeof RendererAccessibility.createFixedHeightVirtualList> | null = null;
const chatViewerRenderGeneration = new RendererAccessibility.RenderGeneration(); const chatViewerRenderGeneration = new RendererAccessibility.RenderGeneration();
const CHAT_VIEWER_ROW_HEIGHT = 29; const CHAT_VIEWER_ROW_HEIGHT = 29;
const CHAT_VIEWER_OVERSCAN = 12; const CHAT_VIEWER_OVERSCAN = 12;
function disposeChatViewerVirtualList(): void {
chatViewerVirtualList?.dispose();
chatViewerVirtualList = null;
}
async function openChatViewer(filePath: string, title: string): Promise<void> { async function openChatViewer(filePath: string, title: string): Promise<void> {
const sessionGeneration = ++chatViewerSessionGeneration; const sessionGeneration = ++chatViewerSessionGeneration;
chatViewerReadAbort?.abort(); chatViewerReadAbort?.abort();
chatViewerRenderGeneration.cancel();
const abortController = new AbortController(); const abortController = new AbortController();
chatViewerReadAbort = abortController; chatViewerReadAbort = abortController;
const modal = byId('chatViewerModal'); const modal = byId('chatViewerModal');
@@ -426,13 +475,16 @@ async function openChatViewer(filePath: string, title: string): Promise<void> {
const status = byId('chatViewerStatus'); const status = byId('chatViewerStatus');
const filterInput = byId<HTMLInputElement>('chatViewerFilter'); const filterInput = byId<HTMLInputElement>('chatViewerFilter');
byId('chatViewerTitle').textContent = title || UI_TEXT.queue.viewChat; byId('chatViewerTitle').textContent = title || UI_TEXT.queue.viewChat;
disposeChatViewerVirtualList();
list.replaceChildren(); list.replaceChildren();
RendererAccessibility.setBusy(list, true);
filterInput.value = ''; filterInput.value = '';
status.textContent = UI_TEXT.queue.viewChatLoading; status.textContent = UI_TEXT.queue.viewChatLoading;
RendererAccessibility.openDialog('chatViewerModal', { initialFocus: filterInput, onEscape: closeChatViewer }); RendererAccessibility.openDialog('chatViewerModal', { initialFocus: filterInput, onEscape: closeChatViewer });
const result = await window.api.readChatFile(filePath, abortController.signal); const result = await window.api.readChatFile(filePath, abortController.signal);
if (sessionGeneration !== chatViewerSessionGeneration || abortController.signal.aborted || !modal.classList.contains('show')) return; if (sessionGeneration !== chatViewerSessionGeneration || abortController.signal.aborted || !modal.classList.contains('show')) return;
RendererAccessibility.setBusy(list, false);
if (!result.success || !Array.isArray(result.messages)) { if (!result.success || !Array.isArray(result.messages)) {
if (result.cancelled) return; if (result.cancelled) return;
status.textContent = UI_TEXT.queue.viewChatFailed + (result.error ? `: ${result.error}` : ''); status.textContent = UI_TEXT.queue.viewChatFailed + (result.error ? `: ${result.error}` : '');
@@ -451,6 +503,8 @@ function closeChatViewer(): void {
chatViewerReadAbort?.abort(); chatViewerReadAbort?.abort();
chatViewerReadAbort = null; chatViewerReadAbort = null;
chatViewerRenderGeneration.cancel(); chatViewerRenderGeneration.cancel();
disposeChatViewerVirtualList();
RendererAccessibility.setBusy(byId('chatViewerList'), false);
RendererAccessibility.closeDialog('chatViewerModal'); RendererAccessibility.closeDialog('chatViewerModal');
chatViewerMessages = []; chatViewerMessages = [];
} }
@@ -458,6 +512,8 @@ function closeChatViewer(): void {
function onChatViewerFilterChange(): void { function onChatViewerFilterChange(): void {
const generation = chatViewerRenderGeneration.next(); const generation = chatViewerRenderGeneration.next();
const list = byId('chatViewerList'); const list = byId('chatViewerList');
disposeChatViewerVirtualList();
RendererAccessibility.setBusy(list, false);
list.scrollTop = 0; list.scrollTop = 0;
list.replaceChildren(); list.replaceChildren();
const filter = byId<HTMLInputElement>('chatViewerFilter').value.trim().toLowerCase(); const filter = byId<HTMLInputElement>('chatViewerFilter').value.trim().toLowerCase();
@@ -465,7 +521,7 @@ function onChatViewerFilterChange(): void {
renderChatViewerList(chatViewerMessages, generation); renderChatViewerList(chatViewerMessages, generation);
return; return;
} }
list.setAttribute('aria-busy', 'true'); RendererAccessibility.setBusy(list, true);
const filtered: ChatViewerMessage[] = []; const filtered: ChatViewerMessage[] = [];
let index = 0; let index = 0;
const filterChunk = (): void => { const filterChunk = (): void => {
@@ -481,7 +537,7 @@ function onChatViewerFilterChange(): void {
window.setTimeout(filterChunk, 0); window.setTimeout(filterChunk, 0);
return; return;
} }
list.removeAttribute('aria-busy'); RendererAccessibility.setBusy(list, false);
renderChatViewerList(filtered, generation); renderChatViewerList(filtered, generation);
}; };
filterChunk(); filterChunk();
@@ -519,12 +575,15 @@ function createChatViewerRow(m: ChatViewerMessage): HTMLElement {
const message = document.createElement('span'); const message = document.createElement('span');
message.textContent = ' ' + (m.msg || m.text || ''); message.textContent = ' ' + (m.msg || m.text || '');
row.appendChild(message); row.appendChild(message);
row.title = [time, user, m.msg || m.text || ''].filter(Boolean).join(' ');
return row; return row;
} }
function renderChatViewerList(messages: ChatViewerMessage[], generation: number): void { function renderChatViewerList(messages: ChatViewerMessage[], generation: number): void {
if (!chatViewerRenderGeneration.isCurrent(generation) || !RendererAccessibility.isDialogOpen('chatViewerModal')) return; if (!chatViewerRenderGeneration.isCurrent(generation) || !RendererAccessibility.isDialogOpen('chatViewerModal')) return;
const list = byId('chatViewerList'); const list = byId('chatViewerList');
disposeChatViewerVirtualList();
RendererAccessibility.setBusy(list, false);
list.replaceChildren(); list.replaceChildren();
const canvas = document.createElement('div'); const canvas = document.createElement('div');
canvas.className = 'chat-viewer-virtual-canvas'; canvas.className = 'chat-viewer-virtual-canvas';
@@ -533,24 +592,18 @@ function renderChatViewerList(messages: ChatViewerMessage[], generation: number)
rows.className = 'chat-viewer-virtual-rows'; rows.className = 'chat-viewer-virtual-rows';
canvas.appendChild(rows); canvas.appendChild(rows);
list.appendChild(canvas); list.appendChild(canvas);
let scheduled = false; chatViewerVirtualList = RendererAccessibility.createFixedHeightVirtualList(list, {
const renderVisibleRows = (): void => { itemCount: () => messages.length,
rowHeight: CHAT_VIEWER_ROW_HEIGHT,
overscan: CHAT_VIEWER_OVERSCAN,
render: (range) => {
if (!chatViewerRenderGeneration.isCurrent(generation) || !RendererAccessibility.isDialogOpen('chatViewerModal')) return; 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)`; rows.style.transform = `translateY(${range.start * CHAT_VIEWER_ROW_HEIGHT}px)`;
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
for (let index = range.start; index < range.end; index++) fragment.appendChild(createChatViewerRow(messages[index])); for (let index = range.start; index < range.end; index++) fragment.appendChild(createChatViewerRow(messages[index]));
rows.replaceChildren(fragment); rows.replaceChildren(fragment);
}; }
list.addEventListener('scroll', () => {
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
scheduled = false;
renderVisibleRows();
}); });
}, { passive: true });
renderVisibleRows();
} }
function formatChatTimeMarker(m: ChatViewerMessage): string { function formatChatTimeMarker(m: ChatViewerMessage): string {
+38 -6
View File
@@ -4914,13 +4914,17 @@ input[type="number"]::-webkit-outer-spin-button {
per-message colour for the username (driven by Twitch's IRC color per-message colour for the username (driven by Twitch's IRC color
metadata). */ metadata). */
.chat-viewer-row { .chat-viewer-row {
min-height: 29px; box-sizing: border-box;
display: flex;
align-items: center;
height: 29px;
padding: 4px 8px; padding: 4px 8px;
line-height: 1.55; line-height: 1.55;
border-radius: 4px; border-radius: 4px;
transition: background 0.12s; transition: background 0.12s;
font-size: 13px; font-size: 13px;
word-wrap: break-word; overflow: hidden;
white-space: nowrap;
} }
.chat-viewer-virtual-canvas { .chat-viewer-virtual-canvas {
@@ -4952,6 +4956,13 @@ input[type="number"]::-webkit-outer-spin-button {
color: var(--accent); color: var(--accent);
} }
.chat-viewer-row > span:last-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-viewer-row .chat-viewer-tag { .chat-viewer-row .chat-viewer-tag {
color: var(--accent); color: var(--accent);
font-style: italic; font-style: italic;
@@ -4977,9 +4988,27 @@ input[type="number"]::-webkit-outer-spin-button {
handles the palette. Add a new event type by extending this handles the palette. Add a new event type by extending this
block, not the renderer. */ block, not the renderer. */
.event-viewer-row { .event-viewer-row {
box-sizing: border-box;
display: flex;
align-items: center;
height: 36px;
padding: 8px 10px; padding: 8px 10px;
border-bottom: 1px solid var(--border-soft); border-bottom: 1px solid var(--border-soft);
font-size: 12px; font-size: 12px;
gap: 8px;
overflow: hidden;
white-space: nowrap;
}
.event-viewer-virtual-canvas {
position: relative;
min-height: 100%;
}
.event-viewer-virtual-rows {
position: absolute;
inset: 0 0 auto;
width: 100%;
} }
.event-viewer-row:last-child { .event-viewer-row:last-child {
@@ -4988,7 +5017,7 @@ input[type="number"]::-webkit-outer-spin-button {
.event-viewer-time { .event-viewer-time {
color: var(--text-secondary); color: var(--text-secondary);
margin-right: 8px; flex: 0 0 auto;
font-family: 'Consolas', 'Segoe UI Mono', monospace; font-family: 'Consolas', 'Segoe UI Mono', monospace;
} }
@@ -5002,7 +5031,7 @@ input[type="number"]::-webkit-outer-spin-button {
.event-viewer-tag { .event-viewer-tag {
font-weight: 600; font-weight: 600;
margin-right: 8px; flex: 0 0 auto;
color: var(--accent); color: var(--accent);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.3px; letter-spacing: 0.3px;
@@ -5044,9 +5073,12 @@ input[type="number"]::-webkit-outer-spin-button {
} }
.event-viewer-detail { .event-viewer-detail {
margin-top: 4px; flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text); color: var(--text);
line-height: 1.5;
} }
/* ============================================ /* ============================================