import * as React from 'react'; import { createPortal } from 'react-dom'; import { createSelector } from 'reselect'; import { CompositeDecorator, ContentBlock, ContentState, DraftEditorCommand, DraftHandleValue, Editor, EditorChangeType, EditorState, getDefaultKeyBinding, Modifier, SelectionState, } from 'draft-js'; import Measure, { ContentRect } from 'react-measure'; import { Manager, Popper, Reference } from 'react-popper'; import { get, head, noop, trimEnd } from 'lodash'; import classNames from 'classnames'; import emojiRegex from 'emoji-regex'; import { Emoji } from './emoji/Emoji'; import { EmojiPickDataType } from './emoji/EmojiPicker'; import { convertShortName, EmojiData, replaceColons, search, } from './emoji/lib'; import { LocalizerType } from '../types/Util'; const MAX_LENGTH = 64 * 1024; const colonsRegex = /(?:^|\s):[a-z0-9-_+]+:?/gi; const triggerEmojiRegex = /^(?:[-+]\d|[a-z]{2})/i; export type Props = { readonly i18n: LocalizerType; readonly disabled?: boolean; readonly large?: boolean; readonly editorRef?: React.RefObject; readonly inputApi?: React.MutableRefObject; readonly skinTone?: EmojiPickDataType['skinTone']; readonly startingText?: string; onDirtyChange?(dirty: boolean): unknown; onEditorStateChange?(messageText: string, caretLocation: number): unknown; onEditorSizeChange?(rect: ContentRect): unknown; onTextTooLong(): unknown; onPickEmoji(o: EmojiPickDataType): unknown; onSubmit(message: string): unknown; }; export type InputApi = { insertEmoji: (e: EmojiPickDataType) => void; reset: () => void; resetEmojiResults: () => void; submit: () => void; }; export type CompositionInputEditorCommand = | DraftEditorCommand | ('enter-emoji' | 'next-emoji' | 'prev-emoji' | 'submit'); function getTrimmedMatchAtIndex(str: string, index: number, pattern: RegExp) { let match; // Reset regex state pattern.exec(''); // tslint:disable-next-line no-conditional-assignment while ((match = pattern.exec(str))) { const matchStr = match.toString(); const start = match.index + (matchStr.length - matchStr.trimLeft().length); const end = match.index + matchStr.trimRight().length; if (index >= start && index <= end) { return match.toString(); } } return null; } function getLengthOfSelectedText(state: EditorState): number { const currentSelection = state.getSelection(); let length = 0; const currentContent = state.getCurrentContent(); const startKey = currentSelection.getStartKey(); const endKey = currentSelection.getEndKey(); const startBlock = currentContent.getBlockForKey(startKey); const isStartAndEndBlockAreTheSame = startKey === endKey; const startBlockTextLength = startBlock.getLength(); const startSelectedTextLength = startBlockTextLength - currentSelection.getStartOffset(); const endSelectedTextLength = currentSelection.getEndOffset(); const keyAfterEnd = currentContent.getKeyAfter(endKey); if (isStartAndEndBlockAreTheSame) { length += currentSelection.getEndOffset() - currentSelection.getStartOffset(); } else { let currentKey = startKey; while (currentKey && currentKey !== keyAfterEnd) { if (currentKey === startKey) { length += startSelectedTextLength + 1; } else if (currentKey === endKey) { length += endSelectedTextLength; } else { length += currentContent.getBlockForKey(currentKey).getLength() + 1; } currentKey = currentContent.getKeyAfter(currentKey); } } return length; } function getWordAtIndex( str: string, index: number ): { start: number; end: number; word: string } { const start = str .slice(0, index + 1) .replace(/\s+$/, '') .search(/\S+$/); let end = str .slice(index) .split('') .findIndex(c => /[^a-z0-9-_]/i.test(c) || c === ':') + index; const endChar = str[end]; if (/\w|:/.test(endChar)) { end += 1; } const word = str.slice(start, end); if (word === ':' && str.length > 1) { return getWordAtIndex(str, index + 1); } return { start, end, word, }; } const compositeDecorator = new CompositeDecorator([ { strategy: (block, cb) => { const pat = emojiRegex(); const text = block.getText(); let match; let index; // tslint:disable-next-line no-conditional-assignment while ((match = pat.exec(text))) { index = match.index; cb(index, index + match[0].length); } }, component: ({ children, contentState, entityKey, }: { children: React.ReactNode; contentState: ContentState; entityKey: string; }) => entityKey ? ( {children} ) : ( children ), }, ]); type FunctionRef = (el: HTMLElement | null) => unknown; // A selector which combines multiple react refs into a single, referentially-equal functional ref. const combineRefs = createSelector( (r1: FunctionRef) => r1, (_r1: any, r2: FunctionRef) => r2, (_r1: any, _r2: any, r3: React.MutableRefObject) => r3, (r1, r2, r3) => (el: HTMLDivElement) => { r1(el); r2(el); r3.current = el; } ); const getInitialEditorState = (startingText?: string) => { if (!startingText) { return EditorState.createEmpty(compositeDecorator); } const end = startingText.length; const state = EditorState.createWithContent( ContentState.createFromText(startingText), compositeDecorator ); const selection = state.getSelection(); const selectionAtEnd = selection.merge({ anchorOffset: end, focusOffset: end, }) as SelectionState; return EditorState.forceSelection(state, selectionAtEnd); }; // tslint:disable-next-line max-func-body-length export const CompositionInput = ({ i18n, disabled, large, editorRef, inputApi, onDirtyChange, onEditorStateChange, onEditorSizeChange, onTextTooLong, onPickEmoji, onSubmit, skinTone, startingText, }: Props) => { const [editorRenderState, setEditorRenderState] = React.useState( getInitialEditorState(startingText) ); const [searchText, setSearchText] = React.useState(''); const [emojiResults, setEmojiResults] = React.useState>([]); const [emojiResultsIndex, setEmojiResultsIndex] = React.useState(0); const [editorWidth, setEditorWidth] = React.useState(0); const [popperRoot, setPopperRoot] = React.useState( null ); const dirtyRef = React.useRef(false); const focusRef = React.useRef(false); const editorStateRef = React.useRef(editorRenderState); const rootElRef = React.useRef(); // This function sets editorState and also keeps a reference to the newly set // state so we can reference the state in effects and callbacks without // excessive cleanup const setAndTrackEditorState = React.useCallback( (newState: EditorState) => { setEditorRenderState(newState); editorStateRef.current = newState; }, [setEditorRenderState, editorStateRef] ); const updateExternalStateListeners = React.useCallback( (newState: EditorState) => { const plainText = newState .getCurrentContent() .getPlainText() .trim(); const cursorBlockKey = newState.getSelection().getStartKey(); const cursorBlockIndex = editorStateRef.current .getCurrentContent() .getBlockMap() .keySeq() .findIndex(key => key === cursorBlockKey); const caretLocation = newState .getCurrentContent() .getBlockMap() .valueSeq() .toArray() .reduce((sum: number, block: ContentBlock, currentIndex: number) => { if (currentIndex < cursorBlockIndex) { return sum + block.getText().length + 1; // +1 for newline } if (currentIndex === cursorBlockIndex) { return sum + newState.getSelection().getStartOffset(); } return sum; }, 0); if (onDirtyChange) { const isDirty = !!plainText; if (dirtyRef.current !== isDirty) { dirtyRef.current = isDirty; onDirtyChange(isDirty); } } if (onEditorStateChange) { onEditorStateChange(plainText, caretLocation); } }, [onDirtyChange, onEditorStateChange, editorStateRef] ); const resetEmojiResults = React.useCallback( () => { setEmojiResults([]); setEmojiResultsIndex(0); setSearchText(''); }, [setEmojiResults, setEmojiResultsIndex, setSearchText] ); const handleEditorStateChange = React.useCallback( (newState: EditorState) => { // Does the current position have any emojiable text? const selection = newState.getSelection(); const caretLocation = selection.getStartOffset(); const content = newState .getCurrentContent() .getBlockForKey(selection.getAnchorKey()) .getText(); const match = getTrimmedMatchAtIndex(content, caretLocation, colonsRegex); // Update the state to indicate emojiable text at the current position. const newSearchText = match ? match.trim().substr(1) : ''; if (newSearchText.endsWith(':')) { const bareText = trimEnd(newSearchText, ':'); const emoji = head(search(bareText)); if (emoji && bareText === emoji.short_name) { handleEditorCommand('enter-emoji', newState, emoji); // Prevent inserted colon from persisting to state return; } else { resetEmojiResults(); } } else if (triggerEmojiRegex.test(newSearchText) && focusRef.current) { setEmojiResults(search(newSearchText, 10)); setSearchText(newSearchText); setEmojiResultsIndex(0); } else { resetEmojiResults(); } // Finally, update the editor state setAndTrackEditorState(newState); updateExternalStateListeners(newState); }, [ focusRef, resetEmojiResults, setAndTrackEditorState, setSearchText, setEmojiResults, ] ); const handleBeforeInput = React.useCallback( (): DraftHandleValue => { if (!editorStateRef.current) { return 'not-handled'; } const editorState = editorStateRef.current; const plainText = editorState.getCurrentContent().getPlainText(); const selectedTextLength = getLengthOfSelectedText(editorState); if (plainText.length - selectedTextLength > MAX_LENGTH - 1) { onTextTooLong(); return 'handled'; } return 'not-handled'; }, [onTextTooLong, editorStateRef] ); const handlePastedText = React.useCallback( (pastedText: string): DraftHandleValue => { if (!editorStateRef.current) { return 'not-handled'; } const editorState = editorStateRef.current; const plainText = editorState.getCurrentContent().getPlainText(); const selectedTextLength = getLengthOfSelectedText(editorState); if ( plainText.length + pastedText.length - selectedTextLength > MAX_LENGTH ) { onTextTooLong(); return 'handled'; } return 'not-handled'; }, [onTextTooLong, editorStateRef] ); const resetEditorState = React.useCallback( () => { const newEmptyState = EditorState.createEmpty(compositeDecorator); setAndTrackEditorState(newEmptyState); resetEmojiResults(); }, [editorStateRef, resetEmojiResults, setAndTrackEditorState] ); const submit = React.useCallback( () => { const { current: state } = editorStateRef; const text = state.getCurrentContent().getPlainText(); const emojidText = replaceColons(text); const trimmedText = emojidText.trim(); onSubmit(trimmedText); }, [editorStateRef, onSubmit] ); const handleEditorSizeChange = React.useCallback( (rect: ContentRect) => { if (rect.bounds) { setEditorWidth(rect.bounds.width); if (onEditorSizeChange) { onEditorSizeChange(rect); } } }, [onEditorSizeChange, setEditorWidth] ); const selectEmojiResult = React.useCallback( (dir: 'next' | 'prev', e?: React.KeyboardEvent) => { if (emojiResults.length > 0) { if (e) { e.preventDefault(); } if (dir === 'next') { setEmojiResultsIndex(index => { const next = index + 1; if (next >= emojiResults.length) { return 0; } return next; }); } if (dir === 'prev') { setEmojiResultsIndex(index => { const next = index - 1; if (next < 0) { return emojiResults.length - 1; } return next; }); } } }, [emojiResultsIndex, emojiResults] ); const handleEditorArrowKey = React.useCallback( (e: React.KeyboardEvent) => { if (e.key === 'ArrowUp') { selectEmojiResult('prev', e); } if (e.key === 'ArrowDown') { selectEmojiResult('next', e); } }, [selectEmojiResult] ); const handleEscapeKey = React.useCallback( (e: React.KeyboardEvent) => { if (emojiResults.length > 0) { e.preventDefault(); resetEmojiResults(); } }, [resetEmojiResults, emojiResults] ); const getWordAtCaret = React.useCallback((state = editorStateRef.current) => { const selection = state.getSelection(); const index = selection.getAnchorOffset(); return getWordAtIndex( state .getCurrentContent() .getBlockForKey(selection.getAnchorKey()) .getText(), index ); }, []); const insertEmoji = React.useCallback( (e: EmojiPickDataType, replaceWord: boolean = false) => { const { current: state } = editorStateRef; const selection = state.getSelection(); const oldContent = state.getCurrentContent(); const emojiContent = convertShortName(e.shortName, e.skinTone); const emojiEntityKey = oldContent .createEntity('emoji', 'IMMUTABLE', { shortName: e.shortName, skinTone: e.skinTone, }) .getLastCreatedEntityKey(); const word = getWordAtCaret(); let newContent = Modifier.replaceText( oldContent, replaceWord ? (selection.merge({ anchorOffset: word.start, focusOffset: word.end, }) as SelectionState) : selection, emojiContent, undefined, emojiEntityKey ); const afterSelection = newContent.getSelectionAfter(); if ( afterSelection.getAnchorOffset() === newContent.getBlockForKey(afterSelection.getAnchorKey()).getLength() ) { newContent = Modifier.insertText(newContent, afterSelection, ' '); } const newState = EditorState.push( state, newContent, 'insert-emoji' as EditorChangeType ); setAndTrackEditorState(newState); resetEmojiResults(); }, [editorStateRef, setAndTrackEditorState, resetEmojiResults] ); const handleEditorCommand = React.useCallback( ( command: CompositionInputEditorCommand, state: EditorState, emojiOverride?: EmojiData ): DraftHandleValue => { if (command === 'enter-emoji') { const { short_name: shortName } = emojiOverride || emojiResults[emojiResultsIndex]; const content = state.getCurrentContent(); const selection = state.getSelection(); const word = getWordAtCaret(state); const emojiContent = convertShortName(shortName, skinTone); const emojiEntityKey = content .createEntity('emoji', 'IMMUTABLE', { shortName, skinTone, }) .getLastCreatedEntityKey(); const replaceSelection = selection.merge({ anchorOffset: word.start, focusOffset: word.end, }); let newContent = Modifier.replaceText( content, replaceSelection as SelectionState, emojiContent, undefined, emojiEntityKey ); const afterSelection = newContent.getSelectionAfter(); if ( afterSelection.getAnchorOffset() === newContent.getBlockForKey(afterSelection.getAnchorKey()).getLength() ) { newContent = Modifier.insertText(newContent, afterSelection, ' '); } const newState = EditorState.push( state, newContent, 'insert-emoji' as EditorChangeType ); setAndTrackEditorState(newState); resetEmojiResults(); onPickEmoji({ shortName }); return 'handled'; } if (command === 'submit') { submit(); return 'handled'; } if (command === 'next-emoji') { selectEmojiResult('next'); } if (command === 'prev-emoji') { selectEmojiResult('prev'); } return 'not-handled'; }, [ emojiResults, emojiResultsIndex, resetEmojiResults, selectEmojiResult, setAndTrackEditorState, skinTone, submit, ] ); const onTab = React.useCallback( (e: React.KeyboardEvent) => { if (e.shiftKey || emojiResults.length === 0) { return; } e.preventDefault(); handleEditorCommand('enter-emoji', editorStateRef.current); }, [emojiResults, editorStateRef, handleEditorCommand, resetEmojiResults] ); const editorKeybindingFn = React.useCallback( // tslint:disable-next-line cyclomatic-complexity (e: React.KeyboardEvent): CompositionInputEditorCommand | null => { const commandKey = get(window, 'platform') === 'darwin' && e.metaKey; const controlKey = get(window, 'platform') !== 'darwin' && e.ctrlKey; if (e.key === 'Enter' && emojiResults.length > 0) { e.preventDefault(); return 'enter-emoji'; } if (e.key === 'Enter' && !e.shiftKey) { if (large && !(controlKey || commandKey)) { return getDefaultKeyBinding(e); } e.preventDefault(); return 'submit'; } if (e.key === 'n' && e.ctrlKey) { e.preventDefault(); return 'next-emoji'; } if (e.key === 'p' && e.ctrlKey) { e.preventDefault(); return 'prev-emoji'; } // Get rid of default draft.js ctrl-m binding which interferes with Windows minimize if (e.key === 'm' && e.ctrlKey) { return null; } if (get(window, 'platform') === 'linux') { // Get rid of default draft.js shift-del binding which interferes with Linux cut if (e.key === 'Delete' && e.shiftKey) { return null; } } // Get rid of Ctrl-Shift-M, which by default adds a newline if ((e.key === 'm' || e.key === 'M') && e.shiftKey && e.ctrlKey) { e.preventDefault(); return null; } // Get rid of Ctrl-/, which on GNOME is bound to 'select all' if (e.key === '/' && !e.shiftKey && e.ctrlKey) { e.preventDefault(); return null; } return getDefaultKeyBinding(e); }, [emojiResults, large] ); // Create popper root React.useEffect( () => { if (emojiResults.length > 0) { const root = document.createElement('div'); setPopperRoot(root); document.body.appendChild(root); return () => { document.body.removeChild(root); setPopperRoot(null); }; } return noop; }, [setPopperRoot, emojiResults] ); const onFocus = React.useCallback( () => { focusRef.current = true; }, [focusRef] ); const onBlur = React.useCallback( () => { focusRef.current = false; }, [focusRef] ); // Manage focus // Chromium places the editor caret at the beginning of contenteditable divs on focus // Here, we force the last known selection on focusin (doing this with onFocus wasn't behaving properly) // This needs to be done in an effect because React doesn't support focus{In,Out} // https://github.com/facebook/react/issues/6410 React.useLayoutEffect( () => { const { current: rootEl } = rootElRef; if (rootEl) { const onFocusIn = () => { const { current: oldState } = editorStateRef; // Force selection to be old selection setAndTrackEditorState( EditorState.forceSelection(oldState, oldState.getSelection()) ); }; rootEl.addEventListener('focusin', onFocusIn); return () => { rootEl.removeEventListener('focusin', onFocusIn); }; } return noop; }, [editorStateRef, rootElRef, setAndTrackEditorState] ); if (inputApi) { inputApi.current = { reset: resetEditorState, submit, insertEmoji, resetEmojiResults, }; } return ( {({ ref: popperRef }) => ( {({ measureRef }) => (
)}
)}
{emojiResults.length > 0 && popperRoot ? createPortal( {({ ref, style }) => (
{emojiResults.map((emoji, index) => ( ))}
)}
, popperRoot ) : null}
); };