diff --git a/src/vs/base/browser/dom.ts b/src/vs/base/browser/dom.ts index 1f20d93442f..8e98cb9ca81 100644 --- a/src/vs/base/browser/dom.ts +++ b/src/vs/base/browser/dom.ts @@ -149,6 +149,24 @@ export function addDisposableNonBubblingPointerOutListener(node: Element, handle }); } +export function createEventEmitter(target: HTMLElement, type: K, options?: boolean | AddEventListenerOptions): Emitter { + let domListener: DomListener | null = null; + const handler = (e: HTMLElementEventMap[K]) => result.fire(e); + const onFirstListenerAdd = () => { + if (!domListener) { + domListener = new DomListener(target, type, handler, options); + } + }; + const onLastListenerRemove = () => { + if (domListener) { + domListener.dispose(); + domListener = null; + } + }; + const result = new Emitter({ onFirstListenerAdd, onLastListenerRemove }); + return result; +} + interface IRequestAnimationFrame { (callback: (time: number) => void): number; } diff --git a/src/vs/editor/browser/controller/textAreaHandler.ts b/src/vs/editor/browser/controller/textAreaHandler.ts index 323517cac2d..cb7e0678e81 100644 --- a/src/vs/editor/browser/controller/textAreaHandler.ts +++ b/src/vs/editor/browser/controller/textAreaHandler.ts @@ -11,7 +11,7 @@ import { IKeyboardEvent } from 'vs/base/browser/keyboardEvent'; import * as platform from 'vs/base/common/platform'; import * as strings from 'vs/base/common/strings'; import { Configuration } from 'vs/editor/browser/config/configuration'; -import { CopyOptions, ICompositionData, IPasteData, ITextAreaInputHost, TextAreaInput, ClipboardDataToCopy } from 'vs/editor/browser/controller/textAreaInput'; +import { CopyOptions, ICompositionData, IPasteData, ITextAreaInputHost, TextAreaInput, ClipboardDataToCopy, TextAreaWrapper } from 'vs/editor/browser/controller/textAreaInput'; import { ISimpleModel, ITypeData, PagedScreenReaderStrategy, TextAreaState, _debugComposition } from 'vs/editor/browser/controller/textAreaState'; import { ViewController } from 'vs/editor/browser/view/viewController'; import { PartFingerprint, PartFingerprints, ViewPart } from 'vs/editor/browser/view/viewPart'; @@ -226,7 +226,8 @@ export class TextAreaHandler extends ViewPart { } }; - this._textAreaInput = this._register(new TextAreaInput(textAreaInputHost, this.textArea)); + const textAreaWrapper = this._register(new TextAreaWrapper(this.textArea)); + this._textAreaInput = this._register(new TextAreaInput(textAreaInputHost, textAreaWrapper)); this._register(this._textAreaInput.onKeyDown((e: IKeyboardEvent) => { this._viewController.emitKeyDown(e); diff --git a/src/vs/editor/browser/controller/textAreaInput.ts b/src/vs/editor/browser/controller/textAreaInput.ts index 51059054039..ab3ca2952e7 100644 --- a/src/vs/editor/browser/controller/textAreaInput.ts +++ b/src/vs/editor/browser/controller/textAreaInput.ts @@ -6,7 +6,7 @@ import * as browser from 'vs/base/browser/browser'; import * as dom from 'vs/base/browser/dom'; import { FastDomNode } from 'vs/base/browser/fastDomNode'; -import { IKeyboardEvent } from 'vs/base/browser/keyboardEvent'; +import { IKeyboardEvent, StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; import { RunOnceScheduler } from 'vs/base/common/async'; import { Emitter, Event } from 'vs/base/common/event'; import { KeyCode } from 'vs/base/common/keyCodes'; @@ -61,11 +61,6 @@ export interface ITextAreaInputHost { deduceModelPosition(viewAnchorPosition: Position, deltaOffset: number, lineFeedCnt: number): Position; } -interface CompositionEvent extends UIEvent { - readonly data: string; - readonly locale: string; -} - interface InMemoryClipboardMetadata { lastCopiedValue: string; data: ClipboardStoredMetadata; @@ -103,6 +98,28 @@ export interface ICompositionStartEvent { revealDeltaColumns: number; } +export interface ICompleteTextAreaWrapper extends ITextAreaWrapper { + readonly onKeyDown: Event; + readonly onKeyUp: Event; + readonly onCompositionStart: Event; + readonly onCompositionUpdate: Event; + readonly onCompositionEnd: Event; + readonly onBeforeInput: Event; + readonly onInput: Event; + readonly onCut: Event; + readonly onCopy: Event; + readonly onPaste: Event; + readonly onFocus: Event; + readonly onBlur: Event; + readonly onSyntheticTap: Event; + + setIgnoreSelectionChangeTime(reason: string): void; + getIgnoreSelectionChangeTime(): number; + resetSelectionChangeTime(): void; + + hasFocus(): boolean; +} + /** * Writes screen reader content to the textarea and is able to analyze its input events to generate: * - onCut @@ -149,7 +166,7 @@ export class TextAreaInput extends Disposable { // --- private readonly _host: ITextAreaInputHost; - private readonly _textArea: TextAreaWrapper; + private readonly _textArea: ICompleteTextAreaWrapper; private readonly _asyncTriggerCut: RunOnceScheduler; private readonly _asyncFocusGainWriteScreenReaderContent: RunOnceScheduler; @@ -160,10 +177,10 @@ export class TextAreaInput extends Disposable { private _isDoingComposition: boolean; private _nextCommand: ReadFromTextArea; - constructor(host: ITextAreaInputHost, private textArea: FastDomNode) { + constructor(host: ITextAreaInputHost, textArea: ICompleteTextAreaWrapper) { super(); this._host = host; - this._textArea = this._register(new TextAreaWrapper(textArea)); + this._textArea = textArea; this._asyncTriggerCut = this._register(new RunOnceScheduler(() => this._onCut.fire(), 0)); this._asyncFocusGainWriteScreenReaderContent = this._register(new RunOnceScheduler(() => this.writeScreenReaderContent('asyncFocusGain'), 0)); @@ -177,7 +194,8 @@ export class TextAreaInput extends Disposable { let lastKeyDown: IKeyboardEvent | null = null; - this._register(dom.addStandardDisposableListener(textArea.domNode, 'keydown', (e: IKeyboardEvent) => { + this._register(this._textArea.onKeyDown((_e) => { + const e = new StandardKeyboardEvent(_e); if (e.keyCode === KeyCode.KEY_IN_COMPOSITION || (this._isDoingComposition && e.keyCode === KeyCode.Backspace)) { // Stop propagation for keyDown events if the IME is processing key input @@ -194,11 +212,12 @@ export class TextAreaInput extends Disposable { this._onKeyDown.fire(e); })); - this._register(dom.addStandardDisposableListener(textArea.domNode, 'keyup', (e: IKeyboardEvent) => { + this._register(this._textArea.onKeyUp((_e) => { + const e = new StandardKeyboardEvent(_e); this._onKeyUp.fire(e); })); - this._register(dom.addDisposableListener(textArea.domNode, 'compositionstart', (e: CompositionEvent) => { + this._register(this._textArea.onCompositionStart((e) => { if (_debugComposition) { console.log(`[compositionstart]`, e); } @@ -277,7 +296,7 @@ export class TextAreaInput extends Disposable { return [newState, typeInput]; }; - this._register(dom.addDisposableListener(textArea.domNode, 'compositionupdate', (e: CompositionEvent) => { + this._register(this._textArea.onCompositionUpdate((e) => { if (_debugComposition) { console.log(`[compositionupdate]`, e); } @@ -298,7 +317,7 @@ export class TextAreaInput extends Disposable { this._onCompositionUpdate.fire(e); })); - this._register(dom.addDisposableListener(textArea.domNode, 'compositionend', (e: CompositionEvent) => { + this._register(this._textArea.onCompositionEnd((e) => { if (_debugComposition) { console.log(`[compositionend]`, e); } @@ -335,7 +354,7 @@ export class TextAreaInput extends Disposable { this._onCompositionEnd.fire(); })); - this._register(dom.addDisposableListener(textArea.domNode, 'input', () => { + this._register(this._textArea.onInput((e) => { // Pretend here we touched the text area, as the `input` event will most likely // result in a `selectionchange` event which we want to ignore this._textArea.setIgnoreSelectionChangeTime('received input event'); @@ -365,7 +384,7 @@ export class TextAreaInput extends Disposable { // --- Clipboard operations - this._register(dom.addDisposableListener(textArea.domNode, 'cut', (e: ClipboardEvent) => { + this._register(this._textArea.onCut((e) => { // Pretend here we touched the text area, as the `cut` event will most likely // result in a `selectionchange` event which we want to ignore this._textArea.setIgnoreSelectionChangeTime('received cut event'); @@ -374,11 +393,11 @@ export class TextAreaInput extends Disposable { this._asyncTriggerCut.schedule(); })); - this._register(dom.addDisposableListener(textArea.domNode, 'copy', (e: ClipboardEvent) => { + this._register(this._textArea.onCopy((e) => { this._ensureClipboardGetsEditorSelection(e); })); - this._register(dom.addDisposableListener(textArea.domNode, 'paste', (e: ClipboardEvent) => { + this._register(this._textArea.onPaste((e) => { // Pretend here we touched the text area, as the `paste` event will most likely // result in a `selectionchange` event which we want to ignore this._textArea.setIgnoreSelectionChangeTime('received paste event'); @@ -397,7 +416,7 @@ export class TextAreaInput extends Disposable { } })); - this._register(dom.addDisposableListener(textArea.domNode, 'focus', () => { + this._register(this._textArea.onFocus(() => { const hadFocus = this._hasFocus; this._setHasFocus(true); @@ -408,7 +427,7 @@ export class TextAreaInput extends Disposable { this._asyncFocusGainWriteScreenReaderContent.schedule(); } })); - this._register(dom.addDisposableListener(textArea.domNode, 'blur', () => { + this._register(this._textArea.onBlur(() => { if (this._isDoingComposition) { // See https://github.com/microsoft/vscode/issues/112621 // where compositionend is not triggered when the editor @@ -425,7 +444,7 @@ export class TextAreaInput extends Disposable { } this._setHasFocus(false); })); - this._register(dom.addDisposableListener(textArea.domNode, TextAreaSyntethicEvents.Tap, () => { + this._register(this._textArea.onSyntheticTap(() => { if (browser.isAndroid && this._isDoingComposition) { // on Android, tapping does not cancel the current composition, so the // textarea is stuck showing the old composition @@ -547,14 +566,7 @@ export class TextAreaInput extends Disposable { } public refreshFocusState(): void { - const shadowRoot = dom.getShadowRoot(this.textArea.domNode); - if (shadowRoot) { - this._setHasFocus(shadowRoot.activeElement === this.textArea.domNode); - } else if (dom.isInDOM(this.textArea.domNode)) { - this._setHasFocus(document.activeElement === this.textArea.domNode); - } else { - this._setHasFocus(false); - } + this._setHasFocus(this._textArea.hasFocus()); } private _setHasFocus(newHasFocus: boolean): void { @@ -686,15 +698,44 @@ class ClipboardEventUtils { } } -class TextAreaWrapper extends Disposable implements ITextAreaWrapper { +export class TextAreaWrapper extends Disposable implements ICompleteTextAreaWrapper { + + public readonly onKeyDown = this._register(dom.createEventEmitter(this._actual.domNode, 'keydown')).event; + public readonly onKeyUp = this._register(dom.createEventEmitter(this._actual.domNode, 'keyup')).event; + public readonly onCompositionStart = this._register(dom.createEventEmitter(this._actual.domNode, 'compositionstart')).event; + public readonly onCompositionUpdate = this._register(dom.createEventEmitter(this._actual.domNode, 'compositionupdate')).event; + public readonly onCompositionEnd = this._register(dom.createEventEmitter(this._actual.domNode, 'compositionend')).event; + public readonly onBeforeInput = this._register(dom.createEventEmitter(this._actual.domNode, 'beforeinput')).event; + public readonly onInput = >this._register(dom.createEventEmitter(this._actual.domNode, 'input')).event; + public readonly onCut = this._register(dom.createEventEmitter(this._actual.domNode, 'cut')).event; + public readonly onCopy = this._register(dom.createEventEmitter(this._actual.domNode, 'copy')).event; + public readonly onPaste = this._register(dom.createEventEmitter(this._actual.domNode, 'paste')).event; + public readonly onFocus = this._register(dom.createEventEmitter(this._actual.domNode, 'focus')).event; + public readonly onBlur = this._register(dom.createEventEmitter(this._actual.domNode, 'blur')).event; + + private _onSyntheticTap = this._register(new Emitter()); + public readonly onSyntheticTap: Event = this._onSyntheticTap.event; - private readonly _actual: FastDomNode; private _ignoreSelectionChangeTime: number; - constructor(_textArea: FastDomNode) { + constructor( + private readonly _actual: FastDomNode + ) { super(); - this._actual = _textArea; this._ignoreSelectionChangeTime = 0; + + this._register(dom.addDisposableListener(this._actual.domNode, TextAreaSyntethicEvents.Tap, () => this._onSyntheticTap.fire())); + } + + public hasFocus(): boolean { + const shadowRoot = dom.getShadowRoot(this._actual.domNode); + if (shadowRoot) { + return shadowRoot.activeElement === this._actual.domNode; + } else if (dom.isInDOM(this._actual.domNode)) { + return document.activeElement === this._actual.domNode; + } else { + return false; + } } public setIgnoreSelectionChangeTime(reason: string): void { diff --git a/src/vs/editor/test/browser/controller/imeTester.ts b/src/vs/editor/test/browser/controller/imeTester.ts index cb6908d4a34..915379c5643 100644 --- a/src/vs/editor/test/browser/controller/imeTester.ts +++ b/src/vs/editor/test/browser/controller/imeTester.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { createFastDomNode } from 'vs/base/browser/fastDomNode'; -import { ITextAreaInputHost, TextAreaInput } from 'vs/editor/browser/controller/textAreaInput'; +import { ITextAreaInputHost, TextAreaInput, TextAreaWrapper } from 'vs/editor/browser/controller/textAreaInput'; import { ISimpleModel, PagedScreenReaderStrategy, TextAreaState } from 'vs/editor/browser/controller/textAreaState'; import { Position } from 'vs/editor/common/core/position'; import { IRange, Range } from 'vs/editor/common/core/range'; @@ -111,7 +111,7 @@ function doCreateTest(description: string, inputStr: string, expectedStr: string } }; - let handler = new TextAreaInput(textAreaInputHost, createFastDomNode(input)); + let handler = new TextAreaInput(textAreaInputHost, new TextAreaWrapper(createFastDomNode(input))); let output = document.createElement('pre'); output.className = 'output';