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;
|
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
@@ -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
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
|
|||||||
Reference in New Issue
Block a user