Alexandru Dima 2021-02-05 13:08:59 +01:00
parent 6558f86533
commit 20f3ece2e0
No known key found for this signature in database
GPG key ID: 6E58D7B045760DA0
17 changed files with 398 additions and 96 deletions

View file

@ -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);

View file

@ -1931,6 +1931,7 @@ registerOverwritableCommand(Handler.Type, {
}]
});
registerOverwritableCommand(Handler.ReplacePreviousChar);
registerOverwritableCommand(Handler.CompositionType);
registerOverwritableCommand(Handler.CompositionStart);
registerOverwritableCommand(Handler.CompositionEnd);
registerOverwritableCommand(Handler.Paste);

View file

@ -42,6 +42,7 @@ export interface IPointerHandlerHelper {
linesContentDomNode: HTMLElement;
focusTextArea(): void;
dispatchTextAreaEvent(event: CustomEvent): void;
/**
* Get the last rendered information for cursors & textarea.

View file

@ -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);
}
}

View file

@ -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));

View file

@ -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 {

View file

@ -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
};
}
}

View file

@ -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 {

View file

@ -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();

View file

@ -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, {});

View file

@ -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);
}

View file

@ -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);

View file

@ -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
*/

View file

@ -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));

View file

@ -2079,16 +2079,16 @@ suite('Editor Controller - Regression tests', () => {
// Typing sennsei in Japanese - Hiragana
viewModel.type('', '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(), '{}');
});

View file

@ -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;

View file

@ -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 {