import { Callout , DirectionalHint } from "office-ui-fabric-react/lib/Callout" ; import { Async , KeyCodes } from "office-ui-fabric-react/lib/Utilities" ; import * as React from "react" ; import * as ReactDOM from "react-dom" ; import { replaceWithNode } from "roosterjs-editor-api" ; import { Editor , cacheGetContentSearcher , clearContentSearcherCache } from "roosterjs-editor-core" ; import { PluginDomEvent , PluginEvent , PluginEventType , PositionType } from "roosterjs-editor-types" ; import { AriaAttributes , LeanRoosterPlugin , NullFunction , Strings } from "roosterjs-react-common" ; import EmojiPane , { EmojiPaneMode , EmojiPaneProps } from "../components/EmojiPane" ; import Emoji from "../schema/Emoji" ; import { MoreEmoji } from "../utils/emojiList" ; import { matchShortcut } from "../utils/searchEmojis" ; const EMOJI_SEARCH_DELAY = 300 ; const INTERNAL_EMOJI_FONT_NAME = "EmojiFont" ; const EMOJI_FONT_LIST = "'Apple Color Emoji','Segoe UI Emoji', NotoColorEmoji,'Segoe UI Symbol','Android Emoji',EmojiSymbols" ; const EMOJI_BEFORE_COLON_REGEX = /([\u0023-\u0039][\u20e3]|[\ud800-\udbff][\udc00-\udfff]|[\u00a9-\u00ae]|[\u2122-\u3299])*([:;][^:]*)/ ; const KEYCODE_COLON = 186 ; const KEYCODE_COLON_FIREFOX = 59 ; export interface EmojiPluginOptions { strings ? : Strings ; calloutClassName ? : string ; calloutOnDismiss ? : ( ev ? : any ) => void ; emojiPaneProps ? : EmojiPaneProps ; onKeyboardTriggered ? : ( ) => void ; } export default class EmojiPlugin implements LeanRoosterPlugin { private _editor : Editor ; private _contentEditable : HTMLDivElement ; private _contentDiv : HTMLDivElement ; private _isSuggesting : boolean ; private _pane : EmojiPane ; private _eventHandledOnKeyDown : boolean ; private _canUndoEmoji : boolean ; private _timer : number ; private _callout : Callout ; private _async : Async ; private _refreshCalloutDebounced : ( ) => void ; private _strings : Strings ; constructor ( private options = { } ) { this . _async = new Async ( ) ; this . _refreshCalloutDebounced = this . _async . debounce ( ( ) => this . _refreshCallout ( ) , 100 ) ; this . _strings = options . strings ; } public getName ( ) { return 'Emoji' ; } public initialize ( editor ) { this . _editor = editor ; const document = editor . getDocument ( ) ; this . _contentDiv = document . createElement ( "div" ) ; document . body . appendChild ( this . _contentDiv ) ; } public initializeContentEditable ( contentEditable ) { this . _contentEditable = contentEditable ; } public setStrings ( strings ) { this . _strings = strings ; } public dispose ( ) { this . _setIsSuggesting ( false ) ; if ( this . _contentDiv ) { ReactDOM . unmountComponentAtNode ( this . _contentDiv ) ; this . _contentDiv . parentElement . removeChild ( this . _contentDiv ) ; this . _contentDiv = null ; } this . _editor = null ; this . _contentEditable = null ; if ( this . _async ) { this . _async . dispose ( ) ; this . _async = null ; } } public willHandleEventExclusively ( event ) { return this . _isSuggesting && ( event . eventType === PluginEventType . KeyDown || event . eventType === PluginEventType . KeyUp || event . eventType === PluginEventType . MouseUp ) ; } public onPluginEvent ( event ) { const domEvent = event as PluginDomEvent ; const keyboardEvent = domEvent . rawEvent as KeyboardEvent ; if ( event . eventType === PluginEventType . KeyDown ) { this . _eventHandledOnKeyDown = false ; if ( this . _isSuggesting ) { this . _onKeyDownSuggestingDomEvent ( domEvent ) ; } else if ( keyboardEvent . which === KeyCodes . backspace && this . _canUndoEmoji ) { this . _editor . undo ( ) ; this . _handleEventOnKeyDown ( domEvent ) ; this . _canUndoEmoji = false ; } } else if ( event . eventType === PluginEventType . KeyUp && ! this . _isModifierKey ( keyboardEvent . key ) ) { if ( this . _isSuggesting ) { this . _onKeyUpSuggestingDomEvent ( domEvent ) ; } else { this . _onKeyUpDomEvent ( domEvent ) ; } } else if ( event . eventType === PluginEventType . MouseUp ) { this . _canUndoEmoji = false ; this . _setIsSuggesting ( false ) ; } } public startEmoji ( startingString = ":" ) { const { _editor : editor } = this ; if ( ! editor ) { return ; } this . _setIsSuggesting ( true ) ; editor . insertContent ( startingString ) ; this . _triggerChangeEvent ( ) ; } private _setIsSuggesting ( isSuggesting , restoreSavedRange = true ) { if ( this . _isSuggesting === isSuggesting ) { return ; } this . _isSuggesting = isSuggesting ; if ( this . _isSuggesting ) { ReactDOM . render ( this . _getCallout ( ) , this . _contentDiv ) ; setTimeout ( ( ) => { const { _contentEditable } = this ; if ( _contentEditable ) { _contentEditable . setAttribute ( AriaAttributes . AutoComplete , "list" ) ; _contentEditable . setAttribute ( AriaAttributes . Owns , this . _pane . listId ) ; _contentEditable . setAttribute ( AriaAttributes . ActiveDescendant , this . _pane . getEmojiElementIdByIndex ( 0 ) ) ; } } , 0 ) ; this . _editor . saveSelectionRange ( ) ; } else { ReactDOM . unmountComponentAtNode ( this . _contentDiv ) ; this . _removeAutoCompleteAriaAttributes ( ) ; restoreSavedRange && this . _editor . restoreSavedRange ( ) ; } } private _removeAutoCompleteAriaAttributes ( ) { const { _contentEditable } = this ; if ( _contentEditable ) { _contentEditable . removeAttribute ( AriaAttributes . AutoComplete ) ; _contentEditable . removeAttribute ( AriaAttributes . Owns ) ; _contentEditable . removeAttribute ( AriaAttributes . ActiveDescendant ) ; } } /**
     * On KeyDown suggesting DOM event
     * Try to insert emoji is possible
     * Intercept arrow keys to move selection if popup is shown
     */ private _onKeyDownSuggestingDomEvent ( event ) { const keyboardEvent = event . rawEvent as KeyboardEvent ; const selectedEmoji = this . _pane . getSelectedEmoji ( ) ; const wordBeforeCursor = this . _getWordBeforeCursor ( event ) ; let emoji ; switch ( keyboardEvent . which ) { case KeyCodes . space : emoji = wordBeforeCursor ? matchShortcut ( wordBeforeCursor ) : null ; if ( ! emoji ) { this . _setIsSuggesting ( false , false ) ; } break ; case KeyCodes . enter : if ( this . _tryShowFullPicker ( event , selectedEmoji , wordBeforeCursor ) ) { break ; } emoji = this . _timer ? matchShortcut ( wordBeforeCursor ) : selectedEmoji ; break ; case KeyCodes . left : case KeyCodes . right : const nextIndex = this . _pane . navigate ( keyboardEvent . which === KeyCodes . left ? - 1 : 1 ) ; if ( nextIndex >= 0 ) { this . _contentEditable . setAttribute ( AriaAttributes . ActiveDescendant , this . _pane . getEmojiElementIdByIndex ( nextIndex ) ) ; } this . _handleEventOnKeyDown ( event ) ; break ; case KeyCodes . escape : this . _setIsSuggesting ( false ) ; this . _handleEventOnKeyDown ( event ) ; } if ( emoji && ( this . _canUndoEmoji = this . _insertEmoji ( emoji , wordBeforeCursor ) ) ) { this . _handleEventOnKeyDown ( event ) ; } } private _tryShowFullPicker ( event , selectedEmoji , wordBeforeCursor ) { if ( selectedEmoji !== MoreEmoji ) { return false ; } this . _handleEventOnKeyDown ( event ) ; this . _pane . showFullPicker ( wordBeforeCursor ) ; return true ; } /**
     * On KeyUp suggesting DOM event
     * If key is character, update search term
     * Otherwise set isSuggesting to false
     */ private _onKeyUpSuggestingDomEvent ( event ) { if ( this . _eventHandledOnKeyDown ) { return ; } const keyboardEvent = event . rawEvent as KeyboardEvent ; if ( ( keyboardEvent . key . length === 1 && keyboardEvent . which !== KeyCodes . space ) || keyboardEvent . which === KeyCodes . backspace ) { window . clearTimeout ( this . _timer ) ; this . _timer = null ; } const wordBeforeCursor = this . _getWordBeforeCursor ( event ) ; if ( wordBeforeCursor ) { this . _timer = window . setTimeout ( ( ) => { if ( this . _pane ) { this . _pane . setSearch ( wordBeforeCursor ) ; this . _timer = null ; } } , EMOJI_SEARCH_DELAY ) ; } else { this . _setIsSuggesting ( false ) ; } } private _onKeyUpDomEvent ( event ) { if ( this . _eventHandledOnKeyDown ) { return ; } const keyboardEvent = event . rawEvent as KeyboardEvent ; const wordBeforeCursor = this . _getWordBeforeCursor ( event ) ; if ( ( keyboardEvent . which === KEYCODE_COLON || keyboardEvent . which === KEYCODE_COLON_FIREFOX ) && wordBeforeCursor === ":" ) { const { onKeyboardTriggered = NullFunction } = this . options ; this . _setIsSuggesting ( true ) ; onKeyboardTriggered ( ) ; } else if ( wordBeforeCursor ) { const cursorData = cacheGetContentSearcher ( event , this . _editor ) ; const charBeforeCursor = cursorData ? cursorData . getSubStringBefore ( 1 ) : null ; if ( keyboardEvent . key === charBeforeCursor ) { const emoji = matchShortcut ( wordBeforeCursor ) ; if ( emoji && this . _insertEmoji ( emoji , wordBeforeCursor ) ) { clearContentSearcherCache ( event ) ; this . _canUndoEmoji = true ; } } } } private _insertEmoji ( emoji , wordBeforeCursor ) { let inserted = false ; this . _editor . addUndoSnapshot ( ) ; const node = this . _editor . getDocument ( ) . createElement ( "span" ) ; node . innerText = emoji . codePoint ; if ( wordBeforeCursor && replaceWithNode ( this . _editor , wordBeforeCursor , node , false ) ) { inserted = true ; this . _canUndoEmoji = true ; window . requestAnimationFrame ( ( ) => { if ( this . _editor && this . _editor . contains ( node ) ) { this . _editor . select ( node , PositionType . After ) ; this . _editor . addUndoSnapshot ( ) ; } } ) ; } else { inserted = this . _editor . insertNode ( node ) ; } inserted && this . _triggerChangeEvent ( ) ; this . _tryPatchEmojiFont ( ) ; this . _setIsSuggesting ( false ) ; return inserted ; } private _triggerChangeEvent ( ) { this . _editor . triggerContentChangedEvent ( "Emoji" ) ; } private _isModifierKey ( key ) { return key === "Shift" || key === "Control" || key === "Alt" || key === "Command" ; } private _handleEventOnKeyDown ( event ) { this . _eventHandledOnKeyDown = true ; event . rawEvent . preventDefault ( ) ; event . rawEvent . stopImmediatePropagation ( ) ; } private _getCallout ( ) { const { calloutClassName , emojiPaneProps = { } } = this . options ; const cursorRect = this . _editor . getCursorRect ( ) ; const point = { x : cursorRect . left , y : ( cursorRect . top + cursorRect . bottom ) / 2 } ; const gap = ( cursorRect . bottom - cursorRect . top ) / 2 + 5 ; return ( < Callout className = { calloutClassName } target = { point } directionalHint = { DirectionalHint . bottomAutoEdge } isBeakVisible = { false } gapSpace = { gap } onDismiss = { this . _calloutOnDismissInternal } ref = { this . _calloutRef } > < EmojiPane { ... emojiPaneProps } ref = { this . _paneRef } onSelect = { this . _onSelectFromPane } strings = { this . _strings || { } } onLayoutChanged = { this . _refreshCalloutDebounced } onModeChanged = { this . _onModeChanged } navBarProps = { emojiPaneProps . navBarProps } statusBarProps = { emojiPaneProps . statusBarProps } searchDisabled = { ! this . _strings || emojiPaneProps . searchDisabled } hideStatusBar = { ! this . _strings } /> < /Callout>); } private _onModeChanged = ( newMode ) : void => { if ( newMode !== EmojiPaneMode . Quick ) { this . _removeAutoCompleteAriaAttributes ( ) ; } } ; private _paneRef = ( ref ) : void => { this . _pane = ref ; } ; private _calloutRef = ( ref ) : void => { this . _callout = ref ; } ; private _refreshCallout ( ) { this . _callout . forceUpdate ( ) ; } private _calloutOnDismissInternal = ( ev ? ) : void => { this . _setIsSuggesting ( false ) ; if ( this . options . calloutOnDismiss ) { this . options . calloutOnDismiss ( ev ) ; } } ; private _tryPatchEmojiFont ( ) { const range = this . _editor . getSelectionRange ( ) ; const inlineElement = range ? this . _editor . getInlineElementAtNode ( range . startContainer ) : null ; const blockElement = inlineElement ? inlineElement . getParentBlock ( ) : null ; if ( blockElement ) { const blockNode = blockElement . getStartNode ( ) as HTMLElement ; const fontFamily = blockNode . style . fontFamily ; if ( fontFamily && fontFamily . toLowerCase ( ) . indexOf ( "emoji" ) < 0 ) { blockNode . style . fontFamily = fontFamily + "," + INTERNAL_EMOJI_FONT_NAME + "," + EMOJI_FONT_LIST ; return true ; } } return false ; } private _getWordBeforeCursor ( event ) { const cursorData = cacheGetContentSearcher ( event , this . _editor ) ; const wordBeforeCursor = cursorData ? cursorData . getWordBefore ( ) : null ; const matches = EMOJI_BEFORE_COLON_REGEX . exec ( wordBeforeCursor ) ; return matches && matches . length > 2 && matches [ 0 ] === wordBeforeCursor ? matches [ 2 ] : null ; } private _onSelectFromPane = ( emoji , wordBeforeCursor ) : void => { if ( emoji === MoreEmoji ) { this . _pane . showFullPicker ( wordBeforeCursor ) ; return ; } this . _insertEmoji ( emoji , wordBeforeCursor ) ; } ; }