import { DOMUtils, ComposerExtension, RegExpUtils } from 'mailspring-exports'; import emoji from 'node-emoji'; import EmojiStore from './emoji-store'; import EmojiActions from './emoji-actions'; import EmojiPicker from './emoji-picker'; class EmojiComposerExtension extends ComposerExtension { static selState = null; static onContentChanged = ({ editor }) => { const sel = editor.currentSelection(); const { emojiOptions, triggerWord } = EmojiComposerExtension._findEmojiOptions(sel); if (sel.anchorNode && sel.isCollapsed) { if (emojiOptions.length > 0) { if (!DOMUtils.closest(sel.anchorNode, 'emoji-autocomplete')) { const anchorOffset = Math.max(sel.anchorOffset - triggerWord.length - 1, 0); editor.select(sel.anchorNode, anchorOffset, sel.focusNode, sel.focusOffset); DOMUtils.wrap(sel.getRangeAt(0), 'emoji-autocomplete'); editor.currentSelection().collapseToEnd(); } } else { if (DOMUtils.closest(sel.anchorNode, 'emoji-autocomplete')) { editor.unwrapNodeAndSelectAll(DOMUtils.closest(sel.anchorNode, 'emoji-autocomplete')); editor.currentSelection().collapseToEnd(); } } } else { if (DOMUtils.closest(sel.anchorNode, 'emoji-autocomplete')) { editor.unwrapNodeAndSelectAll(DOMUtils.closest(sel.anchorNode, 'emoji-autocomplete')); editor.currentSelection().collapseToEnd(); } } }; static onBlur = ({ editor }) => { EmojiComposerExtension.selState = editor.currentSelection().exportSelection(); }; static onFocus = ({ editor }) => { if (EmojiComposerExtension.selState) { editor.select(EmojiComposerExtension.selState); EmojiComposerExtension.selState = null; } }; static toolbarComponentConfig = ({ toolbarState }) => { const sel = toolbarState.selectionSnapshot; if (sel) { const { emojiOptions } = EmojiComposerExtension._findEmojiOptions(sel); if (emojiOptions.length > 0 && !toolbarState.dragging && !toolbarState.doubleDown) { const locationRefNode = DOMUtils.closest(sel.anchorNode, 'emoji-autocomplete'); if (!locationRefNode) { return null; } const selectedEmoji = locationRefNode.getAttribute('selectedEmoji'); return { component: EmojiPicker, props: { emojiOptions, selectedEmoji, }, locationRefNode: locationRefNode, width: EmojiComposerExtension._emojiPickerWidth(emojiOptions), height: EmojiComposerExtension._emojiPickerHeight(emojiOptions), hidePointer: true, }; } } return null; }; static editingActions = () => { return [ { action: EmojiActions.selectEmoji, callback: EmojiComposerExtension._onSelectEmoji, }, ]; }; static onKeyDown = ({ editor, event }) => { const sel = editor.currentSelection(); const { emojiOptions } = EmojiComposerExtension._findEmojiOptions(sel); if (emojiOptions.length > 0) { if ( event.key === 'ArrowDown' || event.key === 'ArrowRight' || event.key === 'ArrowUp' || event.key === 'ArrowLeft' ) { event.preventDefault(); const moveToNext = event.key === 'ArrowDown' || event.key === 'ArrowRight'; const emojiNameNode = DOMUtils.closest(sel.anchorNode, 'emoji-autocomplete'); if (!emojiNameNode) return null; const selectedEmoji = emojiNameNode.getAttribute('selectedEmoji'); if (selectedEmoji) { const emojiIndex = emojiOptions.indexOf(selectedEmoji); if (emojiIndex < emojiOptions.length - 1 && moveToNext) { emojiNameNode.setAttribute('selectedEmoji', emojiOptions[emojiIndex + 1]); } else if (emojiIndex > 0 && !moveToNext) { emojiNameNode.setAttribute('selectedEmoji', emojiOptions[emojiIndex - 1]); } else { const index = moveToNext ? 0 : emojiOptions.length - 1; emojiNameNode.setAttribute('selectedEmoji', emojiOptions[index]); } } else { const index = moveToNext ? 1 : emojiOptions.length - 1; emojiNameNode.setAttribute('selectedEmoji', emojiOptions[index]); } } else if (event.key === 'Enter' || event.key === 'Tab') { event.preventDefault(); const emojiNameNode = DOMUtils.closest(sel.anchorNode, 'emoji-autocomplete'); if (!emojiNameNode) return null; let selectedEmoji = emojiNameNode.getAttribute('selectedEmoji'); if (!selectedEmoji) selectedEmoji = emojiOptions[0]; const args = { editor: editor, actionArg: { emojiName: selectedEmoji, replaceSelection: true, }, }; EmojiComposerExtension._onSelectEmoji(args); } } return null; }; static applyTransformsForSending = ({ draftBodyRootNode }) => { const imgs = draftBodyRootNode.querySelectorAll('img'); for (const imgEl of Array.from(imgs)) { const names = imgEl.className.split(' '); if (names[0] === 'emoji') { const emojiChar = emoji.get(names[1]); if (emojiChar) { imgEl.parentNode.replaceChild(document.createTextNode(emojiChar), imgEl); } } } }; static unapplyTransformsForSending = ({ draftBodyRootNode }) => { const treeWalker = document.createTreeWalker(draftBodyRootNode, NodeFilter.SHOW_TEXT); while (treeWalker.nextNode()) { const textNode = treeWalker.currentNode; const match = RegExpUtils.emojiRegex().exec(textNode.textContent); if (match) { const emojiPlusTrailingEl = textNode.splitText(match.index); emojiPlusTrailingEl.splitText(match.length); const emojiEl = emojiPlusTrailingEl; const imgEl = document.createElement('img'); const emojiName = emoji.which(match[0]); imgEl.className = `emoji ${emojiName}`; imgEl.src = EmojiStore.getImagePath(emojiName); imgEl.width = '14'; imgEl.height = '14'; imgEl.style.marginTop = '-5px'; emojiEl.parentNode.replaceChild(imgEl, emojiEl); } } }; static _findEmojiOptions(sel) { if ( sel.anchorNode && sel.anchorNode.nodeValue && sel.anchorNode.nodeValue.length > 0 && sel.isCollapsed ) { const words = sel.anchorNode.nodeValue.substring(0, sel.anchorOffset); let index = words.lastIndexOf(':'); let lastWord = ''; if (index !== -1 && words.lastIndexOf(' ') < index) { lastWord = words.substring(index + 1, sel.anchorOffset); } else { const { text } = EmojiComposerExtension._getTextUntilSpace( sel.anchorNode, sel.anchorOffset ); index = text.lastIndexOf(':'); if (index !== -1 && text.lastIndexOf(' ') < index) { lastWord = text.substring(index + 1); } else { return { triggerWord: '', emojiOptions: [] }; } } if (lastWord.length > 0) { return { triggerWord: lastWord, emojiOptions: EmojiComposerExtension._findMatches(lastWord), }; } return { triggerWord: lastWord, emojiOptions: [] }; } return { triggerWord: '', emojiOptions: [] }; } static _onSelectEmoji = ({ editor, actionArg }) => { const { emojiName, replaceSelection } = actionArg; if (!emojiName) return null; if (replaceSelection) { const sel = editor.currentSelection(); if ( sel.anchorNode && sel.anchorNode.nodeValue && sel.anchorNode.nodeValue.length > 0 && sel.isCollapsed ) { const words = sel.anchorNode.nodeValue.substring(0, sel.anchorOffset); let index = words.lastIndexOf(':'); let lastWord = words.substring(index + 1, sel.anchorOffset); if (index !== -1 && words.lastIndexOf(' ') < index) { editor.select( sel.anchorNode, sel.anchorOffset - lastWord.length - 1, sel.focusNode, sel.focusOffset ); } else { const { text, textNode } = EmojiComposerExtension._getTextUntilSpace( sel.anchorNode, sel.anchorOffset ); index = text.lastIndexOf(':'); lastWord = text.substring(index + 1); const offset = textNode.nodeValue.lastIndexOf(':'); editor.select(textNode, offset, sel.focusNode, sel.focusOffset); editor.delete(); } } } const emojiChar = emoji.get(emojiName); const html = ``; editor.insertHTML(html, { selectInsertion: false }); EmojiActions.useEmoji({ emojiName: emojiName, emojiChar: emojiChar }); return null; }; static _emojiPickerWidth(emojiOptions) { let maxLength = 0; for (const emojiOption of emojiOptions) { if (emojiOption.length > maxLength) { maxLength = emojiOption.length; } } // TODO: Calculate width of words more accurately for a closer fit. const WIDTH_PER_CHAR = 8; return (maxLength + 10) * WIDTH_PER_CHAR; } static _emojiPickerHeight(emojiOptions) { const HEIGHT_PER_EMOJI = 25; if (emojiOptions.length < 5) { return emojiOptions.length * HEIGHT_PER_EMOJI + 20; } return 5 * HEIGHT_PER_EMOJI + 23; } static _getTextUntilSpace(node, offset) { let text = node.nodeValue.substring(0, offset); let prevTextNode = DOMUtils.previousTextNode(node); if (!prevTextNode) return { text: text, textNode: node }; while (prevTextNode) { if ( prevTextNode.nodeValue.indexOf(' ') === -1 && prevTextNode.nodeValue.indexOf(':') === -1 ) { text = prevTextNode.nodeValue + text; prevTextNode = DOMUtils.previousTextNode(prevTextNode); } else if (prevTextNode.nextSibling && prevTextNode.nextSibling.nodeName !== 'DIV') { text = prevTextNode.nodeValue.trim() + text; break; } else { break; } } return { text: text, textNode: prevTextNode }; } static _findMatches(word) { const emojiOptions = []; const emojiNames = Object.keys(emoji.emoji).sort(); for (const emojiName of emojiNames) { if (word === emojiName.substring(0, word.length)) { emojiOptions.push(emojiName); } } return emojiOptions; } } export default EmojiComposerExtension;