Bring up picker on backspace into mention
This commit is contained in:
parent
4def45b86a
commit
fe298444fb
11 changed files with 542 additions and 474 deletions
|
@ -8701,7 +8701,8 @@ button.module-image__border-overlay:focus {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding-left: 4px;
|
padding-left: 4px;
|
||||||
padding-right: 4px;
|
padding-right: 4px;
|
||||||
border: 1px solid transparent;
|
height: 22px;
|
||||||
|
line-height: 22px;
|
||||||
|
|
||||||
@include dark-theme {
|
@include dark-theme {
|
||||||
background-color: $color-gray-60;
|
background-color: $color-gray-60;
|
||||||
|
|
|
@ -24,11 +24,13 @@ import {
|
||||||
matchReactEmoji,
|
matchReactEmoji,
|
||||||
} from '../quill/emoji/matchers';
|
} from '../quill/emoji/matchers';
|
||||||
import { matchMention } from '../quill/mentions/matchers';
|
import { matchMention } from '../quill/mentions/matchers';
|
||||||
|
import { MemberRepository } from '../quill/memberRepository';
|
||||||
import {
|
import {
|
||||||
getDeltaToRemoveStaleMentions,
|
getDeltaToRemoveStaleMentions,
|
||||||
getTextAndMentionsFromOps,
|
getTextAndMentionsFromOps,
|
||||||
|
isMentionBlot,
|
||||||
|
getDeltaToRestartMention,
|
||||||
} from '../quill/util';
|
} from '../quill/util';
|
||||||
import { MemberRepository } from '../quill/memberRepository';
|
|
||||||
|
|
||||||
Quill.register('formats/emoji', EmojiBlot);
|
Quill.register('formats/emoji', EmojiBlot);
|
||||||
Quill.register('formats/mention', MentionBlot);
|
Quill.register('formats/mention', MentionBlot);
|
||||||
|
@ -39,24 +41,6 @@ const Block = Quill.import('blots/block');
|
||||||
Block.tagName = 'DIV';
|
Block.tagName = 'DIV';
|
||||||
Quill.register(Block, true);
|
Quill.register(Block, true);
|
||||||
|
|
||||||
declare module 'quill' {
|
|
||||||
interface Quill {
|
|
||||||
// in-code reference missing in @types
|
|
||||||
scrollingContainer: HTMLElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface KeyboardStatic {
|
|
||||||
// in-code reference missing in @types
|
|
||||||
bindings: Record<string | number, Array<unknown>>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module 'react-quill' {
|
|
||||||
// `react-quill` uses a different but compatible version of Delta
|
|
||||||
// tell it to use the type definition from the `quill-delta` library
|
|
||||||
type DeltaStatic = Delta;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HistoryStatic {
|
interface HistoryStatic {
|
||||||
undo(): void;
|
undo(): void;
|
||||||
clear(): void;
|
clear(): void;
|
||||||
|
@ -401,7 +385,7 @@ export const CompositionInput: React.ComponentType<Props> = props => {
|
||||||
|
|
||||||
if (mentionCompletion) {
|
if (mentionCompletion) {
|
||||||
if (mentionCompletion.results.length) {
|
if (mentionCompletion.results.length) {
|
||||||
mentionCompletion.reset();
|
mentionCompletion.clearResults();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -434,6 +418,32 @@ export const CompositionInput: React.ComponentType<Props> = props => {
|
||||||
quill.setSelection(quill.getLength(), 0);
|
quill.setSelection(quill.getLength(), 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onBackspace = () => {
|
||||||
|
const quill = quillRef.current;
|
||||||
|
|
||||||
|
if (quill === undefined) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selection = quill.getSelection();
|
||||||
|
if (!selection || selection.length > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [blotToDelete] = quill.getLeaf(selection.index);
|
||||||
|
if (!isMentionBlot(blotToDelete)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contents = quill.getContents(0, selection.index - 1);
|
||||||
|
const restartDelta = getDeltaToRestartMention(contents.ops);
|
||||||
|
|
||||||
|
quill.updateContents(restartDelta);
|
||||||
|
quill.setSelection(selection.index, 0);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
const onChange = () => {
|
const onChange = () => {
|
||||||
const quill = quillRef.current;
|
const quill = quillRef.current;
|
||||||
|
|
||||||
|
@ -565,6 +575,7 @@ export const CompositionInput: React.ComponentType<Props> = props => {
|
||||||
onEscape: { key: 27, handler: onEscape }, // 27 = Escape
|
onEscape: { key: 27, handler: onEscape }, // 27 = Escape
|
||||||
onCtrlA: { key: 65, ctrlKey: true, handler: onCtrlA }, // 65 = a
|
onCtrlA: { key: 65, ctrlKey: true, handler: onCtrlA }, // 65 = a
|
||||||
onCtrlE: { key: 69, ctrlKey: true, handler: onCtrlE }, // 69 = e
|
onCtrlE: { key: 69, ctrlKey: true, handler: onCtrlE }, // 69 = e
|
||||||
|
onBackspace: { key: 8, handler: onBackspace }, // 8 = Backspace
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
emojiCompletion: {
|
emojiCompletion: {
|
||||||
|
|
|
@ -18,36 +18,7 @@ import {
|
||||||
} from '../../components/emoji/lib';
|
} from '../../components/emoji/lib';
|
||||||
import { Emoji } from '../../components/emoji/Emoji';
|
import { Emoji } from '../../components/emoji/Emoji';
|
||||||
import { EmojiPickDataType } from '../../components/emoji/EmojiPicker';
|
import { EmojiPickDataType } from '../../components/emoji/EmojiPicker';
|
||||||
|
import { getBlotTextPartitions, matchBlotTextPartitions } from '../util';
|
||||||
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 {
|
interface EmojiPickerOptions {
|
||||||
onPickEmoji: (emoji: EmojiPickDataType) => void;
|
onPickEmoji: (emoji: EmojiPickDataType) => void;
|
||||||
|
@ -110,19 +81,9 @@ export class EmojiCompletion {
|
||||||
|
|
||||||
getCurrentLeafTextPartitions(): [string, string] {
|
getCurrentLeafTextPartitions(): [string, string] {
|
||||||
const range = this.quill.getSelection();
|
const range = this.quill.getSelection();
|
||||||
|
const [blot, index] = this.quill.getLeaf(range ? range.index : -1);
|
||||||
|
|
||||||
if (range) {
|
return getBlotTextPartitions(blot, index);
|
||||||
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 {
|
onSelectionChange(): void {
|
||||||
|
@ -135,18 +96,15 @@ export class EmojiCompletion {
|
||||||
|
|
||||||
if (!range) return;
|
if (!range) return;
|
||||||
|
|
||||||
const [leftLeafText, rightLeafText] = this.getCurrentLeafTextPartitions();
|
const [blot, index] = this.quill.getLeaf(range.index);
|
||||||
|
const [leftTokenTextMatch, rightTokenTextMatch] = matchBlotTextPartitions(
|
||||||
const leftTokenTextMatch = /(?<=^|\s):([-+0-9a-z_]*)(:?)$/.exec(
|
blot,
|
||||||
leftLeafText
|
index,
|
||||||
|
/(?<=^|\s):([-+0-9a-z_]*)(:?)$/,
|
||||||
|
/^([-+0-9a-z_]*):/
|
||||||
);
|
);
|
||||||
const rightTokenTextMatch = /^([-+0-9a-z_]*):/.exec(rightLeafText);
|
|
||||||
|
|
||||||
if (!leftTokenTextMatch) {
|
|
||||||
this.reset();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (leftTokenTextMatch) {
|
||||||
const [, leftTokenText, isSelfClosing] = leftTokenTextMatch;
|
const [, leftTokenText, isSelfClosing] = leftTokenTextMatch;
|
||||||
|
|
||||||
if (isSelfClosing) {
|
if (isSelfClosing) {
|
||||||
|
@ -196,15 +154,17 @@ export class EmojiCompletion {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = search(leftTokenText, 10);
|
const showEmojiResults = search(leftTokenText, 10);
|
||||||
|
|
||||||
if (!results.length) {
|
if (showEmojiResults.length > 0) {
|
||||||
this.reset();
|
this.results = showEmojiResults;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.results = results;
|
|
||||||
this.render();
|
this.render();
|
||||||
|
} else if (this.results.length !== 0) {
|
||||||
|
this.reset();
|
||||||
|
}
|
||||||
|
} else if (this.results.length !== 0) {
|
||||||
|
this.reset();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
completeEmoji(): void {
|
completeEmoji(): void {
|
||||||
|
|
|
@ -6,12 +6,10 @@ import Parchment from 'parchment';
|
||||||
import Quill from 'quill';
|
import Quill from 'quill';
|
||||||
import { render } from 'react-dom';
|
import { render } from 'react-dom';
|
||||||
import { Emojify } from '../../components/conversation/Emojify';
|
import { Emojify } from '../../components/conversation/Emojify';
|
||||||
import { ConversationType } from '../../state/ducks/conversations';
|
import { MentionBlotValue } from '../util';
|
||||||
|
|
||||||
const Embed: typeof Parchment.Embed = Quill.import('blots/embed');
|
const Embed: typeof Parchment.Embed = Quill.import('blots/embed');
|
||||||
|
|
||||||
type MentionBlotValue = { uuid?: string; title?: string };
|
|
||||||
|
|
||||||
export class MentionBlot extends Embed {
|
export class MentionBlot extends Embed {
|
||||||
static blotName = 'mention';
|
static blotName = 'mention';
|
||||||
|
|
||||||
|
@ -19,7 +17,7 @@ export class MentionBlot extends Embed {
|
||||||
|
|
||||||
static tagName = 'span';
|
static tagName = 'span';
|
||||||
|
|
||||||
static create(value: ConversationType): Node {
|
static create(value: MentionBlotValue): Node {
|
||||||
const node = super.create(undefined) as HTMLElement;
|
const node = super.create(undefined) as HTMLElement;
|
||||||
|
|
||||||
MentionBlot.buildSpan(value, node);
|
MentionBlot.buildSpan(value, node);
|
||||||
|
@ -29,15 +27,21 @@ export class MentionBlot extends Embed {
|
||||||
|
|
||||||
static value(node: HTMLElement): MentionBlotValue {
|
static value(node: HTMLElement): MentionBlotValue {
|
||||||
const { uuid, title } = node.dataset;
|
const { uuid, title } = node.dataset;
|
||||||
|
if (uuid === undefined || title === undefined) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to make MentionBlot with uuid: ${uuid} and title: ${title}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
uuid,
|
uuid,
|
||||||
title,
|
title,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static buildSpan(member: ConversationType, node: HTMLElement): void {
|
static buildSpan(mention: MentionBlotValue, node: HTMLElement): void {
|
||||||
node.setAttribute('data-uuid', member.uuid || '');
|
node.setAttribute('data-uuid', mention.uuid || '');
|
||||||
node.setAttribute('data-title', member.title || '');
|
node.setAttribute('data-title', mention.title || '');
|
||||||
|
|
||||||
const mentionSpan = document.createElement('span');
|
const mentionSpan = document.createElement('span');
|
||||||
|
|
||||||
|
@ -45,7 +49,7 @@ export class MentionBlot extends Embed {
|
||||||
<span className="module-composition-input__at-mention">
|
<span className="module-composition-input__at-mention">
|
||||||
<bdi>
|
<bdi>
|
||||||
@
|
@
|
||||||
<Emojify text={member.title} />
|
<Emojify text={mention.title} />
|
||||||
</bdi>
|
</bdi>
|
||||||
</span>,
|
</span>,
|
||||||
mentionSpan
|
mentionSpan
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
// Copyright 2020 Signal Messenger, LLC
|
// Copyright 2020 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import _ from 'lodash';
|
||||||
import Quill from 'quill';
|
import Quill from 'quill';
|
||||||
import Delta from 'quill-delta';
|
import Delta from 'quill-delta';
|
||||||
import React, { RefObject } from 'react';
|
import React, { RefObject } from 'react';
|
||||||
|
@ -11,8 +12,8 @@ import { createPortal } from 'react-dom';
|
||||||
import { ConversationType } from '../../state/ducks/conversations';
|
import { ConversationType } from '../../state/ducks/conversations';
|
||||||
import { Avatar } from '../../components/Avatar';
|
import { Avatar } from '../../components/Avatar';
|
||||||
import { LocalizerType } from '../../types/Util';
|
import { LocalizerType } from '../../types/Util';
|
||||||
|
|
||||||
import { MemberRepository } from '../memberRepository';
|
import { MemberRepository } from '../memberRepository';
|
||||||
|
import { matchBlotTextPartitions } from '../util';
|
||||||
|
|
||||||
export interface MentionCompletionOptions {
|
export interface MentionCompletionOptions {
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
|
@ -53,7 +54,7 @@ export class MentionCompletion {
|
||||||
|
|
||||||
const clearResults = () => {
|
const clearResults = () => {
|
||||||
if (this.results.length) {
|
if (this.results.length) {
|
||||||
this.reset();
|
this.clearResults();
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
@ -73,7 +74,7 @@ export class MentionCompletion {
|
||||||
this.quill.keyboard.addBinding({ key: 39 }, clearResults); // Right Arrow
|
this.quill.keyboard.addBinding({ key: 39 }, clearResults); // Right Arrow
|
||||||
this.quill.keyboard.addBinding({ key: 40 }, changeIndex(1)); // Down Arrow
|
this.quill.keyboard.addBinding({ key: 40 }, changeIndex(1)); // Down Arrow
|
||||||
|
|
||||||
this.quill.on('text-change', this.onTextChange.bind(this));
|
this.quill.on('text-change', _.debounce(this.onTextChange.bind(this), 0));
|
||||||
this.quill.on('selection-change', this.onSelectionChange.bind(this));
|
this.quill.on('selection-change', this.onSelectionChange.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -95,42 +96,24 @@ export class MentionCompletion {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getCurrentLeafTextPartitions(): [string, string] {
|
onSelectionChange(): void {
|
||||||
|
// Selection should never change while we're editing a mention
|
||||||
|
this.clearResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
possiblyShowMemberResults(): Array<ConversationType> {
|
||||||
const range = this.quill.getSelection();
|
const range = this.quill.getSelection();
|
||||||
|
|
||||||
if (range) {
|
if (range) {
|
||||||
const [blot, blotIndex] = this.quill.getLeaf(range.index);
|
const [blot, index] = this.quill.getLeaf(range.index);
|
||||||
|
|
||||||
if (blot !== undefined && blot.text !== undefined) {
|
const [leftTokenTextMatch] = matchBlotTextPartitions(
|
||||||
const leftLeafText = blot.text.substr(0, blotIndex);
|
blot,
|
||||||
const rightLeafText = blot.text.substr(blotIndex);
|
index,
|
||||||
|
MENTION_REGEX
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (leftTokenTextMatch) {
|
||||||
const [, leftTokenText] = leftTokenTextMatch;
|
const [, leftTokenText] = leftTokenTextMatch;
|
||||||
|
|
||||||
let results: Array<ConversationType> = [];
|
let results: Array<ConversationType> = [];
|
||||||
|
@ -146,28 +129,43 @@ export class MentionCompletion {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!results.length) {
|
return results;
|
||||||
this.reset();
|
}
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.results = results;
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
onTextChange(): void {
|
||||||
|
const showMemberResults = this.possiblyShowMemberResults();
|
||||||
|
|
||||||
|
if (showMemberResults.length > 0) {
|
||||||
|
this.results = showMemberResults;
|
||||||
this.index = 0;
|
this.index = 0;
|
||||||
this.render();
|
this.render();
|
||||||
|
} else if (this.results.length !== 0) {
|
||||||
|
this.clearResults();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
completeMention(): void {
|
completeMention(resultIndexArg?: number): void {
|
||||||
|
const resultIndex = resultIndexArg || this.index;
|
||||||
|
|
||||||
const range = this.quill.getSelection();
|
const range = this.quill.getSelection();
|
||||||
|
|
||||||
if (range === null) return;
|
if (range === null) return;
|
||||||
|
|
||||||
const member = this.results[this.index];
|
const member = this.results[resultIndex];
|
||||||
const [leftLeafText] = this.getCurrentLeafTextPartitions();
|
|
||||||
|
|
||||||
const leftTokenTextMatch = MENTION_REGEX.exec(leftLeafText);
|
const [blot, index] = this.quill.getLeaf(range.index);
|
||||||
|
|
||||||
if (leftTokenTextMatch === null) return;
|
const [leftTokenTextMatch] = matchBlotTextPartitions(
|
||||||
|
blot,
|
||||||
|
index,
|
||||||
|
MENTION_REGEX
|
||||||
|
);
|
||||||
|
|
||||||
|
if (leftTokenTextMatch) {
|
||||||
const [, leftTokenText] = leftTokenTextMatch;
|
const [, leftTokenText] = leftTokenTextMatch;
|
||||||
|
|
||||||
this.insertMention(
|
this.insertMention(
|
||||||
|
@ -177,14 +175,14 @@ export class MentionCompletion {
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
insertMention(
|
insertMention(
|
||||||
member: ConversationType,
|
mention: ConversationType,
|
||||||
index: number,
|
index: number,
|
||||||
range: number,
|
range: number,
|
||||||
withTrailingSpace = false
|
withTrailingSpace = false
|
||||||
): void {
|
): void {
|
||||||
const mention = member;
|
|
||||||
const delta = new Delta()
|
const delta = new Delta()
|
||||||
.retain(index)
|
.retain(index)
|
||||||
.delete(range)
|
.delete(range)
|
||||||
|
@ -198,17 +196,15 @@ export class MentionCompletion {
|
||||||
this.quill.setSelection(index + 1, 0, 'user');
|
this.quill.setSelection(index + 1, 0, 'user');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.reset();
|
this.clearResults();
|
||||||
}
|
}
|
||||||
|
|
||||||
reset(): void {
|
clearResults(): void {
|
||||||
if (this.results.length) {
|
|
||||||
this.results = [];
|
this.results = [];
|
||||||
this.index = 0;
|
this.index = 0;
|
||||||
|
|
||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
onUnmount(): void {
|
onUnmount(): void {
|
||||||
document.body.removeChild(this.root);
|
document.body.removeChild(this.root);
|
||||||
|
@ -266,8 +262,7 @@ export class MentionCompletion {
|
||||||
role="option button"
|
role="option button"
|
||||||
aria-selected={memberResultsIndex === index}
|
aria-selected={memberResultsIndex === index}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
this.index = index;
|
this.completeMention(index);
|
||||||
this.completeMention();
|
|
||||||
}}
|
}}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'module-composition-input__suggestions__row',
|
'module-composition-input__suggestions__row',
|
||||||
|
|
55
ts/quill/types.d.ts
vendored
Normal file
55
ts/quill/types.d.ts
vendored
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
// Copyright 2019-2020 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import UpdatedDelta from 'quill-delta';
|
||||||
|
|
||||||
|
declare module 'react-quill' {
|
||||||
|
// `react-quill` uses a different but compatible version of Delta
|
||||||
|
// tell it to use the type definition from the `quill-delta` library
|
||||||
|
type DeltaStatic = UpdatedDelta;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdatedTextChangeHandler = (
|
||||||
|
delta: UpdatedDelta,
|
||||||
|
oldContents: UpdatedDelta,
|
||||||
|
source: Sources
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
interface LeafBlot {
|
||||||
|
text?: string;
|
||||||
|
value(): any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Quill {
|
||||||
|
updateContents(delta: UpdatedDelta, source?: Sources): UpdatedDelta;
|
||||||
|
getContents(index?: number, length?: number): UpdatedDelta;
|
||||||
|
getLeaf(index: number): [LeafBlot, number];
|
||||||
|
// in-code reference missing in @types
|
||||||
|
scrollingContainer: HTMLElement;
|
||||||
|
|
||||||
|
on(
|
||||||
|
eventName: 'text-change',
|
||||||
|
handler: UpdatedTextChangeHandler
|
||||||
|
): EventEmitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KeyboardStatic {
|
||||||
|
addBinding(
|
||||||
|
key: UpdatedKey,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
callback: (range: RangeStatic, context: any) => void
|
||||||
|
): void;
|
||||||
|
// in-code reference missing in @types
|
||||||
|
bindings: Record<string | number, Array<unknown>>;
|
||||||
|
}
|
||||||
|
}
|
112
ts/quill/util.ts
112
ts/quill/util.ts
|
@ -2,44 +2,77 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import Delta from 'quill-delta';
|
import Delta from 'quill-delta';
|
||||||
import { DeltaOperation } from 'quill';
|
import { LeafBlot } from 'quill';
|
||||||
|
import Op from 'quill-delta/dist/Op';
|
||||||
|
|
||||||
import { BodyRangeType } from '../types/Util';
|
import { BodyRangeType } from '../types/Util';
|
||||||
|
import { MentionBlot } from './mentions/blot';
|
||||||
|
|
||||||
|
export interface MentionBlotValue {
|
||||||
|
uuid: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isMentionBlot = (blot: LeafBlot): blot is MentionBlot =>
|
||||||
|
blot.value() && blot.value().mention;
|
||||||
|
|
||||||
|
export type RetainOp = Op & { retain: number };
|
||||||
|
export type InsertOp<K extends string, T> = Op & { insert: { [V in K]: T } };
|
||||||
|
|
||||||
|
export type InsertMentionOp = InsertOp<'mention', MentionBlotValue>;
|
||||||
|
export type InsertEmojiOp = InsertOp<'emoji', string>;
|
||||||
|
|
||||||
|
export const isRetainOp = (op?: Op): op is RetainOp =>
|
||||||
|
op !== undefined && op.retain !== undefined;
|
||||||
|
|
||||||
|
export const isSpecificInsertOp = (op: Op, type: string): boolean => {
|
||||||
|
return (
|
||||||
|
op.insert !== undefined &&
|
||||||
|
typeof op.insert === 'object' &&
|
||||||
|
Object.hasOwnProperty.call(op.insert, type)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isInsertEmojiOp = (op: Op): op is InsertEmojiOp =>
|
||||||
|
isSpecificInsertOp(op, 'emoji');
|
||||||
|
|
||||||
|
export const isInsertMentionOp = (op: Op): op is InsertMentionOp =>
|
||||||
|
isSpecificInsertOp(op, 'mention');
|
||||||
|
|
||||||
export const getTextAndMentionsFromOps = (
|
export const getTextAndMentionsFromOps = (
|
||||||
ops: Array<DeltaOperation>
|
ops: Array<Op>
|
||||||
): [string, Array<BodyRangeType>] => {
|
): [string, Array<BodyRangeType>] => {
|
||||||
const mentions: Array<BodyRangeType> = [];
|
const mentions: Array<BodyRangeType> = [];
|
||||||
|
|
||||||
const text = ops.reduce((acc, { insert }, index) => {
|
const text = ops.reduce((acc, op, index) => {
|
||||||
if (typeof insert === 'string') {
|
if (typeof op.insert === 'string') {
|
||||||
let textToAdd;
|
let textToAdd;
|
||||||
switch (index) {
|
switch (index) {
|
||||||
case 0: {
|
case 0: {
|
||||||
textToAdd = insert.trimLeft();
|
textToAdd = op.insert.trimLeft();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case ops.length - 1: {
|
case ops.length - 1: {
|
||||||
textToAdd = insert.trimRight();
|
textToAdd = op.insert.trimRight();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
textToAdd = insert;
|
textToAdd = op.insert;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return acc + textToAdd;
|
return acc + textToAdd;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (insert.emoji) {
|
if (isInsertEmojiOp(op)) {
|
||||||
return acc + insert.emoji;
|
return acc + op.insert.emoji;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (insert.mention) {
|
if (isInsertMentionOp(op)) {
|
||||||
mentions.push({
|
mentions.push({
|
||||||
length: 1, // The length of `\uFFFC`
|
length: 1, // The length of `\uFFFC`
|
||||||
mentionUuid: insert.mention.uuid,
|
mentionUuid: op.insert.mention.uuid,
|
||||||
replacementText: insert.mention.title,
|
replacementText: op.insert.mention.title,
|
||||||
start: acc.length,
|
start: acc.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -52,13 +85,62 @@ export const getTextAndMentionsFromOps = (
|
||||||
return [text, mentions];
|
return [text, mentions];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getBlotTextPartitions = (
|
||||||
|
blot: LeafBlot,
|
||||||
|
index: number
|
||||||
|
): [string, string] => {
|
||||||
|
if (blot !== undefined && blot.text !== undefined) {
|
||||||
|
const leftLeafText = blot.text.substr(0, index);
|
||||||
|
const rightLeafText = blot.text.substr(index);
|
||||||
|
|
||||||
|
return [leftLeafText, rightLeafText];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['', ''];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const matchBlotTextPartitions = (
|
||||||
|
blot: LeafBlot,
|
||||||
|
index: number,
|
||||||
|
leftRegExp: RegExp,
|
||||||
|
rightRegExp?: RegExp
|
||||||
|
): Array<RegExpMatchArray | null> => {
|
||||||
|
const [leftText, rightText] = getBlotTextPartitions(blot, index);
|
||||||
|
|
||||||
|
const leftMatch = leftRegExp.exec(leftText);
|
||||||
|
let rightMatch = null;
|
||||||
|
|
||||||
|
if (rightRegExp) {
|
||||||
|
rightMatch = rightRegExp.exec(rightText);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [leftMatch, rightMatch];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDeltaToRestartMention = (ops: Array<Op>): Delta => {
|
||||||
|
const changes = ops.reduce((acc, op): Array<Op> => {
|
||||||
|
if (op.insert && typeof op.insert === 'string') {
|
||||||
|
acc.push({ retain: op.insert.length });
|
||||||
|
} else {
|
||||||
|
acc.push({ retain: 1 });
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, Array<Op>());
|
||||||
|
changes.push({ delete: 1 });
|
||||||
|
changes.push({ insert: '@' });
|
||||||
|
return new Delta(changes);
|
||||||
|
};
|
||||||
|
|
||||||
export const getDeltaToRemoveStaleMentions = (
|
export const getDeltaToRemoveStaleMentions = (
|
||||||
ops: Array<DeltaOperation>,
|
ops: Array<Op>,
|
||||||
memberUuids: Array<string>
|
memberUuids: Array<string>
|
||||||
): Delta => {
|
): Delta => {
|
||||||
const newOps = ops.reduce((memo, op) => {
|
const newOps = ops.reduce((memo, op) => {
|
||||||
if (op.insert) {
|
if (op.insert) {
|
||||||
if (op.insert.mention && !memberUuids.includes(op.insert.mention.uuid)) {
|
if (
|
||||||
|
isInsertMentionOp(op) &&
|
||||||
|
!memberUuids.includes(op.insert.mention.uuid)
|
||||||
|
) {
|
||||||
const deleteOp = { delete: 1 };
|
const deleteOp = { delete: 1 };
|
||||||
const textOp = { insert: `@${op.insert.mention.title}` };
|
const textOp = { insert: `@${op.insert.mention.title}` };
|
||||||
return [...memo, deleteOp, textOp];
|
return [...memo, deleteOp, textOp];
|
||||||
|
@ -74,7 +156,7 @@ export const getDeltaToRemoveStaleMentions = (
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...memo, op];
|
return [...memo, op];
|
||||||
}, Array<DeltaOperation>());
|
}, Array<Op>());
|
||||||
|
|
||||||
return new Delta(newOps);
|
return new Delta(newOps);
|
||||||
};
|
};
|
||||||
|
|
|
@ -113,9 +113,8 @@ describe('emojiCompletion', () => {
|
||||||
emojiCompletion.onTextChange();
|
emojiCompletion.onTextChange();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resets the completion', () => {
|
it('does not show results', () => {
|
||||||
assert.equal(emojiCompletion.results.length, 0);
|
assert.equal(emojiCompletion.results.length, 0);
|
||||||
assert.equal(emojiCompletion.index, 0);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -134,9 +133,8 @@ describe('emojiCompletion', () => {
|
||||||
emojiCompletion.onTextChange();
|
emojiCompletion.onTextChange();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resets the completion', () => {
|
it('does not show results', () => {
|
||||||
assert.equal(emojiCompletion.results.length, 0);
|
assert.equal(emojiCompletion.results.length, 0);
|
||||||
assert.equal(emojiCompletion.index, 0);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -155,9 +153,8 @@ describe('emojiCompletion', () => {
|
||||||
emojiCompletion.onTextChange();
|
emojiCompletion.onTextChange();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resets the completion', () => {
|
it('does not show results', () => {
|
||||||
assert.equal(emojiCompletion.results.length, 0);
|
assert.equal(emojiCompletion.results.length, 0);
|
||||||
assert.equal(emojiCompletion.index, 0);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -176,9 +173,8 @@ describe('emojiCompletion', () => {
|
||||||
emojiCompletion.onTextChange();
|
emojiCompletion.onTextChange();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resets the completion', () => {
|
it('does not show results', () => {
|
||||||
assert.equal(emojiCompletion.results.length, 0);
|
assert.equal(emojiCompletion.results.length, 0);
|
||||||
assert.equal(emojiCompletion.index, 0);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -231,9 +227,8 @@ describe('emojiCompletion', () => {
|
||||||
assert.equal(range, 7);
|
assert.equal(range, 7);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resets the completion', () => {
|
it('does not show results', () => {
|
||||||
assert.equal(emojiCompletion.results.length, 0);
|
assert.equal(emojiCompletion.results.length, 0);
|
||||||
assert.equal(emojiCompletion.index, 0);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -261,9 +256,8 @@ describe('emojiCompletion', () => {
|
||||||
assert.equal(range, 7);
|
assert.equal(range, 7);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resets the completion', () => {
|
it('does not show results', () => {
|
||||||
assert.equal(emojiCompletion.results.length, 0);
|
assert.equal(emojiCompletion.results.length, 0);
|
||||||
assert.equal(emojiCompletion.index, 0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sets the quill selection to the right cursor position', () => {
|
it('sets the quill selection to the right cursor position', () => {
|
||||||
|
@ -286,9 +280,8 @@ describe('emojiCompletion', () => {
|
||||||
emojiCompletion.onTextChange();
|
emojiCompletion.onTextChange();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resets the completion', () => {
|
it('does not show results', () => {
|
||||||
assert.equal(emojiCompletion.results.length, 0);
|
assert.equal(emojiCompletion.results.length, 0);
|
||||||
assert.equal(emojiCompletion.index, 0);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -323,9 +316,8 @@ describe('emojiCompletion', () => {
|
||||||
assert.equal(range, validEmoji.length);
|
assert.equal(range, validEmoji.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resets the completion', () => {
|
it('does not show results', () => {
|
||||||
assert.equal(emojiCompletion.results.length, 0);
|
assert.equal(emojiCompletion.results.length, 0);
|
||||||
assert.equal(emojiCompletion.index, 0);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -339,9 +331,8 @@ describe('emojiCompletion', () => {
|
||||||
emojiCompletion.onTextChange();
|
emojiCompletion.onTextChange();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resets the completion', () => {
|
it('does not show results', () => {
|
||||||
assert.equal(emojiCompletion.results.length, 0);
|
assert.equal(emojiCompletion.results.length, 0);
|
||||||
assert.equal(emojiCompletion.index, 0);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
// Copyright 2020 Signal Messenger, LLC
|
// Copyright 2020 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { expect } from 'chai';
|
import { assert } from 'chai';
|
||||||
import sinon from 'sinon';
|
import Delta from 'quill-delta';
|
||||||
|
import sinon, { SinonStub } from 'sinon';
|
||||||
|
import Quill, { KeyboardStatic } from 'quill';
|
||||||
|
|
||||||
import { MutableRefObject } from 'react';
|
import { MutableRefObject } from 'react';
|
||||||
import {
|
import {
|
||||||
|
@ -12,9 +14,6 @@ import {
|
||||||
import { ConversationType } from '../../../state/ducks/conversations';
|
import { ConversationType } from '../../../state/ducks/conversations';
|
||||||
import { MemberRepository } from '../../../quill/memberRepository';
|
import { MemberRepository } from '../../../quill/memberRepository';
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const globalAsAny = global as any;
|
|
||||||
|
|
||||||
const me: ConversationType = {
|
const me: ConversationType = {
|
||||||
id: '666777',
|
id: '666777',
|
||||||
uuid: 'pqrstuv',
|
uuid: 'pqrstuv',
|
||||||
|
@ -50,30 +49,37 @@ const members: Array<ConversationType> = [
|
||||||
me,
|
me,
|
||||||
];
|
];
|
||||||
|
|
||||||
describe('mentionCompletion', () => {
|
declare global {
|
||||||
let mentionCompletion: MentionCompletion;
|
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||||
|
namespace NodeJS {
|
||||||
|
interface Global {
|
||||||
|
document: {
|
||||||
|
body: {
|
||||||
|
appendChild: unknown;
|
||||||
|
};
|
||||||
|
createElement: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MentionCompletion', () => {
|
||||||
const mockSetMentionPickerElement = sinon.spy();
|
const mockSetMentionPickerElement = sinon.spy();
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
let mockQuill: any;
|
let mockQuill: Omit<
|
||||||
|
Partial<{ [K in keyof Quill]: SinonStub }>,
|
||||||
|
'keyboard'
|
||||||
|
> & {
|
||||||
|
keyboard: Partial<{ [K in keyof KeyboardStatic]: SinonStub }>;
|
||||||
|
};
|
||||||
|
let mentionCompletion: MentionCompletion;
|
||||||
|
|
||||||
beforeEach(function beforeEach() {
|
beforeEach(function beforeEach() {
|
||||||
this.oldDocument = globalAsAny.document;
|
global.document = {
|
||||||
globalAsAny.document = {
|
|
||||||
body: {
|
body: {
|
||||||
appendChild: () => null,
|
appendChild: sinon.spy(),
|
||||||
},
|
},
|
||||||
createElement: () => null,
|
createElement: sinon.spy(),
|
||||||
};
|
|
||||||
|
|
||||||
mockQuill = {
|
|
||||||
getLeaf: sinon.stub(),
|
|
||||||
getSelection: sinon.stub(),
|
|
||||||
keyboard: {
|
|
||||||
addBinding: sinon.stub(),
|
|
||||||
},
|
|
||||||
on: sinon.stub(),
|
|
||||||
setSelection: sinon.stub(),
|
|
||||||
updateContents: sinon.stub(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const memberRepositoryRef: MutableRefObject<MemberRepository> = {
|
const memberRepositoryRef: MutableRefObject<MemberRepository> = {
|
||||||
|
@ -84,271 +90,177 @@ describe('mentionCompletion', () => {
|
||||||
i18n: sinon.stub(),
|
i18n: sinon.stub(),
|
||||||
me,
|
me,
|
||||||
memberRepositoryRef,
|
memberRepositoryRef,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
setMentionPickerElement: mockSetMentionPickerElement,
|
||||||
setMentionPickerElement: mockSetMentionPickerElement as any,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
mockQuill = {
|
||||||
mentionCompletion = new MentionCompletion(mockQuill as any, options);
|
getContents: sinon.stub(),
|
||||||
|
getLeaf: sinon.stub(),
|
||||||
// Stub rendering to avoid missing DOM until we bring in Enzyme
|
getSelection: sinon.stub(),
|
||||||
mentionCompletion.render = sinon.stub();
|
keyboard: { addBinding: sinon.stub() },
|
||||||
});
|
on: sinon.stub(),
|
||||||
|
setSelection: sinon.stub(),
|
||||||
afterEach(function afterEach() {
|
updateContents: sinon.stub(),
|
||||||
mockSetMentionPickerElement.resetHistory();
|
|
||||||
(mentionCompletion.render as sinon.SinonStub).resetHistory();
|
|
||||||
|
|
||||||
if (this.oldDocument === undefined) {
|
|
||||||
delete globalAsAny.document;
|
|
||||||
} else {
|
|
||||||
globalAsAny.document = this.oldDocument;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getCurrentLeafTextPartitions', () => {
|
|
||||||
it('returns left and right text', () => {
|
|
||||||
mockQuill.getSelection.returns({ index: 0, length: 0 });
|
|
||||||
const blot = {
|
|
||||||
text: '@shia',
|
|
||||||
};
|
};
|
||||||
mockQuill.getLeaf.returns([blot, 3]);
|
|
||||||
const [
|
mentionCompletion = new MentionCompletion(
|
||||||
leftLeafText,
|
(mockQuill as unknown) as Quill,
|
||||||
rightLeafText,
|
options
|
||||||
] = mentionCompletion.getCurrentLeafTextPartitions();
|
);
|
||||||
expect(leftLeafText).to.equal('@sh');
|
|
||||||
expect(rightLeafText).to.equal('ia');
|
sinon.stub(mentionCompletion, 'render');
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('onTextChange', () => {
|
describe('onTextChange', () => {
|
||||||
let insertMentionStub: sinon.SinonStub<
|
let possiblyShowMemberResultsStub: sinon.SinonStub<[], ConversationType[]>;
|
||||||
[ConversationType, number, number, (boolean | undefined)?],
|
|
||||||
void
|
|
||||||
>;
|
|
||||||
|
|
||||||
beforeEach(function beforeEach() {
|
beforeEach(() => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
possiblyShowMemberResultsStub = sinon.stub(
|
||||||
mentionCompletion.results = [{ title: 'Mahershala Ali' } as any];
|
mentionCompletion,
|
||||||
mentionCompletion.index = 5;
|
'possiblyShowMemberResults'
|
||||||
insertMentionStub = sinon
|
|
||||||
.stub(mentionCompletion, 'insertMention')
|
|
||||||
.callThrough();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(function afterEach() {
|
|
||||||
insertMentionStub.restore();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('given a mention is not starting (no @)', () => {
|
|
||||||
beforeEach(function beforeEach() {
|
|
||||||
mockQuill.getSelection.returns({
|
|
||||||
index: 3,
|
|
||||||
length: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const blot = {
|
|
||||||
text: 'smi',
|
|
||||||
};
|
|
||||||
mockQuill.getLeaf.returns([blot, 3]);
|
|
||||||
|
|
||||||
mentionCompletion.onTextChange();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resets the completion', () => {
|
|
||||||
expect(mentionCompletion.results).to.have.lengthOf(0);
|
|
||||||
expect(mentionCompletion.index).to.equal(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('given an mention is starting but does not match a member', () => {
|
|
||||||
beforeEach(function beforeEach() {
|
|
||||||
mockQuill.getSelection.returns({
|
|
||||||
index: 4,
|
|
||||||
length: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const blot = {
|
|
||||||
text: '@nope',
|
|
||||||
};
|
|
||||||
mockQuill.getLeaf.returns([blot, 5]);
|
|
||||||
|
|
||||||
mentionCompletion.onTextChange();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resets the completion', () => {
|
|
||||||
expect(mentionCompletion.results).to.have.lengthOf(0);
|
|
||||||
expect(mentionCompletion.index).to.equal(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('given an mention is started without text', () => {
|
|
||||||
beforeEach(function beforeEach() {
|
|
||||||
mockQuill.getSelection.returns({
|
|
||||||
index: 4,
|
|
||||||
length: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const blot = {
|
|
||||||
text: '@',
|
|
||||||
};
|
|
||||||
mockQuill.getLeaf.returns([blot, 2]);
|
|
||||||
|
|
||||||
mentionCompletion.onTextChange();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stores all results, omitting `me`, and renders', () => {
|
|
||||||
expect(mentionCompletion.results).to.have.lengthOf(2);
|
|
||||||
expect((mentionCompletion.render as sinon.SinonStub).called).to.equal(
|
|
||||||
true
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('given a change that should show members', () => {
|
||||||
|
const newContents = new Delta().insert('@a');
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockQuill.getContents?.returns(newContents);
|
||||||
|
|
||||||
|
possiblyShowMemberResultsStub.returns(members);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('given a mention is started and matches members', () => {
|
it('shows member results', () => {
|
||||||
beforeEach(function beforeEach() {
|
|
||||||
mockQuill.getSelection.returns({
|
|
||||||
index: 4,
|
|
||||||
length: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const blot = {
|
|
||||||
text: '@sh',
|
|
||||||
};
|
|
||||||
mockQuill.getLeaf.returns([blot, 3]);
|
|
||||||
|
|
||||||
mentionCompletion.onTextChange();
|
mentionCompletion.onTextChange();
|
||||||
|
|
||||||
|
assert.equal(mentionCompletion.results, members);
|
||||||
|
assert.equal(mentionCompletion.index, 0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('stores the results, omitting `me`, and renders', () => {
|
describe('given a change that should clear results', () => {
|
||||||
expect(mentionCompletion.results).to.have.lengthOf(1);
|
const newContents = new Delta().insert('foo ');
|
||||||
expect((mentionCompletion.render as sinon.SinonStub).called).to.equal(
|
|
||||||
true
|
let clearResultsStub: SinonStub<[], void>;
|
||||||
);
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mentionCompletion.results = members;
|
||||||
|
|
||||||
|
mockQuill.getContents?.returns(newContents);
|
||||||
|
|
||||||
|
possiblyShowMemberResultsStub.returns([]);
|
||||||
|
|
||||||
|
clearResultsStub = sinon.stub(mentionCompletion, 'clearResults');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears member results', () => {
|
||||||
|
mentionCompletion.onTextChange();
|
||||||
|
|
||||||
|
assert.equal(clearResultsStub.called, true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('completeMention', () => {
|
describe('completeMention', () => {
|
||||||
let insertMentionStub: sinon.SinonStub<
|
describe('given a completable mention', () => {
|
||||||
|
let insertMentionStub: SinonStub<
|
||||||
[ConversationType, number, number, (boolean | undefined)?],
|
[ConversationType, number, number, (boolean | undefined)?],
|
||||||
void
|
void
|
||||||
>;
|
>;
|
||||||
|
|
||||||
beforeEach(function beforeEach() {
|
beforeEach(() => {
|
||||||
mentionCompletion.results = [
|
mentionCompletion.results = members;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
mockQuill.getSelection?.returns({ index: 5 });
|
||||||
{ title: 'Mahershala Ali' } as any,
|
mockQuill.getLeaf?.returns([{ text: '@shia' }, 5]);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
{ title: 'Shia LaBeouf' } as any,
|
|
||||||
];
|
|
||||||
mentionCompletion.index = 1;
|
|
||||||
insertMentionStub = sinon.stub(mentionCompletion, 'insertMention');
|
insertMentionStub = sinon.stub(mentionCompletion, 'insertMention');
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('given a valid mention', () => {
|
|
||||||
const text = '@sh';
|
|
||||||
const index = text.length;
|
|
||||||
|
|
||||||
beforeEach(function beforeEach() {
|
|
||||||
mockQuill.getSelection.returns({
|
|
||||||
index,
|
|
||||||
length: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const blot = {
|
|
||||||
text,
|
|
||||||
};
|
|
||||||
mockQuill.getLeaf.returns([blot, index]);
|
|
||||||
|
|
||||||
mentionCompletion.completeMention();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('inserts the currently selected mention at the current cursor position', () => {
|
it('inserts the currently selected mention at the current cursor position', () => {
|
||||||
const [mention, insertIndex, range] = insertMentionStub.args[0];
|
mentionCompletion.completeMention(1);
|
||||||
|
|
||||||
expect(mention.title).to.equal('Shia LaBeouf');
|
const [
|
||||||
expect(insertIndex).to.equal(0);
|
member,
|
||||||
expect(range).to.equal(text.length);
|
distanceFromCursor,
|
||||||
|
adjustCursorAfterBy,
|
||||||
|
withTrailingSpace,
|
||||||
|
] = insertMentionStub.getCall(0).args;
|
||||||
|
|
||||||
|
assert.equal(member, members[1]);
|
||||||
|
assert.equal(distanceFromCursor, 0);
|
||||||
|
assert.equal(adjustCursorAfterBy, 5);
|
||||||
|
assert.equal(withTrailingSpace, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can infer the member to complete with', () => {
|
||||||
|
mentionCompletion.index = 1;
|
||||||
|
mentionCompletion.completeMention();
|
||||||
|
|
||||||
|
const [
|
||||||
|
member,
|
||||||
|
distanceFromCursor,
|
||||||
|
adjustCursorAfterBy,
|
||||||
|
withTrailingSpace,
|
||||||
|
] = insertMentionStub.getCall(0).args;
|
||||||
|
|
||||||
|
assert.equal(member, members[1]);
|
||||||
|
assert.equal(distanceFromCursor, 0);
|
||||||
|
assert.equal(adjustCursorAfterBy, 5);
|
||||||
|
assert.equal(withTrailingSpace, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('from the middle of a string', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockQuill.getSelection?.returns({ index: 9 });
|
||||||
|
mockQuill.getLeaf?.returns([{ text: 'foo @shia bar' }, 9]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inserts correctly', () => {
|
||||||
|
mentionCompletion.completeMention(1);
|
||||||
|
|
||||||
|
const [
|
||||||
|
member,
|
||||||
|
distanceFromCursor,
|
||||||
|
adjustCursorAfterBy,
|
||||||
|
withTrailingSpace,
|
||||||
|
] = insertMentionStub.getCall(0).args;
|
||||||
|
|
||||||
|
assert.equal(member, members[1]);
|
||||||
|
assert.equal(distanceFromCursor, 4);
|
||||||
|
assert.equal(adjustCursorAfterBy, 5);
|
||||||
|
assert.equal(withTrailingSpace, true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('given a valid mention starting with a capital letter', () => {
|
describe('given a completable mention starting with a capital letter', () => {
|
||||||
const text = '@Sh';
|
const text = '@Sh';
|
||||||
const index = text.length;
|
const index = text.length;
|
||||||
|
|
||||||
beforeEach(function beforeEach() {
|
beforeEach(function beforeEach() {
|
||||||
mockQuill.getSelection.returns({
|
mockQuill.getSelection?.returns({ index });
|
||||||
index,
|
|
||||||
length: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const blot = {
|
const blot = {
|
||||||
text,
|
text,
|
||||||
};
|
};
|
||||||
mockQuill.getLeaf.returns([blot, index]);
|
mockQuill.getLeaf?.returns([blot, index]);
|
||||||
|
|
||||||
mentionCompletion.completeMention();
|
mentionCompletion.completeMention(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('inserts the currently selected mention at the current cursor position', () => {
|
it('inserts the currently selected mention at the current cursor position', () => {
|
||||||
const [mention, insertIndex, range] = insertMentionStub.args[0];
|
const [
|
||||||
|
member,
|
||||||
|
distanceFromCursor,
|
||||||
|
adjustCursorAfterBy,
|
||||||
|
withTrailingSpace,
|
||||||
|
] = insertMentionStub.getCall(0).args;
|
||||||
|
|
||||||
expect(mention.title).to.equal('Shia LaBeouf');
|
assert.equal(member, members[1]);
|
||||||
expect(insertIndex).to.equal(0);
|
assert.equal(distanceFromCursor, 0);
|
||||||
expect(range).to.equal(text.length);
|
assert.equal(adjustCursorAfterBy, 3);
|
||||||
|
assert.equal(withTrailingSpace, true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('given a valid mention inside a string', () => {
|
|
||||||
const text = 'foo @shia bar';
|
|
||||||
const index = 9;
|
|
||||||
|
|
||||||
beforeEach(function beforeEach() {
|
|
||||||
mockQuill.getSelection.returns({
|
|
||||||
index,
|
|
||||||
length: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const blot = {
|
|
||||||
text,
|
|
||||||
};
|
|
||||||
mockQuill.getLeaf.returns([blot, index]);
|
|
||||||
|
|
||||||
mentionCompletion.completeMention();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('inserts the currently selected mention at the current cursor position, replacing all mention text', () => {
|
|
||||||
const [mention, insertIndex, range] = insertMentionStub.args[0];
|
|
||||||
|
|
||||||
expect(mention.title).to.equal('Shia LaBeouf');
|
|
||||||
expect(insertIndex).to.equal(4);
|
|
||||||
expect(range).to.equal(5);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('given a valid mention is not present', () => {
|
|
||||||
const text = 'sh';
|
|
||||||
const index = text.length;
|
|
||||||
|
|
||||||
beforeEach(function beforeEach() {
|
|
||||||
mockQuill.getSelection.returns({
|
|
||||||
index,
|
|
||||||
length: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const blot = {
|
|
||||||
text,
|
|
||||||
};
|
|
||||||
mockQuill.getLeaf.returns([blot, index]);
|
|
||||||
|
|
||||||
mentionCompletion.completeMention();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not insert anything', () => {
|
|
||||||
expect(insertMentionStub.called).to.equal(false);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { assert } from 'chai';
|
||||||
import {
|
import {
|
||||||
getDeltaToRemoveStaleMentions,
|
getDeltaToRemoveStaleMentions,
|
||||||
getTextAndMentionsFromOps,
|
getTextAndMentionsFromOps,
|
||||||
|
getDeltaToRestartMention,
|
||||||
} from '../../quill/util';
|
} from '../../quill/util';
|
||||||
|
|
||||||
describe('getDeltaToRemoveStaleMentions', () => {
|
describe('getDeltaToRemoveStaleMentions', () => {
|
||||||
|
@ -150,3 +151,59 @@ describe('getTextAndMentionsFromOps', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getDeltaToRestartMention', () => {
|
||||||
|
describe('given text and emoji', () => {
|
||||||
|
it('returns the correct retains, a delete, and an @', () => {
|
||||||
|
const originalOps = [
|
||||||
|
{
|
||||||
|
insert: {
|
||||||
|
emoji: '😂',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
insert: {
|
||||||
|
mention: {
|
||||||
|
uuid: 'ghijkl',
|
||||||
|
title: '@sam',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
insert: ' wow, funny, ',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
insert: {
|
||||||
|
mention: {
|
||||||
|
uuid: 'abcdef',
|
||||||
|
title: '@fred',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { ops } = getDeltaToRestartMention(originalOps);
|
||||||
|
|
||||||
|
assert.deepEqual(ops, [
|
||||||
|
{
|
||||||
|
retain: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
retain: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
retain: 13,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
retain: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
delete: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
insert: '@',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
@ -14906,7 +14906,7 @@
|
||||||
"rule": "React-createRef",
|
"rule": "React-createRef",
|
||||||
"path": "ts/quill/mentions/completion.js",
|
"path": "ts/quill/mentions/completion.js",
|
||||||
"line": " this.suggestionListRef = react_1.default.createRef();",
|
"line": " this.suggestionListRef = react_1.default.createRef();",
|
||||||
"lineNumber": 22,
|
"lineNumber": 24,
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2020-10-30T23:03:08.319Z"
|
"updated": "2020-10-30T23:03:08.319Z"
|
||||||
},
|
},
|
||||||
|
|
Loading…
Reference in a new issue