Bring up picker on backspace into mention

This commit is contained in:
Chris Svenningsen 2020-11-05 13:18:42 -08:00 committed by GitHub
parent 4def45b86a
commit fe298444fb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 542 additions and 474 deletions

View File

@ -8701,7 +8701,8 @@ button.module-image__border-overlay:focus {
display: inline-block;
padding-left: 4px;
padding-right: 4px;
border: 1px solid transparent;
height: 22px;
line-height: 22px;
@include dark-theme {
background-color: $color-gray-60;

View File

@ -24,11 +24,13 @@ import {
matchReactEmoji,
} from '../quill/emoji/matchers';
import { matchMention } from '../quill/mentions/matchers';
import { MemberRepository } from '../quill/memberRepository';
import {
getDeltaToRemoveStaleMentions,
getTextAndMentionsFromOps,
isMentionBlot,
getDeltaToRestartMention,
} from '../quill/util';
import { MemberRepository } from '../quill/memberRepository';
Quill.register('formats/emoji', EmojiBlot);
Quill.register('formats/mention', MentionBlot);
@ -39,24 +41,6 @@ const Block = Quill.import('blots/block');
Block.tagName = 'DIV';
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 {
undo(): void;
clear(): void;
@ -401,7 +385,7 @@ export const CompositionInput: React.ComponentType<Props> = props => {
if (mentionCompletion) {
if (mentionCompletion.results.length) {
mentionCompletion.reset();
mentionCompletion.clearResults();
return false;
}
}
@ -434,6 +418,32 @@ export const CompositionInput: React.ComponentType<Props> = props => {
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 quill = quillRef.current;
@ -565,6 +575,7 @@ export const CompositionInput: React.ComponentType<Props> = props => {
onEscape: { key: 27, handler: onEscape }, // 27 = Escape
onCtrlA: { key: 65, ctrlKey: true, handler: onCtrlA }, // 65 = a
onCtrlE: { key: 69, ctrlKey: true, handler: onCtrlE }, // 69 = e
onBackspace: { key: 8, handler: onBackspace }, // 8 = Backspace
},
},
emojiCompletion: {

View File

@ -18,36 +18,7 @@ import {
} 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;
}
}
import { getBlotTextPartitions, matchBlotTextPartitions } from '../util';
interface EmojiPickerOptions {
onPickEmoji: (emoji: EmojiPickDataType) => void;
@ -110,19 +81,9 @@ export class EmojiCompletion {
getCurrentLeafTextPartitions(): [string, string] {
const range = this.quill.getSelection();
const [blot, index] = this.quill.getLeaf(range ? range.index : -1);
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 ['', ''];
return getBlotTextPartitions(blot, index);
}
onSelectionChange(): void {
@ -135,76 +96,75 @@ export class EmojiCompletion {
if (!range) return;
const [leftLeafText, rightLeafText] = this.getCurrentLeafTextPartitions();
const leftTokenTextMatch = /(?<=^|\s):([-+0-9a-z_]*)(:?)$/.exec(
leftLeafText
const [blot, index] = this.quill.getLeaf(range.index);
const [leftTokenTextMatch, rightTokenTextMatch] = matchBlotTextPartitions(
blot,
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 (isShortName(leftTokenText)) {
const emojiData = convertShortNameToData(
leftTokenText,
this.options.skinTone
);
if (emojiData) {
this.insertEmoji(
emojiData,
range.index - leftTokenText.length - 2,
leftTokenText.length + 2
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;
}
} else {
}
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;
}
}
if (rightTokenTextMatch) {
const [, rightTokenText] = rightTokenTextMatch;
const tokenText = leftTokenText + rightTokenText;
const showEmojiResults = search(leftTokenText, 10);
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 (showEmojiResults.length > 0) {
this.results = showEmojiResults;
this.render();
} else if (this.results.length !== 0) {
this.reset();
}
}
if (leftTokenText.length < 2) {
} else if (this.results.length !== 0) {
this.reset();
return;
}
const results = search(leftTokenText, 10);
if (!results.length) {
this.reset();
return;
}
this.results = results;
this.render();
}
completeEmoji(): void {

View File

@ -6,12 +6,10 @@ import Parchment from 'parchment';
import Quill from 'quill';
import { render } from 'react-dom';
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');
type MentionBlotValue = { uuid?: string; title?: string };
export class MentionBlot extends Embed {
static blotName = 'mention';
@ -19,7 +17,7 @@ export class MentionBlot extends Embed {
static tagName = 'span';
static create(value: ConversationType): Node {
static create(value: MentionBlotValue): Node {
const node = super.create(undefined) as HTMLElement;
MentionBlot.buildSpan(value, node);
@ -29,15 +27,21 @@ export class MentionBlot extends Embed {
static value(node: HTMLElement): MentionBlotValue {
const { uuid, title } = node.dataset;
if (uuid === undefined || title === undefined) {
throw new Error(
`Failed to make MentionBlot with uuid: ${uuid} and title: ${title}`
);
}
return {
uuid,
title,
};
}
static buildSpan(member: ConversationType, node: HTMLElement): void {
node.setAttribute('data-uuid', member.uuid || '');
node.setAttribute('data-title', member.title || '');
static buildSpan(mention: MentionBlotValue, node: HTMLElement): void {
node.setAttribute('data-uuid', mention.uuid || '');
node.setAttribute('data-title', mention.title || '');
const mentionSpan = document.createElement('span');
@ -45,7 +49,7 @@ export class MentionBlot extends Embed {
<span className="module-composition-input__at-mention">
<bdi>
@
<Emojify text={member.title} />
<Emojify text={mention.title} />
</bdi>
</span>,
mentionSpan

View File

@ -1,6 +1,7 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import _ from 'lodash';
import Quill from 'quill';
import Delta from 'quill-delta';
import React, { RefObject } from 'react';
@ -11,8 +12,8 @@ import { createPortal } from 'react-dom';
import { ConversationType } from '../../state/ducks/conversations';
import { Avatar } from '../../components/Avatar';
import { LocalizerType } from '../../types/Util';
import { MemberRepository } from '../memberRepository';
import { matchBlotTextPartitions } from '../util';
export interface MentionCompletionOptions {
i18n: LocalizerType;
@ -53,7 +54,7 @@ export class MentionCompletion {
const clearResults = () => {
if (this.results.length) {
this.reset();
this.clearResults();
}
return true;
@ -73,7 +74,7 @@ export class MentionCompletion {
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('text-change', _.debounce(this.onTextChange.bind(this), 0));
this.quill.on('selection-change', this.onSelectionChange.bind(this));
}
@ -95,96 +96,93 @@ 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();
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 leftLeafText = blot.text.substr(0, blotIndex);
const rightLeafText = blot.text.substr(blotIndex);
const [leftTokenTextMatch] = matchBlotTextPartitions(
blot,
index,
MENTION_REGEX
);
return [leftLeafText, rightLeafText];
if (leftTokenTextMatch) {
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);
}
}
return results;
}
}
return ['', ''];
}
onSelectionChange(): void {
// Selection should never change while we're editing a mention
this.reset();
return [];
}
onTextChange(): void {
const range = this.quill.getSelection();
const showMemberResults = this.possiblyShowMemberResults();
if (!range) return;
const [leftLeafText] = this.getCurrentLeafTextPartitions();
const leftTokenTextMatch = MENTION_REGEX.exec(leftLeafText);
if (!leftTokenTextMatch) {
this.reset();
return;
if (showMemberResults.length > 0) {
this.results = showMemberResults;
this.index = 0;
this.render();
} else if (this.results.length !== 0) {
this.clearResults();
}
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 {
completeMention(resultIndexArg?: number): void {
const resultIndex = resultIndexArg || this.index;
const range = this.quill.getSelection();
if (range === null) return;
const member = this.results[this.index];
const [leftLeafText] = this.getCurrentLeafTextPartitions();
const member = this.results[resultIndex];
const leftTokenTextMatch = MENTION_REGEX.exec(leftLeafText);
const [blot, index] = this.quill.getLeaf(range.index);
if (leftTokenTextMatch === null) return;
const [, leftTokenText] = leftTokenTextMatch;
this.insertMention(
member,
range.index - leftTokenText.length - 1,
leftTokenText.length + 1,
true
const [leftTokenTextMatch] = matchBlotTextPartitions(
blot,
index,
MENTION_REGEX
);
if (leftTokenTextMatch) {
const [, leftTokenText] = leftTokenTextMatch;
this.insertMention(
member,
range.index - leftTokenText.length - 1,
leftTokenText.length + 1,
true
);
}
}
insertMention(
member: ConversationType,
mention: ConversationType,
index: number,
range: number,
withTrailingSpace = false
): void {
const mention = member;
const delta = new Delta()
.retain(index)
.delete(range)
@ -198,16 +196,14 @@ export class MentionCompletion {
this.quill.setSelection(index + 1, 0, 'user');
}
this.reset();
this.clearResults();
}
reset(): void {
if (this.results.length) {
this.results = [];
this.index = 0;
clearResults(): void {
this.results = [];
this.index = 0;
this.render();
}
this.render();
}
onUnmount(): void {
@ -266,8 +262,7 @@ export class MentionCompletion {
role="option button"
aria-selected={memberResultsIndex === index}
onClick={() => {
this.index = index;
this.completeMention();
this.completeMention(index);
}}
className={classNames(
'module-composition-input__suggestions__row',

55
ts/quill/types.d.ts vendored Normal file
View 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>>;
}
}

View File

@ -2,44 +2,77 @@
// SPDX-License-Identifier: AGPL-3.0-only
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 { 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 = (
ops: Array<DeltaOperation>
ops: Array<Op>
): [string, Array<BodyRangeType>] => {
const mentions: Array<BodyRangeType> = [];
const text = ops.reduce((acc, { insert }, index) => {
if (typeof insert === 'string') {
const text = ops.reduce((acc, op, index) => {
if (typeof op.insert === 'string') {
let textToAdd;
switch (index) {
case 0: {
textToAdd = insert.trimLeft();
textToAdd = op.insert.trimLeft();
break;
}
case ops.length - 1: {
textToAdd = insert.trimRight();
textToAdd = op.insert.trimRight();
break;
}
default: {
textToAdd = insert;
textToAdd = op.insert;
break;
}
}
return acc + textToAdd;
}
if (insert.emoji) {
return acc + insert.emoji;
if (isInsertEmojiOp(op)) {
return acc + op.insert.emoji;
}
if (insert.mention) {
if (isInsertMentionOp(op)) {
mentions.push({
length: 1, // The length of `\uFFFC`
mentionUuid: insert.mention.uuid,
replacementText: insert.mention.title,
mentionUuid: op.insert.mention.uuid,
replacementText: op.insert.mention.title,
start: acc.length,
});
@ -52,13 +85,62 @@ export const getTextAndMentionsFromOps = (
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 = (
ops: Array<DeltaOperation>,
ops: Array<Op>,
memberUuids: Array<string>
): Delta => {
const newOps = ops.reduce((memo, op) => {
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 textOp = { insert: `@${op.insert.mention.title}` };
return [...memo, deleteOp, textOp];
@ -74,7 +156,7 @@ export const getDeltaToRemoveStaleMentions = (
}
return [...memo, op];
}, Array<DeltaOperation>());
}, Array<Op>());
return new Delta(newOps);
};

View File

@ -113,9 +113,8 @@ describe('emojiCompletion', () => {
emojiCompletion.onTextChange();
});
it('resets the completion', () => {
it('does not show results', () => {
assert.equal(emojiCompletion.results.length, 0);
assert.equal(emojiCompletion.index, 0);
});
});
@ -134,9 +133,8 @@ describe('emojiCompletion', () => {
emojiCompletion.onTextChange();
});
it('resets the completion', () => {
it('does not show results', () => {
assert.equal(emojiCompletion.results.length, 0);
assert.equal(emojiCompletion.index, 0);
});
});
@ -155,9 +153,8 @@ describe('emojiCompletion', () => {
emojiCompletion.onTextChange();
});
it('resets the completion', () => {
it('does not show results', () => {
assert.equal(emojiCompletion.results.length, 0);
assert.equal(emojiCompletion.index, 0);
});
});
@ -176,9 +173,8 @@ describe('emojiCompletion', () => {
emojiCompletion.onTextChange();
});
it('resets the completion', () => {
it('does not show results', () => {
assert.equal(emojiCompletion.results.length, 0);
assert.equal(emojiCompletion.index, 0);
});
});
@ -231,9 +227,8 @@ describe('emojiCompletion', () => {
assert.equal(range, 7);
});
it('resets the completion', () => {
it('does not show results', () => {
assert.equal(emojiCompletion.results.length, 0);
assert.equal(emojiCompletion.index, 0);
});
});
@ -261,9 +256,8 @@ describe('emojiCompletion', () => {
assert.equal(range, 7);
});
it('resets the completion', () => {
it('does not show results', () => {
assert.equal(emojiCompletion.results.length, 0);
assert.equal(emojiCompletion.index, 0);
});
it('sets the quill selection to the right cursor position', () => {
@ -286,9 +280,8 @@ describe('emojiCompletion', () => {
emojiCompletion.onTextChange();
});
it('resets the completion', () => {
it('does not show results', () => {
assert.equal(emojiCompletion.results.length, 0);
assert.equal(emojiCompletion.index, 0);
});
});
});
@ -323,9 +316,8 @@ describe('emojiCompletion', () => {
assert.equal(range, validEmoji.length);
});
it('resets the completion', () => {
it('does not show results', () => {
assert.equal(emojiCompletion.results.length, 0);
assert.equal(emojiCompletion.index, 0);
});
});
@ -339,9 +331,8 @@ describe('emojiCompletion', () => {
emojiCompletion.onTextChange();
});
it('resets the completion', () => {
it('does not show results', () => {
assert.equal(emojiCompletion.results.length, 0);
assert.equal(emojiCompletion.index, 0);
});
});
});

View File

@ -1,8 +1,10 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { expect } from 'chai';
import sinon from 'sinon';
import { assert } from 'chai';
import Delta from 'quill-delta';
import sinon, { SinonStub } from 'sinon';
import Quill, { KeyboardStatic } from 'quill';
import { MutableRefObject } from 'react';
import {
@ -12,9 +14,6 @@ import {
import { ConversationType } from '../../../state/ducks/conversations';
import { MemberRepository } from '../../../quill/memberRepository';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const globalAsAny = global as any;
const me: ConversationType = {
id: '666777',
uuid: 'pqrstuv',
@ -50,30 +49,37 @@ const members: Array<ConversationType> = [
me,
];
describe('mentionCompletion', () => {
let mentionCompletion: MentionCompletion;
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace NodeJS {
interface Global {
document: {
body: {
appendChild: unknown;
};
createElement: unknown;
};
}
}
}
describe('MentionCompletion', () => {
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() {
this.oldDocument = globalAsAny.document;
globalAsAny.document = {
global.document = {
body: {
appendChild: () => null,
appendChild: sinon.spy(),
},
createElement: () => null,
};
mockQuill = {
getLeaf: sinon.stub(),
getSelection: sinon.stub(),
keyboard: {
addBinding: sinon.stub(),
},
on: sinon.stub(),
setSelection: sinon.stub(),
updateContents: sinon.stub(),
createElement: sinon.spy(),
};
const memberRepositoryRef: MutableRefObject<MemberRepository> = {
@ -84,270 +90,176 @@ describe('mentionCompletion', () => {
i18n: sinon.stub(),
me,
memberRepositoryRef,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setMentionPickerElement: mockSetMentionPickerElement as any,
setMentionPickerElement: mockSetMentionPickerElement,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mentionCompletion = new MentionCompletion(mockQuill as any, options);
mockQuill = {
getContents: sinon.stub(),
getLeaf: sinon.stub(),
getSelection: sinon.stub(),
keyboard: { addBinding: sinon.stub() },
on: sinon.stub(),
setSelection: sinon.stub(),
updateContents: sinon.stub(),
};
// Stub rendering to avoid missing DOM until we bring in Enzyme
mentionCompletion.render = sinon.stub();
});
mentionCompletion = new MentionCompletion(
(mockQuill as unknown) as Quill,
options
);
afterEach(function afterEach() {
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 [
leftLeafText,
rightLeafText,
] = mentionCompletion.getCurrentLeafTextPartitions();
expect(leftLeafText).to.equal('@sh');
expect(rightLeafText).to.equal('ia');
});
sinon.stub(mentionCompletion, 'render');
});
describe('onTextChange', () => {
let insertMentionStub: sinon.SinonStub<
[ConversationType, number, number, (boolean | undefined)?],
void
>;
let possiblyShowMemberResultsStub: sinon.SinonStub<[], ConversationType[]>;
beforeEach(function beforeEach() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mentionCompletion.results = [{ title: 'Mahershala Ali' } as any];
mentionCompletion.index = 5;
insertMentionStub = sinon
.stub(mentionCompletion, 'insertMention')
.callThrough();
beforeEach(() => {
possiblyShowMemberResultsStub = sinon.stub(
mentionCompletion,
'possiblyShowMemberResults'
);
});
afterEach(function afterEach() {
insertMentionStub.restore();
});
describe('given a change that should show members', () => {
const newContents = new Delta().insert('@a');
describe('given a mention is not starting (no @)', () => {
beforeEach(function beforeEach() {
mockQuill.getSelection.returns({
index: 3,
length: 0,
});
beforeEach(() => {
mockQuill.getContents?.returns(newContents);
const blot = {
text: 'smi',
};
mockQuill.getLeaf.returns([blot, 3]);
possiblyShowMemberResultsStub.returns(members);
});
it('shows member results', () => {
mentionCompletion.onTextChange();
});
it('resets the completion', () => {
expect(mentionCompletion.results).to.have.lengthOf(0);
expect(mentionCompletion.index).to.equal(0);
assert.equal(mentionCompletion.results, members);
assert.equal(mentionCompletion.index, 0);
});
});
describe('given an mention is starting but does not match a member', () => {
beforeEach(function beforeEach() {
mockQuill.getSelection.returns({
index: 4,
length: 0,
});
describe('given a change that should clear results', () => {
const newContents = new Delta().insert('foo ');
const blot = {
text: '@nope',
};
mockQuill.getLeaf.returns([blot, 5]);
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();
});
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 mention is started and matches members', () => {
beforeEach(function beforeEach() {
mockQuill.getSelection.returns({
index: 4,
length: 0,
});
const blot = {
text: '@sh',
};
mockQuill.getLeaf.returns([blot, 3]);
mentionCompletion.onTextChange();
});
it('stores the results, omitting `me`, and renders', () => {
expect(mentionCompletion.results).to.have.lengthOf(1);
expect((mentionCompletion.render as sinon.SinonStub).called).to.equal(
true
);
assert.equal(clearResultsStub.called, true);
});
});
});
describe('completeMention', () => {
let insertMentionStub: sinon.SinonStub<
[ConversationType, number, number, (boolean | undefined)?],
void
>;
describe('given a completable mention', () => {
let insertMentionStub: SinonStub<
[ConversationType, number, number, (boolean | undefined)?],
void
>;
beforeEach(function beforeEach() {
mentionCompletion.results = [
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ title: 'Mahershala Ali' } as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ title: 'Shia LaBeouf' } as any,
];
mentionCompletion.index = 1;
insertMentionStub = sinon.stub(mentionCompletion, 'insertMention');
});
beforeEach(() => {
mentionCompletion.results = members;
mockQuill.getSelection?.returns({ index: 5 });
mockQuill.getLeaf?.returns([{ text: '@shia' }, 5]);
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();
insertMentionStub = sinon.stub(mentionCompletion, 'insertMention');
});
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');
expect(insertIndex).to.equal(0);
expect(range).to.equal(text.length);
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('given a valid mention starting with a capital letter', () => {
const text = '@Sh';
const index = text.length;
it('can infer the member to complete with', () => {
mentionCompletion.index = 1;
mentionCompletion.completeMention();
beforeEach(function beforeEach() {
mockQuill.getSelection.returns({
index,
length: 0,
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]);
});
const blot = {
text,
};
mockQuill.getLeaf.returns([blot, index]);
it('inserts correctly', () => {
mentionCompletion.completeMention(1);
mentionCompletion.completeMention();
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);
});
});
it('inserts the currently selected mention at the current cursor position', () => {
const [mention, insertIndex, range] = insertMentionStub.args[0];
describe('given a completable mention starting with a capital letter', () => {
const text = '@Sh';
const index = text.length;
expect(mention.title).to.equal('Shia LaBeouf');
expect(insertIndex).to.equal(0);
expect(range).to.equal(text.length);
});
});
beforeEach(function beforeEach() {
mockQuill.getSelection?.returns({ index });
describe('given a valid mention inside a string', () => {
const text = 'foo @shia bar';
const index = 9;
const blot = {
text,
};
mockQuill.getLeaf?.returns([blot, index]);
beforeEach(function beforeEach() {
mockQuill.getSelection.returns({
index,
length: 0,
mentionCompletion.completeMention(1);
});
const blot = {
text,
};
mockQuill.getLeaf.returns([blot, index]);
it('inserts the currently selected mention at the current cursor position', () => {
const [
member,
distanceFromCursor,
adjustCursorAfterBy,
withTrailingSpace,
] = insertMentionStub.getCall(0).args;
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,
assert.equal(member, members[1]);
assert.equal(distanceFromCursor, 0);
assert.equal(adjustCursorAfterBy, 3);
assert.equal(withTrailingSpace, true);
});
const blot = {
text,
};
mockQuill.getLeaf.returns([blot, index]);
mentionCompletion.completeMention();
});
it('does not insert anything', () => {
expect(insertMentionStub.called).to.equal(false);
});
});
});

View File

@ -6,6 +6,7 @@ import { assert } from 'chai';
import {
getDeltaToRemoveStaleMentions,
getTextAndMentionsFromOps,
getDeltaToRestartMention,
} from '../../quill/util';
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: '@',
},
]);
});
});
});

View File

@ -14906,7 +14906,7 @@
"rule": "React-createRef",
"path": "ts/quill/mentions/completion.js",
"line": " this.suggestionListRef = react_1.default.createRef();",
"lineNumber": 22,
"lineNumber": 24,
"reasonCategory": "usageTrusted",
"updated": "2020-10-30T23:03:08.319Z"
},