@@ -10,6 +10,7 @@ import { Disposable, toDisposable } from 'common/Lifecycle';
1010import { ICoreBrowserService , IRenderService } from 'browser/services/Services' ;
1111import { IBuffer } from 'common/buffer/Types' ;
1212import { IInstantiationService } from 'common/services/Services' ;
13+ import { addDisposableDomListener } from 'browser/Lifecycle' ;
1314
1415const 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+
2126export 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 ) ;
0 commit comments