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