Path: blob/main/src/vs/editor/browser/controller/editContext/native/screenReaderContentSimple.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 } from '../../../../../base/browser/dom.js';6import { FastDomNode } from '../../../../../base/browser/fastDomNode.js';7import { AccessibilitySupport, IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js';8import { EditorOption, IComputedEditorOptions } from '../../../../common/config/editorOptions.js';9import { EndOfLineSequence } from '../../../../common/model.js';10import { ViewContext } from '../../../../common/viewModel/viewContext.js';11import { Selection } from '../../../../common/core/selection.js';12import { SimplePagedScreenReaderStrategy, ISimpleScreenReaderContentState } from '../screenReaderUtils.js';13import { PositionOffsetTransformer } from '../../../../common/core/text/positionToOffset.js';14import { Disposable, IDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js';15import { IME } from '../../../../../base/common/ime.js';16import { ViewController } from '../../../view/viewController.js';17import { IScreenReaderContent } from './screenReaderUtils.js';1819export class SimpleScreenReaderContent extends Disposable implements IScreenReaderContent {2021private readonly _selectionChangeListener = this._register(new MutableDisposable());2223private _accessibilityPageSize: number = 1;24private _ignoreSelectionChangeTime: number = 0;2526private _state: ISimpleScreenReaderContentState | undefined;27private _strategy: SimplePagedScreenReaderStrategy = new SimplePagedScreenReaderStrategy();2829constructor(30private readonly _domNode: FastDomNode<HTMLElement>,31private readonly _context: ViewContext,32private readonly _viewController: ViewController,33@IAccessibilityService private readonly _accessibilityService: IAccessibilityService34) {35super();36this.onConfigurationChanged(this._context.configuration.options);37}3839public updateScreenReaderContent(primarySelection: Selection): void {40const domNode = this._domNode.domNode;41const focusedElement = getActiveWindow().document.activeElement;42if (!focusedElement || focusedElement !== domNode) {43return;44}45const isScreenReaderOptimized = this._accessibilityService.isScreenReaderOptimized();46if (isScreenReaderOptimized) {47this._state = this._getScreenReaderContentState(primarySelection);48if (domNode.textContent !== this._state.value) {49this._setIgnoreSelectionChangeTime('setValue');50domNode.textContent = this._state.value;51}52const selection = getActiveWindow().document.getSelection();53if (!selection) {54return;55}56const data = this._getScreenReaderRange(this._state.selectionStart, this._state.selectionEnd);57if (!data) {58return;59}60this._setIgnoreSelectionChangeTime('setRange');61selection.setBaseAndExtent(62data.anchorNode,63data.anchorOffset,64data.focusNode,65data.focusOffset66);67} else {68this._state = undefined;69this._setIgnoreSelectionChangeTime('setValue');70this._domNode.domNode.textContent = '';71}72}7374public updateScrollTop(primarySelection: Selection): void {75if (!this._state) {76return;77}78const viewLayout = this._context.viewModel.viewLayout;79const stateStartLineNumber = this._state.startPositionWithinEditor.lineNumber;80const verticalOffsetOfStateStartLineNumber = viewLayout.getVerticalOffsetForLineNumber(stateStartLineNumber);81const verticalOffsetOfPositionLineNumber = viewLayout.getVerticalOffsetForLineNumber(primarySelection.positionLineNumber);82this._domNode.domNode.scrollTop = verticalOffsetOfPositionLineNumber - verticalOffsetOfStateStartLineNumber;83}8485public onFocusChange(newFocusValue: boolean): void {86if (newFocusValue) {87this._selectionChangeListener.value = this._setSelectionChangeListener();88} else {89this._selectionChangeListener.value = undefined;90}91}9293public onConfigurationChanged(options: IComputedEditorOptions): void {94this._accessibilityPageSize = options.get(EditorOption.accessibilityPageSize);95}9697public onWillCut(): void {98this._setIgnoreSelectionChangeTime('onCut');99}100101public onWillPaste(): void {102this._setIgnoreSelectionChangeTime('onWillPaste');103}104105// --- private methods106107public _setIgnoreSelectionChangeTime(reason: string): void {108this._ignoreSelectionChangeTime = Date.now();109}110111private _setSelectionChangeListener(): IDisposable {112// See https://github.com/microsoft/vscode/issues/27216 and https://github.com/microsoft/vscode/issues/98256113// When using a Braille display or NVDA for example, it is possible for users to reposition the114// system caret. This is reflected in Chrome as a `selectionchange` event and needs to be reflected within the editor.115116// `selectionchange` events often come multiple times for a single logical change117// so throttle multiple `selectionchange` events that burst in a short period of time.118let previousSelectionChangeEventTime = 0;119return addDisposableListener(this._domNode.domNode.ownerDocument, 'selectionchange', () => {120const isScreenReaderOptimized = this._accessibilityService.isScreenReaderOptimized();121if (!this._state || !isScreenReaderOptimized || !IME.enabled) {122return;123}124const activeElement = getActiveWindow().document.activeElement;125const isFocused = activeElement === this._domNode.domNode;126if (!isFocused) {127return;128}129const selection = getActiveWindow().document.getSelection();130if (!selection) {131return;132}133const rangeCount = selection.rangeCount;134if (rangeCount === 0) {135return;136}137const range = selection.getRangeAt(0);138139const now = Date.now();140const delta1 = now - previousSelectionChangeEventTime;141previousSelectionChangeEventTime = now;142if (delta1 < 5) {143// received another `selectionchange` event within 5ms of the previous `selectionchange` event144// => ignore it145return;146}147const delta2 = now - this._ignoreSelectionChangeTime;148this._ignoreSelectionChangeTime = 0;149if (delta2 < 100) {150// received a `selectionchange` event within 100ms since we touched the hidden div151// => ignore it, since we caused it152return;153}154155this._viewController.setSelection(this._getEditorSelectionFromDomRange(this._context, this._state, selection.direction, range));156});157}158159private _getScreenReaderContentState(primarySelection: Selection): ISimpleScreenReaderContentState {160const state = this._strategy.fromEditorSelection(161this._context.viewModel,162primarySelection,163this._accessibilityPageSize,164this._accessibilityService.getAccessibilitySupport() === AccessibilitySupport.Unknown165);166const endPosition = this._context.viewModel.model.getPositionAt(Infinity);167let value = state.value;168if (endPosition.column === 1 && primarySelection.getEndPosition().equals(endPosition)) {169value += '\n';170}171state.value = value;172return state;173}174175private _getScreenReaderRange(selectionOffsetStart: number, selectionOffsetEnd: number): { anchorNode: Node; anchorOffset: number; focusNode: Node; focusOffset: number } | undefined {176const textContent = this._domNode.domNode.firstChild;177if (!textContent) {178return;179}180const range = new globalThis.Range();181range.setStart(textContent, selectionOffsetStart);182range.setEnd(textContent, selectionOffsetEnd);183return {184anchorNode: textContent,185anchorOffset: selectionOffsetStart,186focusNode: textContent,187focusOffset: selectionOffsetEnd188};189}190191private _getEditorSelectionFromDomRange(context: ViewContext, state: ISimpleScreenReaderContentState, direction: string, range: globalThis.Range): Selection {192const viewModel = context.viewModel;193const model = viewModel.model;194const coordinatesConverter = viewModel.coordinatesConverter;195const modelScreenReaderContentStartPositionWithinEditor = coordinatesConverter.convertViewPositionToModelPosition(state.startPositionWithinEditor);196const offsetOfStartOfScreenReaderContent = model.getOffsetAt(modelScreenReaderContentStartPositionWithinEditor);197let offsetOfSelectionStart = range.startOffset + offsetOfStartOfScreenReaderContent;198let offsetOfSelectionEnd = range.endOffset + offsetOfStartOfScreenReaderContent;199const modelUsesCRLF = model.getEndOfLineSequence() === EndOfLineSequence.CRLF;200if (modelUsesCRLF) {201const screenReaderContentText = state.value;202const offsetTransformer = new PositionOffsetTransformer(screenReaderContentText);203const positionOfStartWithinText = offsetTransformer.getPosition(range.startOffset);204const positionOfEndWithinText = offsetTransformer.getPosition(range.endOffset);205offsetOfSelectionStart += positionOfStartWithinText.lineNumber - 1;206offsetOfSelectionEnd += positionOfEndWithinText.lineNumber - 1;207}208const positionOfSelectionStart = model.getPositionAt(offsetOfSelectionStart);209const positionOfSelectionEnd = model.getPositionAt(offsetOfSelectionEnd);210const selectionStart = direction === 'forward' ? positionOfSelectionStart : positionOfSelectionEnd;211const selectionEnd = direction === 'forward' ? positionOfSelectionEnd : positionOfSelectionStart;212return Selection.fromPositions(selectionStart, selectionEnd);213}214}215216217