Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Communication: Fix improper deletion of combined emojis in Monaco editor #10242

Open
wants to merge 4 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"export-to-csv": "1.4.0",
"fast-json-patch": "3.1.1",
"franc-min": "6.2.0",
"grapheme-splitter": "^1.0.4",
"html-diff-ts": "1.4.2",
"interactjs": "1.10.27",
"ismobilejs-es5": "0.0.1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ export class EmojiAction extends TextEditorAction {

this.insertTextAtPosition(editor, position, emoji);

const newPosition = new TextEditorPosition(position.getLineNumber(), position.getColumn() + 2);
const newPosition = new TextEditorPosition(position.getLineNumber(), position.getColumn() + emoji.length);
editor.setPosition(newPosition);
editor.focus();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { MonacoEditorLineHighlight } from 'app/shared/monaco-editor/model/monaco
import { MonacoEditorOptionPreset } from 'app/shared/monaco-editor/model/monaco-editor-option-preset.model';
import { MonacoEditorService } from 'app/shared/monaco-editor/monaco-editor.service';
import { getOS } from 'app/shared/util/os-detector.util';
import GraphemeSplitter from 'grapheme-splitter';

import { EmojiConvertor } from 'emoji-js';
import * as monaco from 'monaco-editor';
Expand Down Expand Up @@ -66,6 +67,7 @@ export class MonacoEditorComponent implements OnInit, OnDestroy {
private textChangedListener?: Disposable;
private blurEditorWidgetListener?: Disposable;
private textChangedEmitTimeout?: NodeJS.Timeout;
private customBackspaceCommandId: string | undefined;

/*
* Injected services and elements.
Expand Down Expand Up @@ -154,6 +156,44 @@ export class MonacoEditorComponent implements OnInit, OnDestroy {
}
this.onBlurEditor.emit();
});

this.customBackspaceCommandId =
this._editor.addCommand(monaco.KeyCode.Backspace, () => {
const model = this._editor.getModel();
const selection = this._editor.getSelection();
if (!model || !selection) return;

if (!selection.isEmpty()) {
this._editor.trigger('keyboard', 'deleteLeft', null);
return;
}

const lineNumber = selection.startLineNumber;
const column = selection.startColumn;
const lineContent = model.getLineContent(lineNumber);

const textBeforeCursor = lineContent.substring(0, column - 1);
const splitter = new GraphemeSplitter();
const graphemes = splitter.splitGraphemes(textBeforeCursor);

if (graphemes.length === 0) return;

graphemes.pop();
const newTextBeforeCursor = graphemes.join('');
const textAfterCursor = lineContent.substring(column - 1);

const newLineContent = newTextBeforeCursor + textAfterCursor;
model.pushEditOperations(
[],
[
{
range: new monaco.Range(lineNumber, 1, lineNumber, lineContent.length + 1),
text: newLineContent,
},
],
() => null,
);
}) || undefined;
}

ngOnDestroy() {
Expand Down Expand Up @@ -216,7 +256,7 @@ export class MonacoEditorComponent implements OnInit, OnDestroy {
const convertedText = this.convertTextToEmoji(text);
if (this.isConvertedToEmoji(text, convertedText)) {
this._editor.setValue(convertedText);
this.setPosition({ column: this.getPosition().column + 2 + text.length, lineNumber: this.getPosition().lineNumber });
this.setPosition({ column: this.getPosition().column + convertedText.length + text.length, lineNumber: this.getPosition().lineNumber });
}
if (this.getText() !== convertedText) {
this._editor.setValue(convertedText);
Expand Down Expand Up @@ -444,4 +484,8 @@ export class MonacoEditorComponent implements OnInit, OnDestroy {
applyOptionPreset(options: MonacoEditorOptionPreset): void {
options.apply(this._editor);
}

public getCustomBackspaceCommandId(): string | undefined {
return this.customBackspaceCommandId;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -605,4 +605,39 @@ describe('PostingsMarkdownEditor', () => {
});
expect(cursorPosition?.getColumn()).toBe(5);
});

it('should insert emoji at the cursor position', () => {
const emojiAction = new EmojiAction(component.viewContainerRef, mockOverlay as any, overlayPositionBuilderMock as any);
const mockCursorPosition = new TextEditorPosition(1, 5);
mockEditor.getPosition.mockReturnValue(mockCursorPosition);

emojiAction.insertEmojiAtCursor(mockEditor, 'πŸ˜€');

expect(mockEditor.replaceTextAtRange).toHaveBeenCalledWith(expect.any(TextEditorRange), 'πŸ˜€');
expect(mockEditor.setPosition).toHaveBeenCalledWith(new TextEditorPosition(1, 7));
expect(mockEditor.focus).toHaveBeenCalled();
});

it('should close the emoji picker and insert emoji on selection event', () => {
const emojiAction = new EmojiAction(component.viewContainerRef, mockOverlay as any, overlayPositionBuilderMock as any);
emojiAction.setPoint({ x: 100, y: 200 });
mockEditor.getPosition.mockReturnValue(new TextEditorPosition(1, 1));

const emojiSelectSubject = new Subject<{ emoji: any; event: PointerEvent }>();
const componentRef = {
instance: {
emojiSelect: emojiSelectSubject.asObservable(),
},
location: { nativeElement: document.createElement('div') },
};

mockOverlayRef.attach.mockReturnValue(componentRef);
emojiAction.run(mockEditor);

const selectionEvent = { emoji: { native: 'πŸ˜€' }, event: new PointerEvent('click') };
emojiSelectSubject.next(selectionEvent);

expect(mockEditor.replaceTextAtRange).toHaveBeenCalledWith(expect.any(TextEditorRange), 'πŸ˜€');
expect(mockOverlayRef.dispose).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -327,4 +327,83 @@ describe('MonacoEditorComponent', () => {
comp.setText('line1\n\nline3');
expect(comp.getLineContent(2)).toBe('');
});

it('should delete a combined emoji entirely on backspace press', fakeAsync(() => {
fixture.detectChanges();
const combinedEmoji = 'πŸ‡©πŸ‡ͺ';
comp.setText(combinedEmoji);

const lines = combinedEmoji.split('\n');
const lastLine = lines[lines.length - 1];
comp.setPosition({ lineNumber: lines.length, column: lastLine.length + 1 });

const commandId = comp.getCustomBackspaceCommandId();
expect(commandId).toBeDefined();

comp['_editor'].trigger('keyboard', commandId!, null);
tick();

expect(comp.getText()).toEqual('');
}));

it('should delete combined emojis one cluster at a time on backspace press', fakeAsync(() => {
fixture.detectChanges();

const emoji1 = 'πŸ‡©πŸ‡ͺ';
const emoji2 = 'πŸ‡«πŸ‡·';
const combinedText = emoji1 + emoji2;

comp.setText(combinedText);
comp.setPosition({ lineNumber: 1, column: combinedText.length + 1 });

let commandId = comp.getCustomBackspaceCommandId();
expect(commandId).toBeDefined();
comp['_editor'].trigger('keyboard', commandId!, null);
tick();
fixture.detectChanges();

expect(comp.getText()).toEqual(emoji1);

comp.setPosition({ lineNumber: 1, column: emoji1.length + 1 });

commandId = comp.getCustomBackspaceCommandId();
expect(commandId).toBeDefined();
comp['_editor'].trigger('keyboard', commandId!, null);
tick();
fixture.detectChanges();

expect(comp.getText()).toEqual('');
}));

it('should delete only one emoji at a time in mixed text', fakeAsync(() => {
fixture.detectChanges();

const textWithEmoji = 'Hello πŸ‡©πŸ‡ͺ World!';
comp.setText(textWithEmoji);

comp.setPosition({ lineNumber: 1, column: textWithEmoji.length - 6 });

const commandId = comp.getCustomBackspaceCommandId();
expect(commandId).toBeDefined();

comp['_editor'].trigger('keyboard', commandId!, null);
tick();

expect(comp.getText()).toEqual('Hello World!');
}));

it('should place the cursor correctly after deleting an emoji', fakeAsync(() => {
fixture.detectChanges();

const text = 'Hello πŸ‘‹';
comp.setText(text);
comp.setPosition({ lineNumber: 1, column: text.length + 1 });

const commandId = comp.getCustomBackspaceCommandId();
comp['_editor'].trigger('keyboard', commandId!, null);
tick();

const newPosition = comp.getPosition();
expect(newPosition.column).toBe(7);
}));
});
Loading