From 9d086ea7bf200c98eb252b0ceef2351ea2e39169 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:55:49 +0000 Subject: [PATCH 1/2] Initial plan From d6bff6d2928362db67966d2013297ce1b20f6e52 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:04:01 +0000 Subject: [PATCH 2/2] Port emoji plugin to use PickerPlugin --- .../lib/emoji/plugin/createEmojiPlugin.ts | 309 ++++++------------ 1 file changed, 95 insertions(+), 214 deletions(-) diff --git a/packages/roosterjs-react/lib/emoji/plugin/createEmojiPlugin.ts b/packages/roosterjs-react/lib/emoji/plugin/createEmojiPlugin.ts index 77bcb35c8b7e..b51f6ae4c8e4 100644 --- a/packages/roosterjs-react/lib/emoji/plugin/createEmojiPlugin.ts +++ b/packages/roosterjs-react/lib/emoji/plugin/createEmojiPlugin.ts @@ -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'; @@ -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(); - private timer: number | null = null; private uiUtilities: UIUtilities | null = null; private strings: Record; private emojiCalloutRef = React.createRef(); + private pickerPlugin: PickerPlugin; + private pickerHelper: PickerHelper | null = null; + private queryString: string = ':'; private baseId = 0; constructor(private searchBoxStrings?: LocalizedStrings) { @@ -55,6 +45,7 @@ class EmojiPlugin implements ReactEditorPlugin { ...EmojiKeywordStrings, ...EmojiFamilyStrings, }; + this.pickerPlugin = new PickerPlugin(':', this); } setUIUtilities(uiUtilities: UIUtilities) { @@ -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') @@ -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; } @@ -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(); } }