signal-desktop/ts/quill/mentions/completion.tsx
Chris Svenningsen 53c89aa40f Send @mentions
2020-11-04 13:03:13 -06:00

300 lines
7.9 KiB
TypeScript

// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import Quill from 'quill';
import Delta from 'quill-delta';
import React, { RefObject } from 'react';
import { Popper } from 'react-popper';
import classNames from 'classnames';
import { createPortal } from 'react-dom';
import { ConversationType } from '../../state/ducks/conversations';
import { Avatar } from '../../components/Avatar';
import { LocalizerType } from '../../types/Util';
import { MemberRepository } from '../util';
export interface MentionCompletionOptions {
i18n: LocalizerType;
memberRepositoryRef: RefObject<MemberRepository>;
setMentionPickerElement: (element: JSX.Element | null) => void;
me?: ConversationType;
}
declare global {
interface HTMLElement {
// Webkit-specific
scrollIntoViewIfNeeded: (bringToCenter: boolean) => void;
}
}
const MENTION_REGEX = /(?:^|\W)@([-+\w]*)$/;
export class MentionCompletion {
results: Array<ConversationType>;
index: number;
root: HTMLDivElement;
quill: Quill;
options: MentionCompletionOptions;
suggestionListRef: RefObject<HTMLDivElement>;
constructor(quill: Quill, options: MentionCompletionOptions) {
this.results = [];
this.index = 0;
this.options = options;
this.root = document.body.appendChild(document.createElement('div'));
this.quill = quill;
this.suggestionListRef = React.createRef<HTMLDivElement>();
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); // Left Arrow
this.quill.keyboard.addBinding({ key: 38 }, changeIndex(-1)); // Up Arrow
this.quill.keyboard.addBinding({ key: 39 }, clearResults); // Right Arrow
this.quill.keyboard.addBinding({ key: 40 }, changeIndex(1)); // Down Arrow
this.quill.on('text-change', this.onTextChange.bind(this));
this.quill.on('selection-change', this.onSelectionChange.bind(this));
}
destroy(): void {
this.root.remove();
}
changeIndex(by: number): void {
this.index = (this.index + by + this.results.length) % this.results.length;
this.render();
const suggestionList = this.suggestionListRef.current;
if (suggestionList) {
const selectedElement = suggestionList.querySelector<HTMLElement>(
'[aria-selected="true"]'
);
if (selectedElement) {
selectedElement.scrollIntoViewIfNeeded(false);
}
}
}
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 ['', ''];
}
onSelectionChange(): void {
// Selection should never change while we're editing a mention
this.reset();
}
onTextChange(): void {
const range = this.quill.getSelection();
if (!range) return;
const [leftLeafText] = this.getCurrentLeafTextPartitions();
const leftTokenTextMatch = MENTION_REGEX.exec(leftLeafText);
if (!leftTokenTextMatch) {
this.reset();
return;
}
const [, leftTokenText] = leftTokenTextMatch;
let results: Array<ConversationType> = [];
const memberRepository = this.options.memberRepositoryRef.current;
if (memberRepository) {
if (leftTokenText === '') {
results = memberRepository.getMembers(this.options.me);
} else {
const fullMentionText = leftTokenText;
results = memberRepository.search(fullMentionText, this.options.me);
}
}
if (!results.length) {
this.reset();
return;
}
this.results = results;
this.index = 0;
this.render();
}
completeMention(): void {
const range = this.quill.getSelection();
if (range === null) return;
const member = this.results[this.index];
const [leftLeafText] = this.getCurrentLeafTextPartitions();
const leftTokenTextMatch = MENTION_REGEX.exec(leftLeafText);
if (leftTokenTextMatch === null) return;
const [, leftTokenText] = leftTokenTextMatch;
this.insertMention(
member,
range.index - leftTokenText.length - 1,
leftTokenText.length + 1,
true
);
}
insertMention(
member: ConversationType,
index: number,
range: number,
withTrailingSpace = false
): void {
const mention = member;
const delta = new Delta()
.retain(index)
.delete(range)
.insert({ mention });
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.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: memberResults, index: memberResultsIndex } = this;
if (memberResults.length === 0) {
this.options.setMentionPickerElement(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__suggestions"
style={style}
role="listbox"
aria-expanded
aria-activedescendant={`mention-result--${
memberResults.length ? memberResults[memberResultsIndex].name : ''
}`}
tabIndex={0}
>
<div
ref={this.suggestionListRef}
className="module-composition-input__suggestions--scroller"
>
{memberResults.map((member, index) => (
<button
type="button"
key={member.uuid}
id={`mention-result--${member.name}`}
role="option button"
aria-selected={memberResultsIndex === index}
onClick={() => {
this.index = index;
this.completeMention();
}}
className={classNames(
'module-composition-input__suggestions__row',
'module-composition-input__suggestions__row--mention',
memberResultsIndex === index
? 'module-composition-input__suggestions__row--selected'
: null
)}
>
<Avatar
avatarPath={member.avatarPath}
conversationType="direct"
i18n={this.options.i18n}
size={28}
title={member.title}
/>
<div className="module-composition-input__suggestions__title">
{member.title}
</div>
</button>
))}
</div>
</div>
)}
</Popper>,
this.root
);
this.options.setMentionPickerElement(element);
}
}