Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/editor/browser/controller/editContext/native/screenReaderContentRich.ts
3296 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { addDisposableListener, getActiveWindow, isHTMLElement } from '../../../../../base/browser/dom.js';
7
import { FastDomNode } from '../../../../../base/browser/fastDomNode.js';
8
import { createTrustedTypesPolicy } from '../../../../../base/browser/trustedTypes.js';
9
import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js';
10
import { EditorFontLigatures, EditorOption, FindComputedEditorOptionValueById, IComputedEditorOptions } from '../../../../common/config/editorOptions.js';
11
import { Range } from '../../../../common/core/range.js';
12
import { Selection } from '../../../../common/core/selection.js';
13
import { StringBuilder } from '../../../../common/core/stringBuilder.js';
14
import { LineDecoration } from '../../../../common/viewLayout/lineDecorations.js';
15
import { CharacterMapping, RenderLineInput, renderViewLine } from '../../../../common/viewLayout/viewLineRenderer.js';
16
import { ViewContext } from '../../../../common/viewModel/viewContext.js';
17
import { IPagedScreenReaderStrategy } from '../screenReaderUtils.js';
18
import { ISimpleModel } from '../../../../common/viewModel/screenReaderSimpleModel.js';
19
import { Disposable, IDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js';
20
import { IME } from '../../../../../base/common/ime.js';
21
import { ViewController } from '../../../view/viewController.js';
22
import { IScreenReaderContent } from './screenReaderUtils.js';
23
import { getColumnOfNodeOffset } from '../../../viewParts/viewLines/viewLine.js';
24
25
const ttPolicy = createTrustedTypesPolicy('richScreenReaderContent', { createHTML: value => value });
26
27
const LINE_NUMBER_ATTRIBUTE = 'data-line-number';
28
29
export class RichScreenReaderContent extends Disposable implements IScreenReaderContent {
30
31
private readonly _selectionChangeListener = this._register(new MutableDisposable());
32
33
private _accessibilityPageSize: number = 1;
34
private _ignoreSelectionChangeTime: number = 0;
35
36
private _state: RichScreenReaderState = new RichScreenReaderState([]);
37
private _strategy: RichPagedScreenReaderStrategy = new RichPagedScreenReaderStrategy();
38
39
private _renderedLines: Map<number, RichRenderedScreenReaderLine> = new Map();
40
private _renderedSelection: Selection = new Selection(1, 1, 1, 1);
41
42
constructor(
43
private readonly _domNode: FastDomNode<HTMLElement>,
44
private readonly _context: ViewContext,
45
private readonly _viewController: ViewController,
46
@IAccessibilityService private readonly _accessibilityService: IAccessibilityService
47
) {
48
super();
49
this.onConfigurationChanged(this._context.configuration.options);
50
}
51
52
public updateScreenReaderContent(primarySelection: Selection): void {
53
const focusedElement = getActiveWindow().document.activeElement;
54
if (!focusedElement || focusedElement !== this._domNode.domNode) {
55
return;
56
}
57
const isScreenReaderOptimized = this._accessibilityService.isScreenReaderOptimized();
58
if (isScreenReaderOptimized) {
59
const state = this._getScreenReaderContentLineIntervals(primarySelection);
60
if (!this._state.equals(state)) {
61
this._state = state;
62
this._renderedLines = this._renderScreenReaderContent(state);
63
}
64
if (!this._renderedSelection.equalsSelection(primarySelection)) {
65
this._renderedSelection = primarySelection;
66
this._setSelectionOnScreenReaderContent(this._context, this._renderedLines, primarySelection);
67
}
68
} else {
69
this._state = new RichScreenReaderState([]);
70
this._setIgnoreSelectionChangeTime('setValue');
71
this._domNode.domNode.textContent = '';
72
}
73
}
74
75
public updateScrollTop(primarySelection: Selection): void {
76
const intervals = this._state.intervals;
77
if (!intervals.length) {
78
return;
79
}
80
const viewLayout = this._context.viewModel.viewLayout;
81
const stateStartLineNumber = intervals[0].startLine;
82
const verticalOffsetOfStateStartLineNumber = viewLayout.getVerticalOffsetForLineNumber(stateStartLineNumber);
83
const verticalOffsetOfPositionLineNumber = viewLayout.getVerticalOffsetForLineNumber(primarySelection.positionLineNumber);
84
this._domNode.domNode.scrollTop = verticalOffsetOfPositionLineNumber - verticalOffsetOfStateStartLineNumber;
85
}
86
87
public onFocusChange(newFocusValue: boolean): void {
88
if (newFocusValue) {
89
this._selectionChangeListener.value = this._setSelectionChangeListener();
90
} else {
91
this._selectionChangeListener.value = undefined;
92
}
93
}
94
95
public onConfigurationChanged(options: IComputedEditorOptions): void {
96
this._accessibilityPageSize = options.get(EditorOption.accessibilityPageSize);
97
}
98
99
public onWillCut(): void {
100
this._setIgnoreSelectionChangeTime('onCut');
101
}
102
103
public onWillPaste(): void {
104
this._setIgnoreSelectionChangeTime('onWillPaste');
105
}
106
107
// --- private methods
108
109
private _setIgnoreSelectionChangeTime(reason: string): void {
110
this._ignoreSelectionChangeTime = Date.now();
111
}
112
113
private _setSelectionChangeListener(): IDisposable {
114
// See https://github.com/microsoft/vscode/issues/27216 and https://github.com/microsoft/vscode/issues/98256
115
// When using a Braille display or NVDA for example, it is possible for users to reposition the
116
// system caret. This is reflected in Chrome as a `selectionchange` event and needs to be reflected within the editor.
117
118
// `selectionchange` events often come multiple times for a single logical change
119
// so throttle multiple `selectionchange` events that burst in a short period of time.
120
let previousSelectionChangeEventTime = 0;
121
return addDisposableListener(this._domNode.domNode.ownerDocument, 'selectionchange', () => {
122
const activeElement = getActiveWindow().document.activeElement;
123
const isFocused = activeElement === this._domNode.domNode;
124
if (!isFocused) {
125
return;
126
}
127
const isScreenReaderOptimized = this._accessibilityService.isScreenReaderOptimized();
128
if (!isScreenReaderOptimized || !IME.enabled) {
129
return;
130
}
131
const now = Date.now();
132
const delta1 = now - previousSelectionChangeEventTime;
133
previousSelectionChangeEventTime = now;
134
if (delta1 < 5) {
135
// received another `selectionchange` event within 5ms of the previous `selectionchange` event
136
// => ignore it
137
return;
138
}
139
const delta2 = now - this._ignoreSelectionChangeTime;
140
this._ignoreSelectionChangeTime = 0;
141
if (delta2 < 100) {
142
// received a `selectionchange` event within 100ms since we touched the hidden div
143
// => ignore it, since we caused it
144
return;
145
}
146
const selection = this._getEditorSelectionFromDomRange();
147
if (!selection) {
148
return;
149
}
150
this._viewController.setSelection(selection);
151
});
152
}
153
154
private _renderScreenReaderContent(state: RichScreenReaderState): Map<number, RichRenderedScreenReaderLine> {
155
const nodes: HTMLDivElement[] = [];
156
const renderedLines = new Map<number, RichRenderedScreenReaderLine>();
157
for (const interval of state.intervals) {
158
for (let lineNumber = interval.startLine; lineNumber <= interval.endLine; lineNumber++) {
159
const renderedLine = this._renderLine(lineNumber);
160
renderedLines.set(lineNumber, renderedLine);
161
nodes.push(renderedLine.domNode);
162
}
163
}
164
this._setIgnoreSelectionChangeTime('setValue');
165
this._domNode.domNode.replaceChildren(...nodes);
166
return renderedLines;
167
}
168
169
private _renderLine(viewLineNumber: number): RichRenderedScreenReaderLine {
170
const viewModel = this._context.viewModel;
171
const positionLineData = viewModel.getViewLineRenderingData(viewLineNumber);
172
const options = this._context.configuration.options;
173
const fontInfo = options.get(EditorOption.fontInfo);
174
const stopRenderingLineAfter = options.get(EditorOption.stopRenderingLineAfter);
175
const renderControlCharacters = options.get(EditorOption.renderControlCharacters);
176
const fontLigatures = options.get(EditorOption.fontLigatures);
177
const disableMonospaceOptimizations = options.get(EditorOption.disableMonospaceOptimizations);
178
const lineDecorations = LineDecoration.filter(positionLineData.inlineDecorations, viewLineNumber, positionLineData.minColumn, positionLineData.maxColumn);
179
const useMonospaceOptimizations = fontInfo.isMonospace && !disableMonospaceOptimizations;
180
const useFontLigatures = fontLigatures !== EditorFontLigatures.OFF;
181
let renderWhitespace: FindComputedEditorOptionValueById<EditorOption.renderWhitespace>;
182
const experimentalWhitespaceRendering = options.get(EditorOption.experimentalWhitespaceRendering);
183
if (experimentalWhitespaceRendering === 'off') {
184
renderWhitespace = options.get(EditorOption.renderWhitespace);
185
} else {
186
renderWhitespace = 'none';
187
}
188
const renderLineInput = new RenderLineInput(
189
useMonospaceOptimizations,
190
fontInfo.canUseHalfwidthRightwardsArrow,
191
positionLineData.content,
192
positionLineData.continuesWithWrappedLine,
193
positionLineData.isBasicASCII,
194
positionLineData.containsRTL,
195
positionLineData.minColumn - 1,
196
positionLineData.tokens,
197
lineDecorations,
198
positionLineData.tabSize,
199
positionLineData.startVisibleColumn,
200
fontInfo.spaceWidth,
201
fontInfo.middotWidth,
202
fontInfo.wsmiddotWidth,
203
stopRenderingLineAfter,
204
renderWhitespace,
205
renderControlCharacters,
206
useFontLigatures,
207
null,
208
null,
209
0,
210
true
211
);
212
const htmlBuilder = new StringBuilder(10000);
213
const renderOutput = renderViewLine(renderLineInput, htmlBuilder);
214
const html = htmlBuilder.build();
215
const trustedhtml = ttPolicy?.createHTML(html) ?? html;
216
const lineHeight = viewModel.viewLayout.getLineHeightForLineNumber(viewLineNumber) + 'px';
217
const domNode = document.createElement('div');
218
domNode.innerHTML = trustedhtml as string;
219
domNode.style.lineHeight = lineHeight;
220
domNode.style.height = lineHeight;
221
domNode.setAttribute(LINE_NUMBER_ATTRIBUTE, viewLineNumber.toString());
222
return new RichRenderedScreenReaderLine(domNode, renderOutput.characterMapping);
223
}
224
225
private _setSelectionOnScreenReaderContent(context: ViewContext, renderedLines: Map<number, RichRenderedScreenReaderLine>, viewSelection: Selection): void {
226
const activeDocument = getActiveWindow().document;
227
const activeDocumentSelection = activeDocument.getSelection();
228
if (!activeDocumentSelection) {
229
return;
230
}
231
const startLineNumber = viewSelection.startLineNumber;
232
const endLineNumber = viewSelection.endLineNumber;
233
const startRenderedLine = renderedLines.get(startLineNumber);
234
const endRenderedLine = renderedLines.get(endLineNumber);
235
if (!startRenderedLine || !endRenderedLine) {
236
return;
237
}
238
const viewModel = context.viewModel;
239
const model = viewModel.model;
240
const coordinatesConverter = viewModel.coordinatesConverter;
241
const startRange = new Range(startLineNumber, 1, startLineNumber, viewSelection.selectionStartColumn);
242
const modelStartRange = coordinatesConverter.convertViewRangeToModelRange(startRange);
243
const characterCountForStart = model.getCharacterCountInRange(modelStartRange);
244
const endRange = new Range(endLineNumber, 1, endLineNumber, viewSelection.positionColumn);
245
const modelEndRange = coordinatesConverter.convertViewRangeToModelRange(endRange);
246
const characterCountForEnd = model.getCharacterCountInRange(modelEndRange);
247
const startDomPosition = startRenderedLine.characterMapping.getDomPosition(characterCountForStart);
248
const endDomPosition = endRenderedLine.characterMapping.getDomPosition(characterCountForEnd);
249
const startDomNode = startRenderedLine.domNode.firstChild!;
250
const endDomNode = endRenderedLine.domNode.firstChild!;
251
const startChildren = startDomNode.childNodes;
252
const endChildren = endDomNode.childNodes;
253
const startNode = startChildren.item(startDomPosition.partIndex);
254
const endNode = endChildren.item(endDomPosition.partIndex);
255
if (!startNode.firstChild || !endNode.firstChild) {
256
return;
257
}
258
this._setIgnoreSelectionChangeTime('setRange');
259
activeDocumentSelection.setBaseAndExtent(
260
startNode.firstChild,
261
viewSelection.startColumn === 1 ? 0 : startDomPosition.charIndex + 1,
262
endNode.firstChild,
263
viewSelection.endColumn === 1 ? 0 : endDomPosition.charIndex + 1
264
);
265
}
266
267
private _getScreenReaderContentLineIntervals(primarySelection: Selection): RichScreenReaderState {
268
return this._strategy.fromEditorSelection(this._context.viewModel, primarySelection, this._accessibilityPageSize);
269
}
270
271
private _getEditorSelectionFromDomRange(): Selection | undefined {
272
if (!this._renderedLines) {
273
return;
274
}
275
const selection = getActiveWindow().document.getSelection();
276
if (!selection) {
277
return;
278
}
279
const rangeCount = selection.rangeCount;
280
if (rangeCount === 0) {
281
return;
282
}
283
const range = selection.getRangeAt(0);
284
const startContainer = range.startContainer;
285
const endContainer = range.endContainer;
286
const startSpanElement = startContainer.parentElement;
287
const endSpanElement = endContainer.parentElement;
288
if (!startSpanElement || !isHTMLElement(startSpanElement) || !endSpanElement || !isHTMLElement(endSpanElement)) {
289
return;
290
}
291
const startLineDomNode = startSpanElement.parentElement?.parentElement;
292
const endLineDomNode = endSpanElement.parentElement?.parentElement;
293
if (!startLineDomNode || !endLineDomNode) {
294
return;
295
}
296
const startLineNumberAttribute = startLineDomNode.getAttribute(LINE_NUMBER_ATTRIBUTE);
297
const endLineNumberAttribute = endLineDomNode.getAttribute(LINE_NUMBER_ATTRIBUTE);
298
if (!startLineNumberAttribute || !endLineNumberAttribute) {
299
return;
300
}
301
const startLineNumber = parseInt(startLineNumberAttribute);
302
const endLineNumber = parseInt(endLineNumberAttribute);
303
const startMapping = this._renderedLines.get(startLineNumber)?.characterMapping;
304
const endMapping = this._renderedLines.get(endLineNumber)?.characterMapping;
305
if (!startMapping || !endMapping) {
306
return;
307
}
308
const startColumn = getColumnOfNodeOffset(startMapping, startSpanElement, range.startOffset);
309
const endColumn = getColumnOfNodeOffset(endMapping, endSpanElement, range.endOffset);
310
if (selection.direction === 'forward') {
311
return new Selection(
312
startLineNumber,
313
startColumn,
314
endLineNumber,
315
endColumn
316
);
317
} else {
318
return new Selection(
319
endLineNumber,
320
endColumn,
321
startLineNumber,
322
startColumn
323
);
324
}
325
}
326
}
327
328
class RichRenderedScreenReaderLine {
329
constructor(
330
public readonly domNode: HTMLDivElement,
331
public readonly characterMapping: CharacterMapping
332
) { }
333
}
334
335
class LineInterval {
336
constructor(
337
public readonly startLine: number,
338
public readonly endLine: number
339
) { }
340
}
341
342
class RichScreenReaderState {
343
344
constructor(public readonly intervals: LineInterval[]) { }
345
346
equals(other: RichScreenReaderState): boolean {
347
if (this.intervals.length !== other.intervals.length) {
348
return false;
349
}
350
for (let i = 0; i < this.intervals.length; i++) {
351
if (this.intervals[i].startLine !== other.intervals[i].startLine || this.intervals[i].endLine !== other.intervals[i].endLine) {
352
return false;
353
}
354
}
355
return true;
356
}
357
}
358
359
class RichPagedScreenReaderStrategy implements IPagedScreenReaderStrategy<RichScreenReaderState> {
360
361
constructor() { }
362
363
private _getPageOfLine(lineNumber: number, linesPerPage: number): number {
364
return Math.floor((lineNumber - 1) / linesPerPage);
365
}
366
367
private _getRangeForPage(context: ISimpleModel, page: number, linesPerPage: number): LineInterval {
368
const offset = page * linesPerPage;
369
const startLineNumber = offset + 1;
370
const endLineNumber = Math.min(offset + linesPerPage, context.getLineCount());
371
return new LineInterval(startLineNumber, endLineNumber);
372
}
373
374
public fromEditorSelection(context: ISimpleModel, viewSelection: Selection, linesPerPage: number): RichScreenReaderState {
375
const selectionStartPage = this._getPageOfLine(viewSelection.startLineNumber, linesPerPage);
376
const selectionStartPageRange = this._getRangeForPage(context, selectionStartPage, linesPerPage);
377
const selectionEndPage = this._getPageOfLine(viewSelection.endLineNumber, linesPerPage);
378
const selectionEndPageRange = this._getRangeForPage(context, selectionEndPage, linesPerPage);
379
const lineIntervals: LineInterval[] = [{ startLine: selectionStartPageRange.startLine, endLine: selectionStartPageRange.endLine }];
380
if (selectionStartPage + 1 < selectionEndPage) {
381
lineIntervals.push({ startLine: selectionEndPageRange.startLine, endLine: selectionEndPageRange.endLine });
382
}
383
return new RichScreenReaderState(lineIntervals);
384
}
385
}
386
387