signal-desktop/ts/components/CompositionInput.tsx

694 lines
19 KiB
TypeScript
Raw Normal View History

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 { clamp, noop } 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 colonsRegex = /(?:^|\s):[a-z0-9-_+]+:?/gi;
export type Props = {
readonly i18n: LocalizerType;
readonly disabled?: boolean;
readonly editorRef?: React.RefObject<Editor>;
readonly inputApi?: React.MutableRefObject<InputApi | undefined>;
readonly skinTone?: EmojiPickDataType['skinTone'];
onDirtyChange?(dirty: boolean): unknown;
onEditorStateChange?(messageText: string, caretLocation: number): unknown;
onEditorSizeChange?(rect: ContentRect): 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 getWordAtIndex(str: string, index: number) {
const start = str
.slice(0, index + 1)
.replace(/\s+$/, '')
.search(/\S+$/);
const end = str.slice(index).search(/(?:\s|$)/) + index;
return {
start,
end,
word: str.slice(start, end),
};
}
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 ? (
<Emoji
shortName={contentState.getEntity(entityKey).getData().shortName}
skinTone={contentState.getEntity(entityKey).getData().skinTone}
inline={true}
size={20}
>
{children}
</Emoji>
) : (
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<HTMLDivElement>) => r3,
(r1, r2, r3) => (el: HTMLDivElement) => {
r1(el);
r2(el);
r3.current = el;
}
);
// tslint:disable-next-line max-func-body-length
export const CompositionInput = ({
i18n,
disabled,
editorRef,
inputApi,
onDirtyChange,
onEditorStateChange,
onEditorSizeChange,
onPickEmoji,
onSubmit,
skinTone,
}: Props) => {
const [editorState, setEditorState] = React.useState(
EditorState.createEmpty(compositeDecorator)
);
const [searchText, setSearchText] = React.useState<string>('');
const [emojiResults, setEmojiResults] = React.useState<Array<EmojiData>>([]);
const [emojiResultsIndex, setEmojiResultsIndex] = React.useState<number>(0);
const [editorWidth, setEditorWidth] = React.useState<number>(0);
const [popperRoot, setPopperRoot] = React.useState<HTMLDivElement | null>(
null
);
const dirtyRef = React.useRef(false);
const focusRef = React.useRef(false);
const editorStateRef = React.useRef<EditorState>(editorState);
const rootElRef = React.useRef<HTMLDivElement>();
// 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) => {
setEditorState(newState);
editorStateRef.current = newState;
},
[setEditorState, editorStateRef]
);
const updateExternalStateListeners = React.useCallback(
(newState: EditorState) => {
const plainText = newState.getCurrentContent().getPlainText();
const currentBlockKey = newState.getSelection().getStartKey();
const currentBlockIndex = editorState
.getCurrentContent()
.getBlockMap()
.keySeq()
.findIndex(key => key === currentBlockKey);
const caretLocation = newState
.getCurrentContent()
.getBlockMap()
.valueSeq()
.toArray()
.reduce((sum: number, block: ContentBlock, index: number) => {
if (currentBlockIndex < index) {
return sum + block.getText().length + 1; // +1 for newline
}
if (currentBlockIndex === index) {
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]
);
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.length >= 2 && 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 resetEditorState = React.useCallback(
() => {
const newEmptyState = EditorState.createEmpty(compositeDecorator);
setAndTrackEditorState(newEmptyState);
resetEmojiResults();
},
[editorStateRef, resetEmojiResults, setAndTrackEditorState]
);
const submit = React.useCallback(
() => {
const text = editorState.getCurrentContent().getPlainText();
const emojidText = replaceColons(text);
onSubmit(emojidText);
},
[editorState, 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(
clamp(emojiResultsIndex + 1, 0, emojiResults.length - 1)
);
}
if (dir === 'prev') {
setEmojiResultsIndex(
clamp(emojiResultsIndex - 1, 0, emojiResults.length - 1)
);
}
}
},
[setEmojiResultsIndex, 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(
() => {
const selection = editorState.getSelection();
const index = selection.getAnchorOffset();
return getWordAtIndex(
editorState
.getCurrentContent()
.getBlockForKey(selection.getAnchorKey())
.getText(),
index
);
},
[editorState]
);
const insertEmoji = React.useCallback(
(e: EmojiPickDataType, replaceWord: boolean = false) => {
const selection = editorState.getSelection();
const oldContent = editorState.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 = replaceWord
? Modifier.replaceText(
oldContent,
selection.merge({
anchorOffset: word.start,
focusOffset: word.end,
}) as SelectionState,
emojiContent,
undefined,
emojiEntityKey
)
: Modifier.insertText(
oldContent,
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(
editorState,
newContent,
'insert-emoji' as EditorChangeType
);
setAndTrackEditorState(newState);
resetEmojiResults();
},
[editorState, setAndTrackEditorState, resetEmojiResults]
);
const handleEditorCommand = React.useCallback(
(
command: CompositionInputEditorCommand,
state: EditorState
): DraftHandleValue => {
if (command === 'enter-emoji') {
const shortName = emojiResults[emojiResultsIndex].short_name;
const content = state.getCurrentContent();
const selection = state.getSelection();
const word = getWordAtCaret();
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', editorState);
},
[emojiResults, editorState, handleEditorCommand, resetEmojiResults]
);
const editorKeybindingFn = React.useCallback(
(e: React.KeyboardEvent): CompositionInputEditorCommand | null => {
if (e.key === 'Enter' && emojiResults.length > 0) {
e.preventDefault();
return 'enter-emoji';
}
if (e.key === 'Enter' && !e.shiftKey) {
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';
}
return getDefaultKeyBinding(e);
},
[emojiResults]
);
// 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 (
<Manager>
<Reference>
{({ ref: popperRef }) => (
<Measure bounds={true} onResize={handleEditorSizeChange}>
{({ measureRef }) => (
<div
className="module-composition-input__input"
ref={combineRefs(popperRef, measureRef, rootElRef)}
>
<div className="module-composition-input__input__scroller">
<Editor
ref={editorRef}
editorState={editorState}
onChange={handleEditorStateChange}
placeholder={i18n('sendMessage')}
onUpArrow={handleEditorArrowKey}
onDownArrow={handleEditorArrowKey}
onEscape={handleEscapeKey}
onTab={onTab}
handleKeyCommand={handleEditorCommand}
keyBindingFn={editorKeybindingFn}
spellCheck={true}
stripPastedStyles={true}
readOnly={disabled}
onFocus={onFocus}
onBlur={onBlur}
/>
</div>
</div>
)}
</Measure>
)}
</Reference>
{emojiResults.length > 0 && popperRoot
? createPortal(
<Popper placement="top" key={searchText}>
{({ ref, style }) => (
<div
ref={ref}
className="module-composition-input__emoji-suggestions"
style={{
...style,
width: editorWidth,
}}
role="listbox"
aria-expanded={true}
aria-activedescendant={`emoji-result--${
emojiResults[emojiResultsIndex].short_name
}`}
>
{emojiResults.map((emoji, index) => (
<button
key={emoji.short_name}
id={`emoji-result--${emoji.short_name}`}
role="option button"
aria-selected={emojiResultsIndex === index}
onMouseDown={() => {
insertEmoji(
{ shortName: emoji.short_name, skinTone },
true
);
onPickEmoji({ shortName: emoji.short_name });
}}
className={classNames(
'module-composition-input__emoji-suggestions__row',
emojiResultsIndex === index
? 'module-composition-input__emoji-suggestions__row--selected'
: null
)}
>
<Emoji
shortName={emoji.short_name}
size={16}
skinTone={skinTone}
/>
<div className="module-composition-input__emoji-suggestions__row__short-name">
:{emoji.short_name}:
</div>
</button>
))}
</div>
)}
</Popper>,
popperRoot
)
: null}
</Manager>
);
};