mirror of
https://github.com/wavetermdev/xterm.js.git
synced 2026-08-05 13:43:48 -07:00
Initial prototype
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
import { ITerminal, IBuffer, IDisposable } from './Interfaces';
|
||||
|
||||
export class AccessibilityManager implements IDisposable {
|
||||
private _accessibilityTreeRoot: HTMLElement;
|
||||
private _rowContainer: HTMLElement;
|
||||
private _rowElements: HTMLElement[] = [];
|
||||
private _liveRegion: HTMLElement;
|
||||
|
||||
private _disposables: IDisposable[] = [];
|
||||
|
||||
/**
|
||||
* This queue has a character pushed to it for keys that are pressed, if the
|
||||
* next character added to the terminal is equal to the key char then it is
|
||||
* not announced (added to live region) because it has already been announced
|
||||
* by the textarea event (which cannot be canceled). There are some race
|
||||
* condition cases if there is typing while data is streaming, but this covers
|
||||
* the main case of typing into the prompt and inputting the answer to a
|
||||
* question (Y/N, etc.).
|
||||
*/
|
||||
private _charsToConsume: string[] = [];
|
||||
|
||||
constructor(private _terminal: ITerminal) {
|
||||
this._accessibilityTreeRoot = document.createElement('div');
|
||||
this._accessibilityTreeRoot.classList.add('accessibility');
|
||||
this._rowContainer = document.createElement('div');
|
||||
this._rowContainer.classList.add('accessibility-tree');
|
||||
for (let i = 0; i < this._terminal.rows; i++) {
|
||||
this._rowElements[i] = document.createElement('div');
|
||||
this._rowContainer.appendChild(this._rowElements[i]);
|
||||
}
|
||||
this._accessibilityTreeRoot.appendChild(this._rowContainer);
|
||||
|
||||
this._liveRegion = document.createElement('div');
|
||||
this._liveRegion.classList.add('live-region');
|
||||
this._liveRegion.setAttribute('aria-live', 'polite');
|
||||
this._accessibilityTreeRoot.appendChild(this._liveRegion);
|
||||
|
||||
this._terminal.element.appendChild(this._accessibilityTreeRoot);
|
||||
|
||||
this._addTerminalEventListener('resize', data => this._onResize(data.cols, data.rows));
|
||||
this._addTerminalEventListener('refresh', data => this._refreshRows(data.start, data.end));
|
||||
// Line feed is an issue as the prompt won't be read out after a command is run
|
||||
// this._terminal.on('lineFeed', () => this._onLineFeed());
|
||||
this._addTerminalEventListener('a11y.char', (char) => this._onChar(char));
|
||||
this._addTerminalEventListener('lineFeed', () => this._onChar('\n'));
|
||||
this._addTerminalEventListener('charsizechanged', () => this._refreshRowsDimensions());
|
||||
this._addTerminalEventListener('key', keyChar => this._onKey(keyChar));
|
||||
}
|
||||
|
||||
private _addTerminalEventListener(type: string, listener: (...args: any[]) => any): void {
|
||||
this._terminal.on(type, listener);
|
||||
this._disposables.push({
|
||||
dispose: () => {
|
||||
this._terminal.off(type, listener);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this._terminal.element.removeChild(this._accessibilityTreeRoot);
|
||||
this._accessibilityTreeRoot = null;
|
||||
this._rowContainer = null;
|
||||
this._liveRegion = null;
|
||||
this._rowContainer = null;
|
||||
this._rowElements = null;
|
||||
this._disposables.forEach(d => d.dispose());
|
||||
this._disposables = null;
|
||||
}
|
||||
|
||||
private _onResize(cols: number, rows: number): void {
|
||||
for (let i = this._rowContainer.children.length; i < this._terminal.rows; i++) {
|
||||
this._rowElements[i] = document.createElement('div');
|
||||
this._rowContainer.appendChild(this._rowElements[i]);
|
||||
}
|
||||
// TODO: Handle case when rows reduces
|
||||
|
||||
this._refreshRowsDimensions();
|
||||
}
|
||||
|
||||
private _onChar(char: string): void {
|
||||
if (this._charsToConsume.length > 0) {
|
||||
// Have the screen reader ignore the char if it was just input
|
||||
if (this._charsToConsume.shift() !== char) {
|
||||
this._liveRegion.textContent += char;
|
||||
}
|
||||
} else {
|
||||
this._liveRegion.textContent += char;
|
||||
}
|
||||
// TODO: Clear at some point
|
||||
// TOOD: Handle heaps of data
|
||||
|
||||
// This is temporary, should refresh at a much slower rate
|
||||
this._refreshRows();
|
||||
}
|
||||
|
||||
private _onKey(keyChar: string): void {
|
||||
this._charsToConsume.push(keyChar);
|
||||
}
|
||||
|
||||
// private _onLineFeed(): void {
|
||||
// const buffer: IBuffer = (<any>this._terminal.buffer);
|
||||
// const newLine = buffer.lines.get(buffer.ybase + buffer.y);
|
||||
// // Only use the data when the new line is ready
|
||||
// if (!(<any>newLine).isWrapped) {
|
||||
// this._accessibilityTreeRoot.textContent += `${this._getWrappedLineData(buffer, buffer.ybase + buffer.y - 1)}\n`;
|
||||
// }
|
||||
// }
|
||||
|
||||
// private _getWrappedLineData(buffer: IBuffer, lineIndex: number): string {
|
||||
// let lineData = buffer.translateBufferLineToString(lineIndex, true);
|
||||
// while (lineIndex >= 0 && (<any>buffer.lines.get(lineIndex--)).isWrapped) {
|
||||
// lineData = buffer.translateBufferLineToString(lineIndex, true) + lineData;
|
||||
// }
|
||||
// return lineData;
|
||||
// }
|
||||
|
||||
// TODO: Hook up to refresh when the renderer refreshes the range? Slower to prevent layout thrashing?
|
||||
private _refreshRows(start?: number, end?: number): void {
|
||||
const buffer: IBuffer = (<any>this._terminal.buffer);
|
||||
start = start || 0;
|
||||
end = end || this._terminal.rows - 1;
|
||||
for (let i = start; i <= end; i++) {
|
||||
const lineData = buffer.translateBufferLineToString(buffer.ybase + i, true);
|
||||
this._rowElements[i].textContent = lineData;
|
||||
}
|
||||
}
|
||||
|
||||
private _refreshRowsDimensions(): void {
|
||||
const buffer: IBuffer = (<any>this._terminal.buffer);
|
||||
const dimensions = this._terminal.renderer.dimensions;
|
||||
for (let i = 0; i < this._terminal.rows; i++) {
|
||||
this._rowElements[i].style.height = `${dimensions.actualCellHeight}px`;
|
||||
}
|
||||
// TODO: Verify it works on macOS and varying zoom levels
|
||||
// TODO: Fire when window resizes
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,10 @@ export class InputHandler implements IInputHandler {
|
||||
char = this._terminal.charset[char];
|
||||
}
|
||||
|
||||
if (this._terminal.options.screenReaderMode) {
|
||||
this._terminal.emit('a11y.char', char);
|
||||
}
|
||||
|
||||
let row = this._terminal.buffer.y + this._terminal.buffer.ybase;
|
||||
|
||||
// insert combining char in last cell
|
||||
|
||||
@@ -145,6 +145,7 @@ export interface ITerminalOptions {
|
||||
lineHeight?: number;
|
||||
rows?: number;
|
||||
screenKeys?: boolean;
|
||||
screenReaderMode?: boolean;
|
||||
scrollback?: number;
|
||||
tabStopWidth?: number;
|
||||
termName?: string;
|
||||
@@ -352,3 +353,7 @@ export interface ITheme {
|
||||
brightCyan?: string;
|
||||
brightWhite?: string;
|
||||
}
|
||||
|
||||
export interface IDisposable {
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ import { IMouseZoneManager } from './input/Interfaces';
|
||||
import { MouseZoneManager } from './input/MouseZoneManager';
|
||||
import { initialize as initializeCharAtlas } from './renderer/CharAtlas';
|
||||
import { IRenderer } from './renderer/Interfaces';
|
||||
import { AccessibilityManager } from './AccessibilityManager';
|
||||
|
||||
// Declares required for loadAddon
|
||||
declare var exports: any;
|
||||
@@ -85,6 +86,7 @@ const DEFAULT_OPTIONS: ITerminalOptions = {
|
||||
letterSpacing: 0,
|
||||
scrollback: 1000,
|
||||
screenKeys: false,
|
||||
screenReaderMode: false,
|
||||
debug: false,
|
||||
cancelEvents: false,
|
||||
disableStdin: false,
|
||||
@@ -204,6 +206,7 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT
|
||||
public charMeasure: CharMeasure;
|
||||
private _mouseZoneManager: IMouseZoneManager;
|
||||
public mouseHelper: MouseHelper;
|
||||
private _accessibilityManager: AccessibilityManager;
|
||||
|
||||
public cols: number;
|
||||
public rows: number;
|
||||
@@ -427,6 +430,18 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT
|
||||
this.buffers.resize(this.cols, this.rows);
|
||||
this.viewport.syncScrollArea();
|
||||
break;
|
||||
case 'screenReaderMode':
|
||||
if (value) {
|
||||
if (!this._accessibilityManager) {
|
||||
this._accessibilityManager = new AccessibilityManager(this);
|
||||
}
|
||||
} else {
|
||||
if (this._accessibilityManager) {
|
||||
this._accessibilityManager.dispose();
|
||||
this._accessibilityManager = null;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'tabStopWidth': this.buffers.setupTabStops(); break;
|
||||
case 'bellSound':
|
||||
case 'bellStyle': this.syncBellSound(); break;
|
||||
@@ -661,6 +676,10 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT
|
||||
|
||||
this.mouseHelper = new MouseHelper(this.renderer);
|
||||
|
||||
if (this.options.screenReaderMode) {
|
||||
this._accessibilityManager = new AccessibilityManager(this);
|
||||
}
|
||||
|
||||
// Measure the character size
|
||||
this.charMeasure.measure(this.options);
|
||||
|
||||
|
||||
@@ -122,3 +122,24 @@
|
||||
.xterm:not(.enable-mouse-events) {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.xterm .accessibility {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.xterm .accessibility-tree {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.xterm .live-region {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user