Skip to content

Commit 58bb7fc

Browse files
authored
Merge pull request #4742 from JasonXJ/sync-select
Allow selection in the a11y tree and sync the selection to terminal
2 parents 13be985 + b16724f commit 58bb7fc

File tree

5 files changed

+249
-53
lines changed

5 files changed

+249
-53
lines changed

css/xterm.css

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@
140140
cursor: crosshair;
141141
}
142142

143-
.xterm .xterm-accessibility,
143+
.xterm .xterm-accessibility:not(.debug),
144144
.xterm .xterm-message {
145145
position: absolute;
146146
left: 0;
@@ -152,6 +152,15 @@
152152
pointer-events: none;
153153
}
154154

155+
.xterm .xterm-accessibility-tree:not(.debug) *::selection {
156+
color: transparent;
157+
}
158+
159+
.xterm .xterm-accessibility-tree {
160+
user-select: text;
161+
white-space: pre;
162+
}
163+
155164
.xterm .live-region {
156165
position: absolute;
157166
left: -9999px;

src/browser/AccessibilityManager.ts

Lines changed: 132 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { Disposable, toDisposable } from 'common/Lifecycle';
1010
import { ICoreBrowserService, IRenderService } from 'browser/services/Services';
1111
import { IBuffer } from 'common/buffer/Types';
1212
import { IInstantiationService } from 'common/services/Services';
13+
import { addDisposableDomListener } from 'browser/Lifecycle';
1314

1415
const MAX_ROWS_TO_READ = 20;
1516

@@ -18,11 +19,17 @@ const enum BoundaryPosition {
1819
BOTTOM
1920
}
2021

22+
// Turn this on to unhide the accessibility tree and display it under
23+
// (instead of overlapping with) the terminal.
24+
const DEBUG = false;
25+
2126
export class AccessibilityManager extends Disposable {
27+
private _debugRootContainer: HTMLElement | undefined;
2228
private _accessibilityContainer: HTMLElement;
2329

2430
private _rowContainer: HTMLElement;
2531
private _rowElements: HTMLElement[];
32+
private _rowColumns: WeakMap<HTMLElement, number[]> = new WeakMap();
2633

2734
private _liveRegion: HTMLElement;
2835
private _liveRegionLineCount: number = 0;
@@ -80,7 +87,23 @@ export class AccessibilityManager extends Disposable {
8087
if (!this._terminal.element) {
8188
throw new Error('Cannot enable accessibility before Terminal.open');
8289
}
83-
this._terminal.element.insertAdjacentElement('afterbegin', this._accessibilityContainer);
90+
91+
if (DEBUG) {
92+
this._accessibilityContainer.classList.add('debug');
93+
this._rowContainer.classList.add('debug');
94+
95+
// Use a `<div class="xterm">` container so that the css will still apply.
96+
this._debugRootContainer = document.createElement('div');
97+
this._debugRootContainer.classList.add('xterm');
98+
99+
this._debugRootContainer.appendChild(document.createTextNode('------start a11y------'));
100+
this._debugRootContainer.appendChild(this._accessibilityContainer);
101+
this._debugRootContainer.appendChild(document.createTextNode('------end a11y------'));
102+
103+
this._terminal.element.insertAdjacentElement('afterend', this._debugRootContainer);
104+
} else {
105+
this._terminal.element.insertAdjacentElement('afterbegin', this._accessibilityContainer);
106+
}
84107

85108
this.register(this._terminal.onResize(e => this._handleResize(e.rows)));
86109
this.register(this._terminal.onRender(e => this._refreshRows(e.start, e.end)));
@@ -92,11 +115,16 @@ export class AccessibilityManager extends Disposable {
92115
this.register(this._terminal.onKey(e => this._handleKey(e.key)));
93116
this.register(this._terminal.onBlur(() => this._clearLiveRegion()));
94117
this.register(this._renderService.onDimensionsChange(() => this._refreshRowsDimensions()));
118+
this.register(addDisposableDomListener(document, 'selectionchange', () => this._handleSelectionChange()));
95119
this.register(this._coreBrowserService.onDprChange(() => this._refreshRowsDimensions()));
96120

97121
this._refreshRows();
98122
this.register(toDisposable(() => {
99-
this._accessibilityContainer.remove();
123+
if (DEBUG) {
124+
this._debugRootContainer!.remove();
125+
} else {
126+
this._accessibilityContainer.remove();
127+
}
100128
this._rowElements.length = 0;
101129
}));
102130
}
@@ -149,14 +177,18 @@ export class AccessibilityManager extends Disposable {
149177
const buffer: IBuffer = this._terminal.buffer;
150178
const setSize = buffer.lines.length.toString();
151179
for (let i = start; i <= end; i++) {
152-
const lineData = buffer.translateBufferLineToString(buffer.ydisp + i, true);
180+
const line = buffer.lines.get(buffer.ydisp + i);
181+
const columns: number[] = [];
182+
const lineData = line?.translateToString(true, undefined, undefined, columns) || '';
153183
const posInSet = (buffer.ydisp + i + 1).toString();
154184
const element = this._rowElements[i];
155185
if (element) {
156186
if (lineData.length === 0) {
157187
element.innerText = '\u00a0';
188+
this._rowColumns.set(element, [0, 1]);
158189
} else {
159190
element.textContent = lineData;
191+
this._rowColumns.set(element, columns);
160192
}
161193
element.setAttribute('aria-posinset', posInSet);
162194
element.setAttribute('aria-setsize', setSize);
@@ -233,6 +265,103 @@ export class AccessibilityManager extends Disposable {
233265
e.stopImmediatePropagation();
234266
}
235267

268+
private _handleSelectionChange(): void {
269+
if (this._rowElements.length === 0) {
270+
return;
271+
}
272+
273+
const selection = document.getSelection();
274+
if (!selection) {
275+
return;
276+
}
277+
278+
if (selection.isCollapsed) {
279+
// Only do something when the anchorNode is inside the row container. This
280+
// behavior mirrors what we do with mouse --- if the mouse clicks
281+
// somewhere outside of the terminal, we don't clear the selection.
282+
if (this._rowContainer.contains(selection.anchorNode)) {
283+
this._terminal.clearSelection();
284+
}
285+
return;
286+
}
287+
288+
if (!selection.anchorNode || !selection.focusNode) {
289+
console.error('anchorNode and/or focusNode are null');
290+
return;
291+
}
292+
293+
// Sort the two selection points in document order.
294+
let begin = { node: selection.anchorNode, offset: selection.anchorOffset };
295+
let end = { node: selection.focusNode, offset: selection.focusOffset };
296+
if ((begin.node.compareDocumentPosition(end.node) & Node.DOCUMENT_POSITION_PRECEDING) || (begin.node === end.node && begin.offset > end.offset) ) {
297+
[begin, end] = [end, begin];
298+
}
299+
300+
// Clamp begin/end to the inside of the row container.
301+
if (begin.node.compareDocumentPosition(this._rowElements[0]) & (Node.DOCUMENT_POSITION_CONTAINED_BY | Node.DOCUMENT_POSITION_FOLLOWING)) {
302+
begin = { node: this._rowElements[0].childNodes[0], offset: 0 };
303+
}
304+
if (!this._rowContainer.contains(begin.node)) {
305+
// This happens when `begin` is below the last row.
306+
return;
307+
}
308+
const lastRowElement = this._rowElements.slice(-1)[0];
309+
if (end.node.compareDocumentPosition(lastRowElement) & (Node.DOCUMENT_POSITION_CONTAINED_BY | Node.DOCUMENT_POSITION_PRECEDING)) {
310+
end = {
311+
node: lastRowElement,
312+
offset: lastRowElement.textContent?.length ?? 0
313+
};
314+
}
315+
if (!this._rowContainer.contains(end.node)) {
316+
// This happens when `end` is above the first row.
317+
return;
318+
}
319+
320+
const toRowColumn = ({ node, offset }: typeof begin): {row: number, column: number} | null => {
321+
// `node` is either the row element or the Text node inside it.
322+
const rowElement: any = node instanceof Text ? node.parentNode : node;
323+
let row = parseInt(rowElement?.getAttribute('aria-posinset'), 10) - 1;
324+
if (isNaN(row)) {
325+
console.warn('row is invalid. Race condition?');
326+
return null;
327+
}
328+
329+
const columns = this._rowColumns.get(rowElement);
330+
if (!columns) {
331+
console.warn('columns is null. Race condition?');
332+
return null;
333+
}
334+
335+
let column = offset < columns.length ? columns[offset] : columns.slice(-1)[0] + 1;
336+
if (column >= this._terminal.cols) {
337+
++row;
338+
column = 0;
339+
}
340+
return {
341+
row,
342+
column
343+
};
344+
};
345+
346+
const beginRowColumn = toRowColumn(begin);
347+
const endRowColumn = toRowColumn(end);
348+
349+
if (!beginRowColumn || !endRowColumn) {
350+
return;
351+
}
352+
353+
if (beginRowColumn.row > endRowColumn.row || (beginRowColumn.row === endRowColumn.row && beginRowColumn.column >= endRowColumn.column)) {
354+
// This should not happen unless we have some bugs.
355+
throw new Error('invalid range');
356+
}
357+
358+
this._terminal.select(
359+
beginRowColumn.column,
360+
beginRowColumn.row,
361+
(endRowColumn.row - beginRowColumn.row) * this._terminal.cols - beginRowColumn.column + endRowColumn.column
362+
);
363+
}
364+
236365
private _handleResize(rows: number): void {
237366
// Remove bottom boundary listener
238367
this._rowElements[this._rowElements.length - 1].removeEventListener('focus', this._bottomBoundaryFocusListener);

src/common/Types.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ export interface IBufferLine {
247247
clone(): IBufferLine;
248248
getTrimmedLength(): number;
249249
getNoBgTrimmedLength(): number;
250-
translateToString(trimRight?: boolean, startCol?: number, endCol?: number): string;
250+
translateToString(trimRight?: boolean, startCol?: number, endCol?: number, outColumns?: number[]): string;
251251

252252
/* direct access to cell attrs */
253253
getWidth(index: number): number;

0 commit comments

Comments
 (0)