From 348c37cbdb4accb61ccc6e9a85d12d3aafbe834f Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Wed, 12 Aug 2026 01:55:50 +0200 Subject: [PATCH] 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. --- ...renderer-accessibility.integration.test.ts | 382 ++++++++++++++++++ src/renderer-accessibility.ts | 42 ++ src/renderer.ts | 185 ++++++--- src/styles.css | 44 +- 4 files changed, 581 insertions(+), 72 deletions(-) create mode 100644 src/renderer-accessibility.integration.test.ts diff --git a/src/renderer-accessibility.integration.test.ts b/src/renderer-accessibility.integration.test.ts new file mode 100644 index 0000000..d5c6a46 --- /dev/null +++ b/src/renderer-accessibility.integration.test.ts @@ -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(); + + 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(); + readonly listeners = new Map(); + 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(); + readonly dataset: Record = {}; + readonly style: Record = {}; + readonly listeners = new Map(); + 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; + 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 = { + 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(); + }); +}); diff --git a/src/renderer-accessibility.ts b/src/renderer-accessibility.ts index bd6e9e6..05957f0 100644 --- a/src/renderer-accessibility.ts +++ b/src/renderer-accessibility.ts @@ -8,6 +8,13 @@ interface RendererOpenDialogState { onEscape?: () => void; } +interface RendererFixedHeightVirtualListOptions { + itemCount: () => number; + rowHeight: number; + overscan: number; + render: (range: { start: number; end: number }) => void; +} + const RendererAccessibility = (() => { const dialogStack: string[] = []; const dialogStates = new Map(); @@ -61,6 +68,38 @@ const RendererAccessibility = (() => { 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[] { return Array.from(dialog.querySelectorAll(focusSelector)).filter((element) => element.getAttribute('aria-hidden') !== 'true'); } @@ -153,6 +192,7 @@ const RendererAccessibility = (() => { if (!(dialog instanceof HTMLElement)) return; if (event.key === 'Escape') { event.preventDefault(); + event.stopImmediatePropagation(); closeTopmostDialog(); return; } @@ -177,6 +217,8 @@ const RendererAccessibility = (() => { getNextFocusIndex, getNextMenuIndex, setDocumentLanguage, + setBusy, + createFixedHeightVirtualList, isDialogOpen, openDialog, closeDialog, diff --git a/src/renderer.ts b/src/renderer.ts index 59462a4..ca4131a 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -308,26 +308,56 @@ interface EventLogEntry { part?: number; } +let eventsViewerMessages: EventLogEntry[] = []; +let eventsViewerSessionGeneration = 0; +let eventsViewerReadAbort: AbortController | null = null; +let eventsViewerVirtualList: ReturnType | 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 { + const sessionGeneration = ++eventsViewerSessionGeneration; + eventsViewerReadAbort?.abort(); + eventsViewerRenderGeneration.cancel(); + const abortController = new AbortController(); + eventsViewerReadAbort = abortController; const list = byId('eventsViewerList'); const status = byId('eventsViewerStatus'); byId('eventsViewerTitle').textContent = title || UI_TEXT.queue.viewEvents; + disposeEventsViewerVirtualList(); list.replaceChildren(); + RendererAccessibility.setBusy(list, true); status.textContent = UI_TEXT.queue.viewChatLoading; 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.cancelled) return; status.textContent = UI_TEXT.queue.viewChatFailed + (result.error ? `: ${result.error}` : ''); return; } - const events = result.messages as EventLogEntry[]; - status.textContent = UI_TEXT.queue.viewEventsCount.replace('{count}', String(events.length)); - renderEventsList(events); + eventsViewerMessages = result.messages as EventLogEntry[]; + status.textContent = UI_TEXT.queue.viewEventsCount.replace('{count}', String(result.total ?? eventsViewerMessages.length)); + renderEventsList(eventsViewerMessages, eventsViewerRenderGeneration.next()); } function closeEventsViewer(): void { + eventsViewerSessionGeneration++; + eventsViewerReadAbort?.abort(); + eventsViewerReadAbort = null; + eventsViewerRenderGeneration.cancel(); + disposeEventsViewerVirtualList(); + RendererAccessibility.setBusy(byId('eventsViewerList'), false); RendererAccessibility.closeDialog('eventsViewerModal'); + eventsViewerMessages = []; } function formatEventTime(iso?: string): string { @@ -338,8 +368,43 @@ function formatEventTime(iso?: string): string { } 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'); + disposeEventsViewerVirtualList(); list.replaceChildren(); if (events.length === 0) { const empty = document.createElement('div'); @@ -348,48 +413,25 @@ function renderEventsList(events: EventLogEntry[]): void { list.appendChild(empty); return; } - - for (const ev of events) { - const row = document.createElement('div'); - row.className = 'event-viewer-row'; - - const time = document.createElement('span'); - time.className = 'event-viewer-time'; - time.textContent = formatEventTime(ev.t); - row.appendChild(time); - - const tag = document.createElement('span'); - tag.className = 'event-viewer-tag'; - // Per-type tag colour comes from CSS via a data-type attribute - // selector — keeps the type->colour mapping with the rest of the - // visual styling instead of inline in the renderer. - if (ev.type) tag.dataset.type = ev.type; - tag.textContent = ev.type || 'event'; - 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); + const canvas = document.createElement('div'); + canvas.className = 'event-viewer-virtual-canvas'; + canvas.style.height = `${events.length * EVENT_VIEWER_ROW_HEIGHT}px`; + const rows = document.createElement('div'); + rows.className = 'event-viewer-virtual-rows'; + canvas.appendChild(rows); + list.appendChild(canvas); + eventsViewerVirtualList = RendererAccessibility.createFixedHeightVirtualList(list, { + itemCount: () => events.length, + rowHeight: EVENT_VIEWER_ROW_HEIGHT, + overscan: EVENT_VIEWER_OVERSCAN, + render: (range) => { + if (!eventsViewerRenderGeneration.isCurrent(generation) || !RendererAccessibility.isDialogOpen('eventsViewerModal')) return; + rows.style.transform = `translateY(${range.start * EVENT_VIEWER_ROW_HEIGHT}px)`; + const fragment = document.createDocumentFragment(); + for (let index = range.start; index < range.end; index++) fragment.appendChild(createEventViewerRow(events[index])); + rows.replaceChildren(fragment); } - row.appendChild(detail); - list.appendChild(row); - } + }); } interface ChatViewerMessage { @@ -412,13 +454,20 @@ let chatViewerMessages: ChatViewerMessage[] = []; let chatViewerFormat: 'replay' | 'live' = 'replay'; let chatViewerSessionGeneration = 0; let chatViewerReadAbort: AbortController | null = null; +let chatViewerVirtualList: ReturnType | null = null; const chatViewerRenderGeneration = new RendererAccessibility.RenderGeneration(); const CHAT_VIEWER_ROW_HEIGHT = 29; const CHAT_VIEWER_OVERSCAN = 12; +function disposeChatViewerVirtualList(): void { + chatViewerVirtualList?.dispose(); + chatViewerVirtualList = null; +} + async function openChatViewer(filePath: string, title: string): Promise { const sessionGeneration = ++chatViewerSessionGeneration; chatViewerReadAbort?.abort(); + chatViewerRenderGeneration.cancel(); const abortController = new AbortController(); chatViewerReadAbort = abortController; const modal = byId('chatViewerModal'); @@ -426,13 +475,16 @@ async function openChatViewer(filePath: string, title: string): Promise { const status = byId('chatViewerStatus'); const filterInput = byId('chatViewerFilter'); byId('chatViewerTitle').textContent = title || UI_TEXT.queue.viewChat; + disposeChatViewerVirtualList(); list.replaceChildren(); + RendererAccessibility.setBusy(list, true); filterInput.value = ''; status.textContent = UI_TEXT.queue.viewChatLoading; RendererAccessibility.openDialog('chatViewerModal', { initialFocus: filterInput, onEscape: closeChatViewer }); const result = await window.api.readChatFile(filePath, abortController.signal); if (sessionGeneration !== chatViewerSessionGeneration || abortController.signal.aborted || !modal.classList.contains('show')) return; + RendererAccessibility.setBusy(list, false); if (!result.success || !Array.isArray(result.messages)) { if (result.cancelled) return; status.textContent = UI_TEXT.queue.viewChatFailed + (result.error ? `: ${result.error}` : ''); @@ -451,6 +503,8 @@ function closeChatViewer(): void { chatViewerReadAbort?.abort(); chatViewerReadAbort = null; chatViewerRenderGeneration.cancel(); + disposeChatViewerVirtualList(); + RendererAccessibility.setBusy(byId('chatViewerList'), false); RendererAccessibility.closeDialog('chatViewerModal'); chatViewerMessages = []; } @@ -458,6 +512,8 @@ function closeChatViewer(): void { function onChatViewerFilterChange(): void { const generation = chatViewerRenderGeneration.next(); const list = byId('chatViewerList'); + disposeChatViewerVirtualList(); + RendererAccessibility.setBusy(list, false); list.scrollTop = 0; list.replaceChildren(); const filter = byId('chatViewerFilter').value.trim().toLowerCase(); @@ -465,7 +521,7 @@ function onChatViewerFilterChange(): void { renderChatViewerList(chatViewerMessages, generation); return; } - list.setAttribute('aria-busy', 'true'); + RendererAccessibility.setBusy(list, true); const filtered: ChatViewerMessage[] = []; let index = 0; const filterChunk = (): void => { @@ -481,7 +537,7 @@ function onChatViewerFilterChange(): void { window.setTimeout(filterChunk, 0); return; } - list.removeAttribute('aria-busy'); + RendererAccessibility.setBusy(list, false); renderChatViewerList(filtered, generation); }; filterChunk(); @@ -519,12 +575,15 @@ function createChatViewerRow(m: ChatViewerMessage): HTMLElement { const message = document.createElement('span'); message.textContent = ' ' + (m.msg || m.text || ''); row.appendChild(message); + row.title = [time, user, m.msg || m.text || ''].filter(Boolean).join(' '); return row; } function renderChatViewerList(messages: ChatViewerMessage[], generation: number): void { if (!chatViewerRenderGeneration.isCurrent(generation) || !RendererAccessibility.isDialogOpen('chatViewerModal')) return; const list = byId('chatViewerList'); + disposeChatViewerVirtualList(); + RendererAccessibility.setBusy(list, false); list.replaceChildren(); const canvas = document.createElement('div'); canvas.className = 'chat-viewer-virtual-canvas'; @@ -533,24 +592,18 @@ function renderChatViewerList(messages: ChatViewerMessage[], generation: number) 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(); - }); - }, { passive: true }); - renderVisibleRows(); + chatViewerVirtualList = RendererAccessibility.createFixedHeightVirtualList(list, { + itemCount: () => messages.length, + rowHeight: CHAT_VIEWER_ROW_HEIGHT, + overscan: CHAT_VIEWER_OVERSCAN, + render: (range) => { + if (!chatViewerRenderGeneration.isCurrent(generation) || !RendererAccessibility.isDialogOpen('chatViewerModal')) return; + 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); + } + }); } function formatChatTimeMarker(m: ChatViewerMessage): string { diff --git a/src/styles.css b/src/styles.css index a3cda60..541cd17 100644 --- a/src/styles.css +++ b/src/styles.css @@ -4914,13 +4914,17 @@ 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; + box-sizing: border-box; + display: flex; + align-items: center; + height: 29px; padding: 4px 8px; line-height: 1.55; border-radius: 4px; transition: background 0.12s; font-size: 13px; - word-wrap: break-word; + overflow: hidden; + white-space: nowrap; } .chat-viewer-virtual-canvas { @@ -4952,6 +4956,13 @@ input[type="number"]::-webkit-outer-spin-button { 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 { color: var(--accent); 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 block, not the renderer. */ .event-viewer-row { + box-sizing: border-box; + display: flex; + align-items: center; + height: 36px; padding: 8px 10px; border-bottom: 1px solid var(--border-soft); 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 { @@ -4988,7 +5017,7 @@ input[type="number"]::-webkit-outer-spin-button { .event-viewer-time { color: var(--text-secondary); - margin-right: 8px; + flex: 0 0 auto; font-family: 'Consolas', 'Segoe UI Mono', monospace; } @@ -5002,7 +5031,7 @@ input[type="number"]::-webkit-outer-spin-button { .event-viewer-tag { font-weight: 600; - margin-right: 8px; + flex: 0 0 auto; color: var(--accent); text-transform: uppercase; letter-spacing: 0.3px; @@ -5044,9 +5073,12 @@ input[type="number"]::-webkit-outer-spin-button { } .event-viewer-detail { - margin-top: 4px; + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; color: var(--text); - line-height: 1.5; } /* ============================================