Skip to content
Draft
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
309 changes: 95 additions & 214 deletions packages/roosterjs-react/lib/emoji/plugin/createEmojiPlugin.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import * as React from 'react';
import { KeyCodes } from '@fluentui/react/lib/Utilities';
import { MoreEmoji } from '../utils/emojiList';
import { showEmojiCallout } from '../components/showEmojiCallout';
import { undo } from 'roosterjs-content-model-core';
import {
isModifierKey,
createContentModelDocument,
createParagraph,
createSelectionMarker,
createText,
isNodeOfType,
iterateSelections,
mutateSegment,
} from 'roosterjs-content-model-dom';
import { PickerPlugin } from 'roosterjs-content-model-plugins';
import { MoreEmoji } from '../utils/emojiList';
import { showEmojiCallout } from '../components/showEmojiCallout';
import type { EmojiICallout } from '../components/showEmojiCallout';
import type { Emoji } from '../type/Emoji';
import type { EmojiPane } from '../components/EmojiPane';
Expand All @@ -19,34 +19,24 @@ import {
EmojiFamilyStrings,
EmojiKeywordStrings,
} from '../type/EmojiStrings';
import type { IEditor, KeyDownEvent, KeyUpEvent, PluginEvent } from 'roosterjs-content-model-types';

const KEYCODE_COLON = 186;
const KEYCODE_COLON_FIREFOX = 59;

// Regex looks for an emoji right before the : to allow contextual search immediately following an emoji
// MATCHES: 0: 😃:r
// 1: 😃
// 2: :r
const EMOJI_BEFORE_COLON_REGEX = /([\u0023-\u0039][\u20e3]|[\ud800-\udbff][\udc00-\udfff]|[\u00a9-\u00ae]|[\u2122-\u3299])*([:;][^:]*)/;

// White space matching regex. It matches following chars:
// \s: white space
// \u00A0: no-breaking white space
// \u200B: zero width space
// \u3000: full width space (which can come from JPN IME)
const WHITESPACE_REGEX = /[\s\u00A0\u200B\u3000]+([^\s\u00A0\u200B\u3000]*)$/i;

class EmojiPlugin implements ReactEditorPlugin {
import type { IEditor, PluginEvent } from 'roosterjs-content-model-types';
import type {
PickerDirection,
PickerHandler,
PickerHelper,
PickerSelectionChangMode,
} from 'roosterjs-content-model-plugins';

class EmojiPlugin implements ReactEditorPlugin, PickerHandler {
private editor: IEditor | null = null;
private eventHandledOnKeyDown: boolean = false;
private canUndoEmoji: boolean = false;
private isSuggesting: boolean = false;
private paneRef = React.createRef<EmojiPane>();
private timer: number | null = null;
private uiUtilities: UIUtilities | null = null;
private strings: Record<string, string>;
private emojiCalloutRef = React.createRef<EmojiICallout>();
private pickerPlugin: PickerPlugin;
private pickerHelper: PickerHelper | null = null;
private queryString: string = ':';
private baseId = 0;

constructor(private searchBoxStrings?: LocalizedStrings<EmojiStringKeys>) {
Expand All @@ -55,6 +45,7 @@ class EmojiPlugin implements ReactEditorPlugin {
...EmojiKeywordStrings,
...EmojiFamilyStrings,
};
this.pickerPlugin = new PickerPlugin(':', this);
}

setUIUtilities(uiUtilities: UIUtilities) {
Expand All @@ -66,146 +57,79 @@ class EmojiPlugin implements ReactEditorPlugin {
}

public dispose() {
this.setIsSuggesting(false);
this.emojiCalloutRef.current?.dismiss();
this.editor = null;
this.pickerPlugin.dispose();
this.baseId = 0;
this.editor = null;
}

public initialize(editor: IEditor): void {
this.editor = editor;
this.pickerPlugin.initialize(editor);
}

public onPluginEvent(event: PluginEvent): void {
if (!this.editor) {
return;
}

if (event.eventType === 'keyDown') {
this.eventHandledOnKeyDown = false;
if (this.isSuggesting) {
this.onKeyDownSuggestingDomEvent(this.editor, event);
} else if (event.rawEvent.key === 'Backspace' && this.canUndoEmoji) {
//TODO: 1051
// If KeyDown is backspace and canUndoEmoji, call editor undo
undo(this.editor);

this.handleEventOnKeyDown(event);
this.canUndoEmoji = false;
}
} else if (event.eventType === 'keyUp' && !isModifierKey(event.rawEvent)) {
if (this.isSuggesting) {
this.onKeyUpSuggestingDomEvent(this.editor, event);
} else {
this.onKeyUpDomEvent(this.editor, event);
}
} else if (event.eventType === 'mouseUp') {
//TODO: 1052
// If MouseUp, the emoji cannot be undone
this.canUndoEmoji = false;
this.setIsSuggesting(false);
}
this.pickerPlugin.onPluginEvent(event);
}

/**
* On KeyDown suggesting DOM event
* Try to insert emoji is possible
* Intercept arrow keys to move selection if popup is shown
*/
private onKeyDownSuggestingDomEvent(editor: IEditor, event: KeyDownEvent): void {
// If key is enter, try insert emoji at selection
// If key is space and selection is shortcut, try insert emoji

const wordBeforeCursor = this.getWordBeforeCursor(editor);
switch (event.rawEvent.key) {
case 'Enter':
const selectedEmoji = this.paneRef.current?.getSelectedEmoji();
// check if selection is on the "..." and show full picker if so, otherwise try to apply emoji
if (
!selectedEmoji ||
!wordBeforeCursor ||
this.tryShowFullPicker(event, selectedEmoji, wordBeforeCursor)
) {
break;
} else {
this.insertEmoji(selectedEmoji, wordBeforeCursor);
this.handleEventOnKeyDown(event);
}
public willHandleEventExclusively(event: PluginEvent): boolean {
return this.pickerPlugin.willHandleEventExclusively(event);
}

break;
case 'ArrowLeft':
case 'ArrowRight':
this.paneRef.current?.navigate(event.rawEvent.key === 'ArrowLeft' ? -1 : 1);
this.handleEventOnKeyDown(event);
break;
case 'Escape':
this.setIsSuggesting(false);
this.handleEventOnKeyDown(event);
}
public onInitialize(helper: PickerHelper): void {
this.pickerHelper = helper;
}

private tryShowFullPicker(
event: KeyDownEvent,
selectedEmoji: Emoji,
wordBeforeCursor: string
): boolean {
if (selectedEmoji !== MoreEmoji) {
return false;
}
public onDispose(): void {
this.setIsSuggesting(false);
this.emojiCalloutRef.current?.dismiss();
this.pickerHelper = null;
}

this.handleEventOnKeyDown(event);
this.paneRef.current?.showFullPicker(wordBeforeCursor);
return true;
public onTrigger(queryString: string): PickerDirection | null {
this.queryString = queryString;
this.setIsSuggesting(true);
this.paneRef.current?.setSearch(queryString);
return this.isSuggesting ? 'both' : null;
}

/**
* On KeyUp suggesting DOM event
* If key is character, update search term
* Otherwise set isSuggesting to false
*/
private onKeyUpSuggestingDomEvent(editor: IEditor, event: KeyUpEvent): void {
if (this.eventHandledOnKeyDown) {
return;
}
// If this is a character key or backspace
// Clear the timer as we will either queue a new timer or stop suggesting
if (
this.timer &&
((event.rawEvent.key.length === 1 && event.rawEvent.which !== KeyCodes.space) ||
event.rawEvent.which === KeyCodes.backspace)
) {
this.editor?.getDocument().defaultView?.clearTimeout(this.timer);
this.timer = null;
this.emojiCalloutRef.current?.dismiss();
}
public onClosePicker(): void {
this.setIsSuggesting(false);
}

const wordBeforeCursor = this.getWordBeforeCursor(editor);
if (wordBeforeCursor) {
if (this.paneRef) {
this.paneRef.current?.setSearch(wordBeforeCursor);
} else {
this.setIsSuggesting(false);
}
} else {
this.setIsSuggesting(false);
}
public onQueryStringChanged(queryString: string): void {
this.queryString = queryString;
this.paneRef.current?.setSearch(queryString);
}

private onKeyUpDomEvent(editor: IEditor, event: KeyUpEvent): void {
if (this.eventHandledOnKeyDown) {
public onSelect(): void {
const selectedEmoji = this.paneRef.current?.getSelectedEmoji();
const queryString = this.queryString;

if (!selectedEmoji || !queryString || this.tryShowFullPicker(selectedEmoji, queryString)) {
return;
}

if (
(event.rawEvent.which === KEYCODE_COLON ||
event.rawEvent.which === KEYCODE_COLON_FIREFOX) &&
this.getWordBeforeCursor(editor) === ':'
) {
this.setIsSuggesting(true);
this.insertEmoji(selectedEmoji, queryString);
}

public onSelectionChanged(mode: PickerSelectionChangMode): void {
switch (mode) {
case 'next':
this.paneRef.current?.navigate(1);
break;
case 'previous':
this.paneRef.current?.navigate(-1);
break;
case 'nextRow':
this.paneRef.current?.navigate(1, 'Vertical');
break;
case 'previousRow':
this.paneRef.current?.navigate(-1, 'Vertical');
break;
}
}

private getCallout() {
private getCallout(): boolean {
const selection = this.editor?.getDOMSelection();
const rangeNode = selection?.type == 'range' ? selection.range.startContainer : null;
const rangeElement = isNodeOfType(rangeNode, 'ELEMENT_NODE')
Expand All @@ -227,14 +151,19 @@ class EmojiPlugin implements ReactEditorPlugin {
this.baseId,
this.searchBoxStrings
);

return true;
}

return false;
}

private onHideCallout = () => this.setIsSuggesting(false);
private onHideCallout = () => this.pickerHelper?.closePicker();

private onSelectFromPane = (emoji: Emoji, wordBeforeCursor: string): void => {
if (emoji === MoreEmoji) {
this.paneRef.current?.showFullPicker(wordBeforeCursor);
this.queryString = wordBeforeCursor;

if (this.tryShowFullPicker(emoji, wordBeforeCursor)) {
return;
}

Expand All @@ -248,84 +177,36 @@ class EmojiPlugin implements ReactEditorPlugin {

this.isSuggesting = isSuggesting;
if (this.isSuggesting) {
this.getCallout();
if (!this.getCallout()) {
this.isSuggesting = false;
}
} else if (this.emojiCalloutRef) {
this.emojiCalloutRef.current?.dismiss();
}
}

private insertEmoji(emoji: Emoji, wordBeforeCursor: string) {
if (!wordBeforeCursor || !this.editor || !emoji.codePoint) {
return;
private tryShowFullPicker(selectedEmoji: Emoji, queryString: string): boolean {
if (selectedEmoji !== MoreEmoji) {
return false;
}

this.editor.formatContentModel(model => {
iterateSelections(model, (_, __, block, segments) => {
if (
block?.blockType == 'Paragraph' &&
segments?.length == 1 &&
segments[0].segmentType == 'SelectionMarker'
) {
const index = block.segments.indexOf(segments[0]);
const previousSegment = block.segments[index - 1];

if (
previousSegment?.segmentType == 'Text' &&
previousSegment.text.endsWith(wordBeforeCursor)
) {
mutateSegment(block, previousSegment, segment => {
segment.text =
segment.text.substring(
0,
segment.text.length - wordBeforeCursor.length
) + emoji.codePoint;
});
}
}

return true;
});

return true;
});

this.emojiCalloutRef.current?.dismiss();
this.paneRef.current?.showFullPicker(queryString);
return true;
}

private getWordBeforeCursor(editor: IEditor): string | null {
let wordBeforeCursor: string | null = null;

editor.formatContentModel(model => {
iterateSelections(model, (_, __, block, segments) => {
if (
block?.blockType == 'Paragraph' &&
segments?.length == 1 &&
segments[0].segmentType == 'SelectionMarker'
) {
const index = block.segments.indexOf(segments[0]);
const prevSegment = block.segments[index - 1];

if (prevSegment?.segmentType == 'Text') {
// Match on the white space, the portion after space is on the index of 1 of the matched result
// (index at 0 is whole match result, index at 1 is the word)
const matches = WHITESPACE_REGEX.exec(prevSegment.text);
wordBeforeCursor = matches?.length == 2 ? matches[1] : prevSegment.text;
}
}

return true;
});
return false;
});
private insertEmoji(emoji: Emoji, queryString: string) {
if (!queryString || !emoji.codePoint || !this.pickerHelper) {
return;
}

const matches = wordBeforeCursor ? EMOJI_BEFORE_COLON_REGEX.exec(wordBeforeCursor) : null;
return matches && matches.length > 2 && matches[0] === wordBeforeCursor ? matches[2] : null;
}
const model = createContentModelDocument();
const paragraph = createParagraph();

paragraph.segments.push(createText(emoji.codePoint), createSelectionMarker());
model.blocks.push(paragraph);

private handleEventOnKeyDown(event: KeyDownEvent): void {
this.eventHandledOnKeyDown = true;
event.rawEvent.preventDefault();
event.rawEvent.stopImmediatePropagation();
this.pickerHelper.replaceQueryString(model, undefined /*options*/, true /*canUndoByBackspace*/);
this.pickerHelper.closePicker();
}
}

Expand Down