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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, RendererOpenDialogState>();
|
||||
@@ -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<HTMLElement>(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,
|
||||
|
||||
+113
-60
@@ -308,26 +308,56 @@ interface EventLogEntry {
|
||||
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> {
|
||||
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);
|
||||
}
|
||||
row.appendChild(detail);
|
||||
list.appendChild(row);
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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<typeof RendererAccessibility.createFixedHeightVirtualList> | 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<void> {
|
||||
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<void> {
|
||||
const status = byId('chatViewerStatus');
|
||||
const filterInput = byId<HTMLInputElement>('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<HTMLInputElement>('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 => {
|
||||
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;
|
||||
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();
|
||||
}
|
||||
|
||||
function formatChatTimeMarker(m: ChatViewerMessage): string {
|
||||
|
||||
+38
-6
@@ -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;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
|
||||
Reference in New Issue
Block a user