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;
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
border: 1px solid transparent;
|
||||
height: 22px;
|
||||
line-height: 22px;
|
||||
|
||||
@include dark-theme {
|
||||
background-color: $color-gray-60;
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
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
|
||||
|
||||
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);
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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: '@',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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"
|
||||
},
|
||||
|
|
Loading…
Reference in a new issue