signal-desktop/ts/quill/emoji/completion.tsx
Chris Svenningsen 6a026c6439 Fix emoji completion; don't show when typing times
* Do not pop picker in timestamps
* Fix timestamp triggering emoji picker

Co-authored-by: Sidney Keese <sidney@carbonfive.com>
2020-11-04 13:00:41 -06:00

343 lines
8.5 KiB
TypeScript

import Quill from 'quill';
import Delta from 'quill-delta';
import React from 'react';
import _ from 'lodash';
import { Popper } from 'react-popper';
import classNames from 'classnames';
import { createPortal } from 'react-dom';
import {
EmojiData,
search,
convertShortName,
isShortName,
convertShortNameToData,
} from '../../components/emoji/lib';
import { Emoji } from '../../components/emoji/Emoji';
import { EmojiPickDataType } from '../../components/emoji/EmojiPicker';
type UpdatedDelta = Delta;
declare module 'quill' {
// this type is fixed in @types/quill, but our version of react-quill cannot
// use the version of quill that has this fix in its typings
// doing this manually allows us to use the correct type
// https://github.com/DefinitelyTyped/DefinitelyTyped/commit/6090a81c7dbd02b6b917f903a28c6c010b8432ea#diff-bff5e435d15f8f99f733c837e76945bced86bb85e93a75467015cc9b33b48212
interface UpdatedKey {
key: string | number;
shiftKey?: boolean;
}
interface Blot {
text?: string;
}
interface Quill {
updateContents(delta: UpdatedDelta, source?: Sources): UpdatedDelta;
getLeaf(index: number): [Blot, number];
}
interface KeyboardStatic {
addBinding(
key: UpdatedKey,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
callback: (range: RangeStatic, context: any) => void
): void;
}
}
interface EmojiPickerOptions {
onPickEmoji: (emoji: EmojiPickDataType) => void;
setEmojiPickerElement: (element: JSX.Element | null) => void;
skinTone: number;
}
export class EmojiCompletion {
results: Array<EmojiData>;
index: number;
options: EmojiPickerOptions;
root: HTMLDivElement;
quill: Quill;
constructor(quill: Quill, options: EmojiPickerOptions) {
this.results = [];
this.index = 0;
this.options = options;
this.root = document.body.appendChild(document.createElement('div'));
this.quill = quill;
const clearResults = () => {
if (this.results.length) {
this.reset();
}
return true;
};
const changeIndex = (by: number) => (): boolean => {
if (this.results.length) {
this.changeIndex(by);
return false;
}
return true;
};
this.quill.keyboard.addBinding({ key: 37 }, clearResults); // 37 = Left
this.quill.keyboard.addBinding({ key: 38 }, changeIndex(-1)); // 38 = Up
this.quill.keyboard.addBinding({ key: 39 }, clearResults); // 39 = Right
this.quill.keyboard.addBinding({ key: 40 }, changeIndex(1)); // 40 = Down
this.quill.on('text-change', _.debounce(this.onTextChange.bind(this), 100));
}
destroy(): void {
this.root.remove();
}
changeIndex(by: number): void {
this.index = (this.index + by + this.results.length) % this.results.length;
this.render();
}
getCurrentLeafTextPartitions(): [string, string] {
const range = this.quill.getSelection();
if (range) {
const [blot, blotIndex] = this.quill.getLeaf(range.index);
if (blot !== undefined && blot.text !== undefined) {
const leftLeafText = blot.text.substr(0, blotIndex);
const rightLeafText = blot.text.substr(blotIndex);
return [leftLeafText, rightLeafText];
}
}
return ['', ''];
}
onTextChange(): void {
const range = this.quill.getSelection();
if (!range) return;
const [leftLeafText, rightLeafText] = this.getCurrentLeafTextPartitions();
const leftTokenTextMatch = /(?<=^|\s):([-+0-9a-z_]*)(:?)$/.exec(
leftLeafText
);
const rightTokenTextMatch = /^([-+0-9a-z_]*):/.exec(rightLeafText);
if (!leftTokenTextMatch) {
this.reset();
return;
}
const [, leftTokenText, isSelfClosing] = leftTokenTextMatch;
if (isSelfClosing) {
if (isShortName(leftTokenText)) {
const emojiData = convertShortNameToData(
leftTokenText,
this.options.skinTone
);
if (emojiData) {
this.insertEmoji(
emojiData,
range.index - leftTokenText.length - 2,
leftTokenText.length + 2
);
return;
}
} else {
this.reset();
return;
}
}
if (rightTokenTextMatch) {
const [, rightTokenText] = rightTokenTextMatch;
const tokenText = leftTokenText + rightTokenText;
if (isShortName(tokenText)) {
const emojiData = convertShortNameToData(
tokenText,
this.options.skinTone
);
if (emojiData) {
this.insertEmoji(
emojiData,
range.index - leftTokenText.length - 1,
tokenText.length + 2
);
return;
}
}
}
if (leftTokenText.length < 2) {
this.reset();
return;
}
const results = search(leftTokenText, 10);
if (!results.length) {
this.reset();
return;
}
this.results = results;
this.render();
}
completeEmoji(): void {
const range = this.quill.getSelection();
if (range === null) return;
const emoji = this.results[this.index];
const [leafText] = this.getCurrentLeafTextPartitions();
const tokenTextMatch = /:([-+0-9a-z_]*)(:?)$/.exec(leafText);
if (tokenTextMatch === null) return;
const [, tokenText] = tokenTextMatch;
this.insertEmoji(
emoji,
range.index - tokenText.length - 1,
tokenText.length + 1,
true
);
}
insertEmoji(
emojiData: EmojiData,
index: number,
range: number,
withTrailingSpace = false
): void {
const emoji = convertShortName(emojiData.short_name, this.options.skinTone);
const delta = new Delta()
.retain(index)
.delete(range)
.insert({ emoji });
if (withTrailingSpace) {
this.quill.updateContents(delta.insert(' '), 'user');
this.quill.setSelection(index + 2, 0, 'user');
} else {
this.quill.updateContents(delta, 'user');
this.quill.setSelection(index + 1, 0, 'user');
}
this.options.onPickEmoji({
shortName: emojiData.short_name,
skinTone: this.options.skinTone,
});
this.reset();
}
reset(): void {
if (this.results.length) {
this.results = [];
this.index = 0;
this.render();
}
}
onUnmount(): void {
document.body.removeChild(this.root);
}
render(): void {
const { results: emojiResults, index: emojiResultsIndex } = this;
if (emojiResults.length === 0) {
this.options.setEmojiPickerElement(null);
return;
}
const element = createPortal(
<Popper
placement="top"
modifiers={{
width: {
enabled: true,
fn: oldData => {
const data = oldData;
const { width, left } = data.offsets.reference;
data.styles.width = `${width}px`;
data.offsets.popper.width = width;
data.offsets.popper.left = left;
return data;
},
order: 840,
},
}}
>
{({ ref, style }) => (
<div
ref={ref}
className="module-composition-input__emoji-suggestions"
style={style}
role="listbox"
aria-expanded
aria-activedescendant={`emoji-result--${
emojiResults.length
? emojiResults[emojiResultsIndex].short_name
: ''
}`}
tabIndex={0}
>
{emojiResults.map((emoji, index) => (
<button
type="button"
key={emoji.short_name}
id={`emoji-result--${emoji.short_name}`}
role="option button"
aria-selected={emojiResultsIndex === index}
onClick={() => {
this.index = index;
this.completeEmoji();
}}
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={this.options.skinTone}
/>
<div className="module-composition-input__emoji-suggestions__row__short-name">
:{emoji.short_name}:
</div>
</button>
))}
</div>
)}
</Popper>,
this.root
);
this.options.setEmojiPickerElement(element);
}
}