mirror of
https://github.com/Microsoft/vscode
synced 2024-08-27 04:49:35 +00:00
Handle input on Android
Fixes microsoft/vscode#107524 Fixes microsoft/monaco-editor#48 Fixes microsoft/monaco-editor#528 Fixes microsoft/monaco-editor#562 Fixes microsoft/monaco-editor#563 Fixes microsoft/monaco-editor#1538 Fixes microsoft/monaco-editor#2261 Refs microsoft/vscode#107602
This commit is contained in:
parent
6558f86533
commit
20f3ece2e0
|
@ -119,4 +119,5 @@ export const isWebkitWebView = (!isChrome && !isSafari && isWebKit);
|
|||
export const isIPad = (userAgent.indexOf('iPad') >= 0 || (isSafari && navigator.maxTouchPoints > 0));
|
||||
export const isEdgeLegacyWebView = isEdgeLegacy && (userAgent.indexOf('WebView/') >= 0);
|
||||
export const isElectron = (userAgent.indexOf('Electron/') >= 0);
|
||||
export const isAndroid = (userAgent.indexOf('Android') >= 0);
|
||||
export const isStandalone = (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches);
|
||||
|
|
|
@ -1931,6 +1931,7 @@ registerOverwritableCommand(Handler.Type, {
|
|||
}]
|
||||
});
|
||||
registerOverwritableCommand(Handler.ReplacePreviousChar);
|
||||
registerOverwritableCommand(Handler.CompositionType);
|
||||
registerOverwritableCommand(Handler.CompositionStart);
|
||||
registerOverwritableCommand(Handler.CompositionEnd);
|
||||
registerOverwritableCommand(Handler.Paste);
|
||||
|
|
|
@ -42,6 +42,7 @@ export interface IPointerHandlerHelper {
|
|||
linesContentDomNode: HTMLElement;
|
||||
|
||||
focusTextArea(): void;
|
||||
dispatchTextAreaEvent(event: CustomEvent): void;
|
||||
|
||||
/**
|
||||
* Get the last rendered information for cursors & textarea.
|
||||
|
|
|
@ -13,6 +13,7 @@ import { EditorMouseEvent, EditorPointerEventFactory } from 'vs/editor/browser/e
|
|||
import { ViewController } from 'vs/editor/browser/view/viewController';
|
||||
import { ViewContext } from 'vs/editor/common/view/viewContext';
|
||||
import { BrowserFeatures } from 'vs/base/browser/canIUse';
|
||||
import { TextAreaSyntethicEvents } from 'vs/editor/browser/controller/textAreaInput';
|
||||
|
||||
interface IThrottledGestureEvent {
|
||||
translationX: number;
|
||||
|
@ -210,6 +211,11 @@ class TouchHandler extends MouseHandler {
|
|||
const target = this._createMouseTarget(new EditorMouseEvent(event, this.viewHelper.viewDomNode), false);
|
||||
|
||||
if (target.position) {
|
||||
// Send the tap event also to the <textarea> (for input purposes)
|
||||
const event = document.createEvent('CustomEvent');
|
||||
event.initEvent(TextAreaSyntethicEvents.Tap, false, true);
|
||||
this.viewHelper.dispatchTextAreaEvent(event);
|
||||
|
||||
this.viewController.moveTo(target.position);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@ 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 { ISimpleModel, ITypeData, PagedScreenReaderStrategy, TextAreaState } from 'vs/editor/browser/controller/textAreaState';
|
||||
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';
|
||||
import { LineNumbersOverlay } from 'vs/editor/browser/viewParts/lineNumbers/lineNumbers';
|
||||
|
@ -202,6 +202,22 @@ export class TextAreaHandler extends ViewPart {
|
|||
return TextAreaState.EMPTY;
|
||||
}
|
||||
|
||||
if (browser.isAndroid) {
|
||||
// when tapping in the editor on a word, Android enters composition mode.
|
||||
// in the `compositionstart` event we cannot clear the textarea, because
|
||||
// it then forgets to ever send a `compositionend`.
|
||||
// we therefore only write the current word in the textarea
|
||||
const selection = this._selections[0];
|
||||
if (selection.isEmpty()) {
|
||||
const position = selection.getStartPosition();
|
||||
const [wordAtPosition, positionOffsetInWord] = this._getAndroidWordAtPosition(position);
|
||||
if (wordAtPosition.length > 0) {
|
||||
return new TextAreaState(wordAtPosition, positionOffsetInWord, positionOffsetInWord, position, position);
|
||||
}
|
||||
}
|
||||
return TextAreaState.EMPTY;
|
||||
}
|
||||
|
||||
return PagedScreenReaderStrategy.fromEditorSelection(currentState, simpleModel, this._selections[0], this._accessibilityPageSize, this._accessibilitySupport === AccessibilitySupport.Unknown);
|
||||
},
|
||||
|
||||
|
@ -237,9 +253,16 @@ export class TextAreaHandler extends ViewPart {
|
|||
}));
|
||||
|
||||
this._register(this._textAreaInput.onType((e: ITypeData) => {
|
||||
if (e.replaceCharCnt) {
|
||||
this._viewController.replacePreviousChar(e.text, e.replaceCharCnt);
|
||||
if (e.replacePrevCharCnt || e.replaceNextCharCnt || e.positionDelta) {
|
||||
// must be handled through the new command
|
||||
if (_debugComposition) {
|
||||
console.log(` => compositionType: <<${e.text}>>, ${e.replacePrevCharCnt}, ${e.replaceNextCharCnt}, ${e.positionDelta}`);
|
||||
}
|
||||
this._viewController.compositionType(e.text, e.replacePrevCharCnt, e.replaceNextCharCnt, e.positionDelta);
|
||||
} else {
|
||||
if (_debugComposition) {
|
||||
console.log(` => type: <<${e.text}>>`);
|
||||
}
|
||||
this._viewController.type(e.text);
|
||||
}
|
||||
}));
|
||||
|
@ -250,7 +273,7 @@ export class TextAreaHandler extends ViewPart {
|
|||
|
||||
this._register(this._textAreaInput.onCompositionStart((e) => {
|
||||
const lineNumber = this._selections[0].startLineNumber;
|
||||
const column = this._selections[0].startColumn - (e.moveOneCharacterLeft ? 1 : 0);
|
||||
const column = this._selections[0].startColumn + e.revealDeltaColumns;
|
||||
|
||||
this._context.model.revealRange(
|
||||
'keyboard',
|
||||
|
@ -280,8 +303,11 @@ export class TextAreaHandler extends ViewPart {
|
|||
}));
|
||||
|
||||
this._register(this._textAreaInput.onCompositionUpdate((e: ICompositionData) => {
|
||||
if (!this._visibleTextArea) {
|
||||
return;
|
||||
}
|
||||
// adjust width by its size
|
||||
this._visibleTextArea = this._visibleTextArea!.setWidth(measureText(e.data, this._fontInfo));
|
||||
this._visibleTextArea = this._visibleTextArea.setWidth(measureText(e.data, this._fontInfo));
|
||||
this._render();
|
||||
}));
|
||||
|
||||
|
@ -308,6 +334,47 @@ export class TextAreaHandler extends ViewPart {
|
|||
super.dispose();
|
||||
}
|
||||
|
||||
private _getAndroidWordAtPosition(position: Position): [string, number] {
|
||||
const ANDROID_WORD_SEPARATORS = '`~!@#$%^&*()-=+[{]}\\|;:",.<>/?';
|
||||
const lineContent = this._context.model.getLineContent(position.lineNumber);
|
||||
const wordSeparators = getMapForWordSeparators(ANDROID_WORD_SEPARATORS);
|
||||
|
||||
let goingLeft = true;
|
||||
let startColumn = position.column;
|
||||
let goingRight = true;
|
||||
let endColumn = position.column;
|
||||
let distance = 0;
|
||||
while (distance < 50 && (goingLeft || goingRight)) {
|
||||
if (goingLeft && startColumn <= 1) {
|
||||
goingLeft = false;
|
||||
}
|
||||
if (goingLeft) {
|
||||
const charCode = lineContent.charCodeAt(startColumn - 2);
|
||||
const charClass = wordSeparators.get(charCode);
|
||||
if (charClass !== WordCharacterClass.Regular) {
|
||||
goingLeft = false;
|
||||
} else {
|
||||
startColumn--;
|
||||
}
|
||||
}
|
||||
if (goingRight && endColumn > lineContent.length) {
|
||||
goingRight = false;
|
||||
}
|
||||
if (goingRight) {
|
||||
const charCode = lineContent.charCodeAt(endColumn - 1);
|
||||
const charClass = wordSeparators.get(charCode);
|
||||
if (charClass !== WordCharacterClass.Regular) {
|
||||
goingRight = false;
|
||||
} else {
|
||||
endColumn++;
|
||||
}
|
||||
}
|
||||
distance++;
|
||||
}
|
||||
|
||||
return [lineContent.substring(startColumn - 1, endColumn - 1), position.column - startColumn];
|
||||
}
|
||||
|
||||
private _getWordBeforePosition(position: Position): string {
|
||||
const lineContent = this._context.model.getLineContent(position.lineNumber);
|
||||
const wordSeparators = getMapForWordSeparators(this._context.configuration.options.get(EditorOption.wordSeparators));
|
||||
|
|
|
@ -18,6 +18,10 @@ import { Position } from 'vs/editor/common/core/position';
|
|||
import { Selection } from 'vs/editor/common/core/selection';
|
||||
import { BrowserFeatures } from 'vs/base/browser/canIUse';
|
||||
|
||||
export namespace TextAreaSyntethicEvents {
|
||||
export const Tap = '-monaco-textarea-synthetic-tap';
|
||||
}
|
||||
|
||||
export interface ICompositionData {
|
||||
data: string;
|
||||
}
|
||||
|
@ -96,7 +100,7 @@ export class InMemoryClipboardMetadataManager {
|
|||
}
|
||||
|
||||
export interface ICompositionStartEvent {
|
||||
moveOneCharacterLeft: boolean;
|
||||
revealDeltaColumns: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -204,7 +208,6 @@ export class TextAreaInput extends Disposable {
|
|||
}
|
||||
this._isDoingComposition = true;
|
||||
|
||||
let moveOneCharacterLeft = false;
|
||||
if (
|
||||
platform.isMacintosh
|
||||
&& lastKeyDown
|
||||
|
@ -212,17 +215,12 @@ export class TextAreaInput extends Disposable {
|
|||
&& this._textAreaState.selectionStart === this._textAreaState.selectionEnd
|
||||
&& this._textAreaState.selectionStart > 0
|
||||
&& this._textAreaState.value.substr(this._textAreaState.selectionStart - 1, 1) === e.data
|
||||
&& (lastKeyDown.code === 'ArrowRight' || lastKeyDown.code === 'ArrowLeft')
|
||||
) {
|
||||
// Handling long press case on macOS + arrow key => pretend the character was selected
|
||||
if (lastKeyDown.code === 'ArrowRight' || lastKeyDown.code === 'ArrowLeft') {
|
||||
if (_debugComposition) {
|
||||
console.log(`[compositionstart] Handling long press case on macOS + arrow key`, e);
|
||||
}
|
||||
moveOneCharacterLeft = true;
|
||||
if (_debugComposition) {
|
||||
console.log(`[compositionstart] Handling long press case on macOS + arrow key`, e);
|
||||
}
|
||||
}
|
||||
|
||||
if (moveOneCharacterLeft) {
|
||||
this._textAreaState = new TextAreaState(
|
||||
this._textAreaState.value,
|
||||
this._textAreaState.selectionStart - 1,
|
||||
|
@ -230,11 +228,19 @@ export class TextAreaInput extends Disposable {
|
|||
this._textAreaState.selectionStartPosition ? new Position(this._textAreaState.selectionStartPosition.lineNumber, this._textAreaState.selectionStartPosition.column - 1) : null,
|
||||
this._textAreaState.selectionEndPosition
|
||||
);
|
||||
} else {
|
||||
this._setAndWriteTextAreaState('compositionstart', TextAreaState.EMPTY);
|
||||
this._onCompositionStart.fire({ revealDeltaColumns: -1 });
|
||||
return;
|
||||
}
|
||||
|
||||
this._onCompositionStart.fire({ moveOneCharacterLeft });
|
||||
if (browser.isAndroid) {
|
||||
// when tapping on the editor, Android enters composition mode to edit the current word
|
||||
// so we cannot clear the textarea on Android and we must pretend the current word was selected
|
||||
this._onCompositionStart.fire({ revealDeltaColumns: -this._textAreaState.selectionStart });
|
||||
return;
|
||||
}
|
||||
|
||||
this._setAndWriteTextAreaState('compositionstart', TextAreaState.EMPTY);
|
||||
this._onCompositionStart.fire({ revealDeltaColumns: 0 });
|
||||
}));
|
||||
|
||||
/**
|
||||
|
@ -246,6 +252,12 @@ export class TextAreaInput extends Disposable {
|
|||
return [newState, TextAreaState.deduceInput(oldState, newState, couldBeEmojiInput)];
|
||||
};
|
||||
|
||||
const deduceAndroidCompositionInput = (): [TextAreaState, ITypeData] => {
|
||||
const oldState = this._textAreaState;
|
||||
const newState = TextAreaState.readFromTextArea(this._textArea);
|
||||
return [newState, TextAreaState.deduceAndroidCompositionInput(oldState, newState)];
|
||||
};
|
||||
|
||||
/**
|
||||
* Deduce the composition input from a string.
|
||||
*/
|
||||
|
@ -254,7 +266,9 @@ export class TextAreaInput extends Disposable {
|
|||
const newState = TextAreaState.selectedText(text);
|
||||
const typeInput: ITypeData = {
|
||||
text: newState.value,
|
||||
replaceCharCnt: oldState.selectionEnd - oldState.selectionStart
|
||||
replacePrevCharCnt: oldState.selectionEnd - oldState.selectionStart,
|
||||
replaceNextCharCnt: 0,
|
||||
positionDelta: 0
|
||||
};
|
||||
return [newState, typeInput];
|
||||
};
|
||||
|
@ -263,6 +277,17 @@ export class TextAreaInput extends Disposable {
|
|||
if (_debugComposition) {
|
||||
console.log(`[compositionupdate]`, e);
|
||||
}
|
||||
if (browser.isAndroid) {
|
||||
// On Android, the data sent with the composition update event is unusable.
|
||||
// For example, if the cursor is in the middle of a word like Mic|osoft
|
||||
// and Microsoft is chosen from the keyboard's suggestions, the e.data will contain "Microsoft".
|
||||
// This is not really usable because it doesn't tell us where the edit began and where it ended.
|
||||
const [newState, typeInput] = deduceAndroidCompositionInput();
|
||||
this._textAreaState = newState;
|
||||
this._onType.fire(typeInput);
|
||||
this._onCompositionUpdate.fire(e);
|
||||
return;
|
||||
}
|
||||
const [newState, typeInput] = deduceComposition(e.data || '');
|
||||
this._textAreaState = newState;
|
||||
this._onType.fire(typeInput);
|
||||
|
@ -278,6 +303,19 @@ export class TextAreaInput extends Disposable {
|
|||
if (!this._isDoingComposition) {
|
||||
return;
|
||||
}
|
||||
this._isDoingComposition = false;
|
||||
|
||||
if (browser.isAndroid) {
|
||||
// On Android, the data sent with the composition update event is unusable.
|
||||
// For example, if the cursor is in the middle of a word like Mic|osoft
|
||||
// and Microsoft is chosen from the keyboard's suggestions, the e.data will contain "Microsoft".
|
||||
// This is not really usable because it doesn't tell us where the edit began and where it ended.
|
||||
const [newState, typeInput] = deduceAndroidCompositionInput();
|
||||
this._textAreaState = newState;
|
||||
this._onType.fire(typeInput);
|
||||
this._onCompositionEnd.fire();
|
||||
return;
|
||||
}
|
||||
|
||||
const [newState, typeInput] = deduceComposition(e.data || '');
|
||||
this._textAreaState = newState;
|
||||
|
@ -290,11 +328,6 @@ export class TextAreaInput extends Disposable {
|
|||
this._textAreaState = TextAreaState.readFromTextArea(this._textArea);
|
||||
}
|
||||
|
||||
if (!this._isDoingComposition) {
|
||||
return;
|
||||
}
|
||||
this._isDoingComposition = false;
|
||||
|
||||
this._onCompositionEnd.fire();
|
||||
}));
|
||||
|
||||
|
@ -308,18 +341,18 @@ export class TextAreaInput extends Disposable {
|
|||
}
|
||||
|
||||
const [newState, typeInput] = deduceInputFromTextAreaValue(/*couldBeEmojiInput*/platform.isMacintosh);
|
||||
if (typeInput.replaceCharCnt === 0 && typeInput.text.length === 1 && strings.isHighSurrogate(typeInput.text.charCodeAt(0))) {
|
||||
if (typeInput.replacePrevCharCnt === 0 && typeInput.text.length === 1 && strings.isHighSurrogate(typeInput.text.charCodeAt(0))) {
|
||||
// Ignore invalid input but keep it around for next time
|
||||
return;
|
||||
}
|
||||
|
||||
this._textAreaState = newState;
|
||||
if (this._nextCommand === ReadFromTextArea.Type) {
|
||||
if (typeInput.text !== '') {
|
||||
if (typeInput.text !== '' || typeInput.replacePrevCharCnt !== 0) {
|
||||
this._onType.fire(typeInput);
|
||||
}
|
||||
} else {
|
||||
if (typeInput.text !== '' || typeInput.replaceCharCnt !== 0) {
|
||||
if (typeInput.text !== '' || typeInput.replacePrevCharCnt !== 0) {
|
||||
this._firePaste(typeInput.text, null);
|
||||
}
|
||||
this._nextCommand = ReadFromTextArea.Type;
|
||||
|
@ -388,6 +421,21 @@ export class TextAreaInput extends Disposable {
|
|||
}
|
||||
this._setHasFocus(false);
|
||||
}));
|
||||
this._register(dom.addDisposableListener(textArea.domNode, TextAreaSyntethicEvents.Tap, () => {
|
||||
if (browser.isAndroid && this._isDoingComposition) {
|
||||
// on Android, tapping does not cancel the current composition, so the
|
||||
// textarea is stuck showing the old composition
|
||||
|
||||
// Clear the flag to be able to write to the textarea
|
||||
this._isDoingComposition = false;
|
||||
|
||||
// Clear the textarea to avoid an unwanted cursor type
|
||||
this.writeScreenReaderContent('tapWithoutCompositionEnd');
|
||||
|
||||
// Fire artificial composition end
|
||||
this._onCompositionEnd.fire();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private _installSelectionChangeListener(): IDisposable {
|
||||
|
|
|
@ -27,7 +27,9 @@ export interface ISimpleModel {
|
|||
|
||||
export interface ITypeData {
|
||||
text: string;
|
||||
replaceCharCnt: number;
|
||||
replacePrevCharCnt: number;
|
||||
replaceNextCharCnt: number;
|
||||
positionDelta: number;
|
||||
}
|
||||
|
||||
export class TextAreaState {
|
||||
|
@ -105,7 +107,9 @@ export class TextAreaState {
|
|||
// This is the EMPTY state
|
||||
return {
|
||||
text: '',
|
||||
replaceCharCnt: 0
|
||||
replacePrevCharCnt: 0,
|
||||
replaceNextCharCnt: 0,
|
||||
positionDelta: 0
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -178,7 +182,9 @@ export class TextAreaState {
|
|||
if (/\uFE0F/.test(potentialEmojiInput) || strings.containsEmoji(potentialEmojiInput)) {
|
||||
return {
|
||||
text: potentialEmojiInput,
|
||||
replaceCharCnt: 0
|
||||
replacePrevCharCnt: 0,
|
||||
replaceNextCharCnt: 0,
|
||||
positionDelta: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -197,7 +203,9 @@ export class TextAreaState {
|
|||
if (strings.containsFullWidthCharacter(currentValue)) {
|
||||
return {
|
||||
text: '',
|
||||
replaceCharCnt: 0
|
||||
replacePrevCharCnt: 0,
|
||||
replaceNextCharCnt: 0,
|
||||
positionDelta: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -210,7 +218,9 @@ export class TextAreaState {
|
|||
|
||||
return {
|
||||
text: currentValue,
|
||||
replaceCharCnt: replacePreviousCharacters
|
||||
replacePrevCharCnt: replacePreviousCharacters,
|
||||
replaceNextCharCnt: 0,
|
||||
positionDelta: 0
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -218,7 +228,57 @@ export class TextAreaState {
|
|||
const replacePreviousCharacters = previousSelectionEnd - previousSelectionStart;
|
||||
return {
|
||||
text: currentValue,
|
||||
replaceCharCnt: replacePreviousCharacters
|
||||
replacePrevCharCnt: replacePreviousCharacters,
|
||||
replaceNextCharCnt: 0,
|
||||
positionDelta: 0
|
||||
};
|
||||
}
|
||||
|
||||
public static deduceAndroidCompositionInput(previousState: TextAreaState, currentState: TextAreaState): ITypeData {
|
||||
if (!previousState) {
|
||||
// This is the EMPTY state
|
||||
return {
|
||||
text: '',
|
||||
replacePrevCharCnt: 0,
|
||||
replaceNextCharCnt: 0,
|
||||
positionDelta: 0
|
||||
};
|
||||
}
|
||||
|
||||
if (_debugComposition) {
|
||||
console.log('------------------------deduceAndroidCompositionInput');
|
||||
console.log('PREVIOUS STATE: ' + previousState.toString());
|
||||
console.log('CURRENT STATE: ' + currentState.toString());
|
||||
}
|
||||
|
||||
if (previousState.value === currentState.value) {
|
||||
return {
|
||||
text: '',
|
||||
replacePrevCharCnt: 0,
|
||||
replaceNextCharCnt: 0,
|
||||
positionDelta: currentState.selectionEnd - previousState.selectionEnd
|
||||
};
|
||||
}
|
||||
|
||||
const prefixLength = Math.min(strings.commonPrefixLength(previousState.value, currentState.value), previousState.selectionEnd);
|
||||
const suffixLength = Math.min(strings.commonSuffixLength(previousState.value, currentState.value), previousState.value.length - previousState.selectionEnd);
|
||||
const previousValue = previousState.value.substring(prefixLength, previousState.value.length - suffixLength);
|
||||
const currentValue = currentState.value.substring(prefixLength, currentState.value.length - suffixLength);
|
||||
const previousSelectionStart = previousState.selectionStart - prefixLength;
|
||||
const previousSelectionEnd = previousState.selectionEnd - prefixLength;
|
||||
const currentSelectionStart = currentState.selectionStart - prefixLength;
|
||||
const currentSelectionEnd = currentState.selectionEnd - prefixLength;
|
||||
|
||||
if (_debugComposition) {
|
||||
console.log('AFTER DIFFING PREVIOUS STATE: <' + previousValue + '>, selectionStart: ' + previousSelectionStart + ', selectionEnd: ' + previousSelectionEnd);
|
||||
console.log('AFTER DIFFING CURRENT STATE: <' + currentValue + '>, selectionStart: ' + currentSelectionStart + ', selectionEnd: ' + currentSelectionEnd);
|
||||
}
|
||||
|
||||
return {
|
||||
text: currentValue,
|
||||
replacePrevCharCnt: previousSelectionEnd,
|
||||
replaceNextCharCnt: previousValue.length - previousSelectionEnd,
|
||||
positionDelta: currentSelectionEnd - currentValue.length
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -37,7 +37,7 @@ export interface IMouseDispatchData {
|
|||
export interface ICommandDelegate {
|
||||
paste(text: string, pasteOnNewLine: boolean, multicursorText: string[] | null, mode: string | null): void;
|
||||
type(text: string): void;
|
||||
replacePreviousChar(text: string, replaceCharCnt: number): void;
|
||||
compositionType(text: string, replacePrevCharCnt: number, replaceNextCharCnt: number, positionDelta: number): void;
|
||||
startComposition(): void;
|
||||
endComposition(): void;
|
||||
cut(): void;
|
||||
|
@ -70,8 +70,8 @@ export class ViewController {
|
|||
this.commandDelegate.type(text);
|
||||
}
|
||||
|
||||
public replacePreviousChar(text: string, replaceCharCnt: number): void {
|
||||
this.commandDelegate.replacePreviousChar(text, replaceCharCnt);
|
||||
public compositionType(text: string, replacePrevCharCnt: number, replaceNextCharCnt: number, positionDelta: number): void {
|
||||
this.commandDelegate.compositionType(text, replacePrevCharCnt, replaceNextCharCnt, positionDelta);
|
||||
}
|
||||
|
||||
public compositionStart(): void {
|
||||
|
|
|
@ -239,6 +239,10 @@ export class View extends ViewEventHandler {
|
|||
this.focus();
|
||||
},
|
||||
|
||||
dispatchTextAreaEvent: (event: CustomEvent) => {
|
||||
this._textAreaHandler.textArea.domNode.dispatchEvent(event);
|
||||
},
|
||||
|
||||
getLastRenderData: (): PointerHandlerLastRenderData => {
|
||||
const lastViewCursorsRenderData = this._viewCursors.getLastRenderData() || [];
|
||||
const lastTextareaPosition = this._textAreaHandler.getLastRenderData();
|
||||
|
|
|
@ -1002,7 +1002,12 @@ export class CodeEditorWidget extends Disposable implements editorBrowser.ICodeE
|
|||
}
|
||||
case editorCommon.Handler.ReplacePreviousChar: {
|
||||
const args = <Partial<editorCommon.ReplacePreviousCharPayload>>payload;
|
||||
this._replacePreviousChar(source, args.text || '', args.replaceCharCnt || 0);
|
||||
this._compositionType(source, args.text || '', args.replaceCharCnt || 0, 0, 0);
|
||||
return;
|
||||
}
|
||||
case editorCommon.Handler.CompositionType: {
|
||||
const args = <Partial<editorCommon.CompositionTypePayload>>payload;
|
||||
this._compositionType(source, args.text || '', args.replacePrevCharCnt || 0, args.replaceNextCharCnt || 0, args.positionDelta || 0);
|
||||
return;
|
||||
}
|
||||
case editorCommon.Handler.Paste: {
|
||||
|
@ -1061,11 +1066,11 @@ export class CodeEditorWidget extends Disposable implements editorBrowser.ICodeE
|
|||
}
|
||||
}
|
||||
|
||||
private _replacePreviousChar(source: string | null | undefined, text: string, replaceCharCnt: number): void {
|
||||
private _compositionType(source: string | null | undefined, text: string, replacePrevCharCnt: number, replaceNextCharCnt: number, positionDelta: number): void {
|
||||
if (!this._modelData) {
|
||||
return;
|
||||
}
|
||||
this._modelData.viewModel.replacePreviousChar(text, replaceCharCnt, source);
|
||||
this._modelData.viewModel.compositionType(text, replacePrevCharCnt, replaceNextCharCnt, positionDelta, source);
|
||||
}
|
||||
|
||||
private _paste(source: string | null | undefined, text: string, pasteOnNewLine: boolean, multicursorText: string[] | null, mode: string | null): void {
|
||||
|
@ -1583,8 +1588,8 @@ export class CodeEditorWidget extends Disposable implements editorBrowser.ICodeE
|
|||
type: (text: string) => {
|
||||
this._type('keyboard', text);
|
||||
},
|
||||
replacePreviousChar: (text: string, replaceCharCnt: number) => {
|
||||
this._replacePreviousChar('keyboard', text, replaceCharCnt);
|
||||
compositionType: (text: string, replacePrevCharCnt: number, replaceNextCharCnt: number, positionDelta: number) => {
|
||||
this._compositionType('keyboard', text, replacePrevCharCnt, replaceNextCharCnt, positionDelta);
|
||||
},
|
||||
startComposition: () => {
|
||||
this._startComposition();
|
||||
|
@ -1606,9 +1611,16 @@ export class CodeEditorWidget extends Disposable implements editorBrowser.ICodeE
|
|||
const payload: editorCommon.TypePayload = { text };
|
||||
this._commandService.executeCommand(editorCommon.Handler.Type, payload);
|
||||
},
|
||||
replacePreviousChar: (text: string, replaceCharCnt: number) => {
|
||||
const payload: editorCommon.ReplacePreviousCharPayload = { text, replaceCharCnt };
|
||||
this._commandService.executeCommand(editorCommon.Handler.ReplacePreviousChar, payload);
|
||||
compositionType: (text: string, replacePrevCharCnt: number, replaceNextCharCnt: number, positionDelta: number) => {
|
||||
// Try if possible to go through the existing `replacePreviousChar` command
|
||||
if (replaceNextCharCnt || positionDelta) {
|
||||
// must be handled through the new command
|
||||
const payload: editorCommon.CompositionTypePayload = { text, replacePrevCharCnt, replaceNextCharCnt, positionDelta };
|
||||
this._commandService.executeCommand(editorCommon.Handler.CompositionType, payload);
|
||||
} else {
|
||||
const payload: editorCommon.ReplacePreviousCharPayload = { text, replaceCharCnt: replacePrevCharCnt };
|
||||
this._commandService.executeCommand(editorCommon.Handler.ReplacePreviousChar, payload);
|
||||
}
|
||||
},
|
||||
startComposition: () => {
|
||||
this._commandService.executeCommand(editorCommon.Handler.CompositionStart, {});
|
||||
|
|
|
@ -659,9 +659,21 @@ export class Cursor extends Disposable {
|
|||
}, eventsCollector, source);
|
||||
}
|
||||
|
||||
public replacePreviousChar(eventsCollector: ViewModelEventsCollector, text: string, replaceCharCnt: number, source?: string | null | undefined): void {
|
||||
public compositionType(eventsCollector: ViewModelEventsCollector, text: string, replacePrevCharCnt: number, replaceNextCharCnt: number, positionDelta: number, source?: string | null | undefined): void {
|
||||
if (text.length === 0 && replacePrevCharCnt === 0 && replaceNextCharCnt === 0) {
|
||||
// this edit is a no-op
|
||||
if (positionDelta !== 0) {
|
||||
// but it still wants to move the cursor
|
||||
const newSelections = this.getSelections().map(selection => {
|
||||
const position = selection.getPosition();
|
||||
return new Selection(position.lineNumber, position.column + positionDelta, position.lineNumber, position.column + positionDelta);
|
||||
});
|
||||
this.setSelections(eventsCollector, source, newSelections, CursorChangeReason.NotSet);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this._executeEdit(() => {
|
||||
this._executeEditOperation(TypeOperations.replacePreviousChar(this._prevEditOperationType, this.context.cursorConfig, this._model, this.getSelections(), text, replaceCharCnt));
|
||||
this._executeEditOperation(TypeOperations.compositionType(this._prevEditOperationType, this.context.cursorConfig, this._model, this.getSelections(), text, replacePrevCharCnt, replaceNextCharCnt, positionDelta));
|
||||
}, eventsCollector, source);
|
||||
}
|
||||
|
||||
|
|
|
@ -258,34 +258,33 @@ export class TypeOperations {
|
|||
return commands;
|
||||
}
|
||||
|
||||
public static replacePreviousChar(prevEditOperationType: EditOperationType, config: CursorConfiguration, model: ITextModel, selections: Selection[], txt: string, replaceCharCnt: number): EditOperationResult {
|
||||
let commands: Array<ICommand | null> = [];
|
||||
for (let i = 0, len = selections.length; i < len; i++) {
|
||||
const selection = selections[i];
|
||||
if (!selection.isEmpty()) {
|
||||
// looks like https://github.com/microsoft/vscode/issues/2773
|
||||
// where a cursor operation occurred before a canceled composition
|
||||
// => ignore composition
|
||||
commands[i] = null;
|
||||
continue;
|
||||
}
|
||||
const pos = selection.getPosition();
|
||||
const startColumn = Math.max(1, pos.column - replaceCharCnt);
|
||||
const range = new Range(pos.lineNumber, startColumn, pos.lineNumber, pos.column);
|
||||
const oldText = model.getValueInRange(range);
|
||||
if (oldText === txt) {
|
||||
// => ignore composition that doesn't do anything
|
||||
commands[i] = null;
|
||||
continue;
|
||||
}
|
||||
commands[i] = new ReplaceCommand(range, txt);
|
||||
}
|
||||
public static compositionType(prevEditOperationType: EditOperationType, config: CursorConfiguration, model: ITextModel, selections: Selection[], text: string, replacePrevCharCnt: number, replaceNextCharCnt: number, positionDelta: number): EditOperationResult {
|
||||
const commands = selections.map(selection => this._compositionType(model, selection, text, replacePrevCharCnt, replaceNextCharCnt, positionDelta));
|
||||
return new EditOperationResult(EditOperationType.Typing, commands, {
|
||||
shouldPushStackElementBefore: (prevEditOperationType !== EditOperationType.Typing),
|
||||
shouldPushStackElementAfter: false
|
||||
});
|
||||
}
|
||||
|
||||
private static _compositionType(model: ITextModel, selection: Selection, text: string, replacePrevCharCnt: number, replaceNextCharCnt: number, positionDelta: number): ICommand | null {
|
||||
if (!selection.isEmpty()) {
|
||||
// looks like https://github.com/microsoft/vscode/issues/2773
|
||||
// where a cursor operation occurred before a canceled composition
|
||||
// => ignore composition
|
||||
return null;
|
||||
}
|
||||
const pos = selection.getPosition();
|
||||
const startColumn = Math.max(1, pos.column - replacePrevCharCnt);
|
||||
const endColumn = Math.min(model.getLineMaxColumn(pos.lineNumber), pos.column + replaceNextCharCnt);
|
||||
const range = new Range(pos.lineNumber, startColumn, pos.lineNumber, endColumn);
|
||||
const oldText = model.getValueInRange(range);
|
||||
if (oldText === text && positionDelta === 0) {
|
||||
// => ignore composition that doesn't do anything
|
||||
return null;
|
||||
}
|
||||
return new ReplaceCommandWithOffsetCursorState(range, text, 0, positionDelta);
|
||||
}
|
||||
|
||||
private static _typeCommand(range: Range, text: string, keepPosition: boolean): ICommand {
|
||||
if (keepPosition) {
|
||||
return new ReplaceCommandWithoutChangingPosition(range, text, true);
|
||||
|
|
|
@ -700,6 +700,7 @@ export const enum Handler {
|
|||
CompositionEnd = 'compositionEnd',
|
||||
Type = 'type',
|
||||
ReplacePreviousChar = 'replacePreviousChar',
|
||||
CompositionType = 'compositionType',
|
||||
Paste = 'paste',
|
||||
Cut = 'cut',
|
||||
}
|
||||
|
@ -719,6 +720,16 @@ export interface ReplacePreviousCharPayload {
|
|||
replaceCharCnt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export interface CompositionTypePayload {
|
||||
text: string;
|
||||
replacePrevCharCnt: number;
|
||||
replaceNextCharCnt: number;
|
||||
positionDelta: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
|
|
|
@ -948,8 +948,8 @@ export class ViewModel extends Disposable implements IViewModel {
|
|||
public type(text: string, source?: string | null | undefined): void {
|
||||
this._executeCursorEdit(eventsCollector => this._cursor.type(eventsCollector, text, source));
|
||||
}
|
||||
public replacePreviousChar(text: string, replaceCharCnt: number, source?: string | null | undefined): void {
|
||||
this._executeCursorEdit(eventsCollector => this._cursor.replacePreviousChar(eventsCollector, text, replaceCharCnt, source));
|
||||
public compositionType(text: string, replacePrevCharCnt: number, replaceNextCharCnt: number, positionDelta: number, source?: string | null | undefined): void {
|
||||
this._executeCursorEdit(eventsCollector => this._cursor.compositionType(eventsCollector, text, replacePrevCharCnt, replaceNextCharCnt, positionDelta, source));
|
||||
}
|
||||
public paste(text: string, pasteOnNewLine: boolean, multicursorText?: string[] | null | undefined, source?: string | null | undefined): void {
|
||||
this._executeCursorEdit(eventsCollector => this._cursor.paste(eventsCollector, text, pasteOnNewLine, multicursorText, source));
|
||||
|
|
|
@ -2079,16 +2079,16 @@ suite('Editor Controller - Regression tests', () => {
|
|||
|
||||
// Typing sennsei in Japanese - Hiragana
|
||||
viewModel.type('s', 'keyboard');
|
||||
viewModel.replacePreviousChar('せ', 1);
|
||||
viewModel.replacePreviousChar('せn', 1);
|
||||
viewModel.replacePreviousChar('せん', 2);
|
||||
viewModel.replacePreviousChar('せんs', 2);
|
||||
viewModel.replacePreviousChar('せんせ', 3);
|
||||
viewModel.replacePreviousChar('せんせ', 3);
|
||||
viewModel.replacePreviousChar('せんせい', 3);
|
||||
viewModel.replacePreviousChar('せんせい', 4);
|
||||
viewModel.replacePreviousChar('せんせい', 4);
|
||||
viewModel.replacePreviousChar('せんせい', 4);
|
||||
viewModel.compositionType('せ', 1, 0, 0);
|
||||
viewModel.compositionType('せn', 1, 0, 0);
|
||||
viewModel.compositionType('せん', 2, 0, 0);
|
||||
viewModel.compositionType('せんs', 2, 0, 0);
|
||||
viewModel.compositionType('せんせ', 3, 0, 0);
|
||||
viewModel.compositionType('せんせ', 3, 0, 0);
|
||||
viewModel.compositionType('せんせい', 3, 0, 0);
|
||||
viewModel.compositionType('せんせい', 4, 0, 0);
|
||||
viewModel.compositionType('せんせい', 4, 0, 0);
|
||||
viewModel.compositionType('せんせい', 4, 0, 0);
|
||||
|
||||
assert.strictEqual(model.getLineContent(1), 'せんせい');
|
||||
assertCursor(viewModel, new Position(1, 5));
|
||||
|
@ -5449,7 +5449,7 @@ suite('autoClosingPairs', () => {
|
|||
// Typing ` + e on the mac US intl kb layout
|
||||
viewModel.startComposition();
|
||||
viewModel.type('`', 'keyboard');
|
||||
viewModel.replacePreviousChar('è', 1, 'keyboard');
|
||||
viewModel.compositionType('è', 1, 0, 0, 'keyboard');
|
||||
viewModel.endComposition('keyboard');
|
||||
|
||||
assert.strictEqual(model.getValue(), 'è');
|
||||
|
@ -5470,8 +5470,8 @@ suite('autoClosingPairs', () => {
|
|||
// Typing ` + e on the mac US intl kb layout
|
||||
viewModel.startComposition();
|
||||
viewModel.type('\'', 'keyboard');
|
||||
viewModel.replacePreviousChar('\'', 1, 'keyboard');
|
||||
viewModel.replacePreviousChar('\'', 1, 'keyboard');
|
||||
viewModel.compositionType('\'', 1, 0, 0, 'keyboard');
|
||||
viewModel.compositionType('\'', 1, 0, 0, 'keyboard');
|
||||
viewModel.endComposition('keyboard');
|
||||
|
||||
assert.strictEqual(model.getValue(), '\'test\'');
|
||||
|
@ -5550,8 +5550,8 @@ suite('autoClosingPairs', () => {
|
|||
viewModel.startComposition();
|
||||
viewModel.type('`', 'keyboard');
|
||||
moveDown(editor, viewModel, true);
|
||||
viewModel.replacePreviousChar('`', 1, 'keyboard');
|
||||
viewModel.replacePreviousChar('`', 1, 'keyboard');
|
||||
viewModel.compositionType('`', 1, 0, 0, 'keyboard');
|
||||
viewModel.compositionType('`', 1, 0, 0, 'keyboard');
|
||||
viewModel.endComposition('keyboard');
|
||||
|
||||
assert.strictEqual(model.getValue(), '`hello\nworld');
|
||||
|
@ -5575,14 +5575,14 @@ suite('autoClosingPairs', () => {
|
|||
// Typing ' + space
|
||||
viewModel.startComposition();
|
||||
viewModel.type('\'', 'keyboard');
|
||||
viewModel.replacePreviousChar('\'', 1, 'keyboard');
|
||||
viewModel.compositionType('\'', 1, 0, 0, 'keyboard');
|
||||
viewModel.endComposition('keyboard');
|
||||
assert.strictEqual(model.getValue(), '\'\'');
|
||||
|
||||
// Typing one more ' + space
|
||||
viewModel.startComposition();
|
||||
viewModel.type('\'', 'keyboard');
|
||||
viewModel.replacePreviousChar('\'', 1, 'keyboard');
|
||||
viewModel.compositionType('\'', 1, 0, 0, 'keyboard');
|
||||
viewModel.endComposition('keyboard');
|
||||
assert.strictEqual(model.getValue(), '\'\'');
|
||||
|
||||
|
@ -5591,7 +5591,7 @@ suite('autoClosingPairs', () => {
|
|||
viewModel.setSelections('test', [new Selection(1, 5, 1, 5)]);
|
||||
viewModel.startComposition();
|
||||
viewModel.type('\'', 'keyboard');
|
||||
viewModel.replacePreviousChar('\'', 1, 'keyboard');
|
||||
viewModel.compositionType('\'', 1, 0, 0, 'keyboard');
|
||||
viewModel.endComposition('keyboard');
|
||||
|
||||
assert.strictEqual(model.getValue(), '\'abc\'');
|
||||
|
@ -5601,7 +5601,7 @@ suite('autoClosingPairs', () => {
|
|||
viewModel.setSelections('test', [new Selection(1, 10, 1, 10)]);
|
||||
viewModel.startComposition();
|
||||
viewModel.type('\'', 'keyboard');
|
||||
viewModel.replacePreviousChar('\'', 1, 'keyboard');
|
||||
viewModel.compositionType('\'', 1, 0, 0, 'keyboard');
|
||||
viewModel.endComposition('keyboard');
|
||||
|
||||
assert.strictEqual(model.getValue(), '\'abc\'def \'\'');
|
||||
|
@ -5611,7 +5611,7 @@ suite('autoClosingPairs', () => {
|
|||
viewModel.setSelections('test', [new Selection(1, 1, 1, 1)]);
|
||||
viewModel.startComposition();
|
||||
viewModel.type('\'', 'keyboard');
|
||||
viewModel.replacePreviousChar('\'', 1, 'keyboard');
|
||||
viewModel.compositionType('\'', 1, 0, 0, 'keyboard');
|
||||
viewModel.endComposition('keyboard');
|
||||
|
||||
// No auto closing if it's after a word.
|
||||
|
@ -5619,7 +5619,7 @@ suite('autoClosingPairs', () => {
|
|||
viewModel.setSelections('test', [new Selection(1, 4, 1, 4)]);
|
||||
viewModel.startComposition();
|
||||
viewModel.type('\'', 'keyboard');
|
||||
viewModel.replacePreviousChar('\'', 1, 'keyboard');
|
||||
viewModel.compositionType('\'', 1, 0, 0, 'keyboard');
|
||||
viewModel.endComposition('keyboard');
|
||||
|
||||
assert.strictEqual(model.getValue(), 'abc\'');
|
||||
|
@ -5640,7 +5640,7 @@ suite('autoClosingPairs', () => {
|
|||
// Typing a + backspace
|
||||
viewModel.startComposition();
|
||||
viewModel.type('a', 'keyboard');
|
||||
viewModel.replacePreviousChar('', 1, 'keyboard');
|
||||
viewModel.compositionType('', 1, 0, 0, 'keyboard');
|
||||
viewModel.endComposition('keyboard');
|
||||
assert.strictEqual(model.getValue(), '{}');
|
||||
});
|
||||
|
|
|
@ -151,9 +151,9 @@ function doCreateTest(description: string, inputStr: string, expectedStr: string
|
|||
};
|
||||
|
||||
handler.onType((e) => {
|
||||
console.log('type text: ' + e.text + ', replaceCharCnt: ' + e.replaceCharCnt);
|
||||
console.log('type text: ' + e.text + ', replaceCharCnt: ' + e.replacePrevCharCnt);
|
||||
let text = model.getModelLineContent(1);
|
||||
let preText = text.substring(0, cursorOffset - e.replaceCharCnt);
|
||||
let preText = text.substring(0, cursorOffset - e.replacePrevCharCnt);
|
||||
let postText = text.substring(cursorOffset + cursorLength);
|
||||
let midText = e.text;
|
||||
|
||||
|
|
|
@ -134,8 +134,12 @@ suite('TextAreaState', () => {
|
|||
let newState = TextAreaState.readFromTextArea(textArea);
|
||||
let actual = TextAreaState.deduceInput(prevState, newState, couldBeEmojiInput);
|
||||
|
||||
assert.strictEqual(actual.text, expected);
|
||||
assert.strictEqual(actual.replaceCharCnt, expectedCharReplaceCnt);
|
||||
assert.deepStrictEqual(actual, {
|
||||
text: expected,
|
||||
replacePrevCharCnt: expectedCharReplaceCnt,
|
||||
replaceNextCharCnt: 0,
|
||||
positionDelta: 0,
|
||||
});
|
||||
|
||||
textArea.dispose();
|
||||
}
|
||||
|
@ -503,6 +507,82 @@ suite('TextAreaState', () => {
|
|||
);
|
||||
});
|
||||
|
||||
function testDeduceAndroidCompositionInput(
|
||||
prevState: TextAreaState | null,
|
||||
value: string, selectionStart: number, selectionEnd: number,
|
||||
expected: string, expectedReplacePrevCharCnt: number, expectedReplaceNextCharCnt: number, expectedPositionDelta: number): void {
|
||||
prevState = prevState || TextAreaState.EMPTY;
|
||||
|
||||
let textArea = new MockTextAreaWrapper();
|
||||
textArea._value = value;
|
||||
textArea._selectionStart = selectionStart;
|
||||
textArea._selectionEnd = selectionEnd;
|
||||
|
||||
let newState = TextAreaState.readFromTextArea(textArea);
|
||||
let actual = TextAreaState.deduceAndroidCompositionInput(prevState, newState);
|
||||
|
||||
assert.deepStrictEqual(actual, {
|
||||
text: expected,
|
||||
replacePrevCharCnt: expectedReplacePrevCharCnt,
|
||||
replaceNextCharCnt: expectedReplaceNextCharCnt,
|
||||
positionDelta: expectedPositionDelta,
|
||||
});
|
||||
|
||||
textArea.dispose();
|
||||
}
|
||||
|
||||
test('Android composition input 1', () => {
|
||||
testDeduceAndroidCompositionInput(
|
||||
new TextAreaState(
|
||||
'Microsoft',
|
||||
4, 4,
|
||||
null, null
|
||||
),
|
||||
'Microsoft',
|
||||
4, 4,
|
||||
'', 0, 0, 0,
|
||||
);
|
||||
});
|
||||
|
||||
test('Android composition input 2', () => {
|
||||
testDeduceAndroidCompositionInput(
|
||||
new TextAreaState(
|
||||
'Microsoft',
|
||||
4, 4,
|
||||
null, null
|
||||
),
|
||||
'Microsoft',
|
||||
0, 9,
|
||||
'', 0, 0, 5,
|
||||
);
|
||||
});
|
||||
|
||||
test('Android composition input 3', () => {
|
||||
testDeduceAndroidCompositionInput(
|
||||
new TextAreaState(
|
||||
'Microsoft',
|
||||
0, 9,
|
||||
null, null
|
||||
),
|
||||
'Microsoft\'s',
|
||||
11, 11,
|
||||
'\'s', 0, 0, 0,
|
||||
);
|
||||
});
|
||||
|
||||
test('Android backspace', () => {
|
||||
testDeduceAndroidCompositionInput(
|
||||
new TextAreaState(
|
||||
'undefinedVariable',
|
||||
2, 2,
|
||||
null, null
|
||||
),
|
||||
'udefinedVariable',
|
||||
1, 1,
|
||||
'', 1, 0, 0,
|
||||
);
|
||||
});
|
||||
|
||||
suite('PagedScreenReaderStrategy', () => {
|
||||
|
||||
function testPagedScreenReaderStrategy(lines: string[], selection: Selection, expected: TextAreaState): void {
|
||||
|
|
Loading…
Reference in a new issue