467 lines
12 KiB
TypeScript
467 lines
12 KiB
TypeScript
// Copyright 2020 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
import emojiRegex from 'emoji-regex';
|
|
import Delta from 'quill-delta';
|
|
import type { LeafBlot, DeltaOperation, AttributeMap } from 'quill';
|
|
import type Op from 'quill-delta/dist/Op';
|
|
|
|
import type {
|
|
DisplayNode,
|
|
DraftBodyRange,
|
|
DraftBodyRanges,
|
|
} from '../types/BodyRange';
|
|
import { BodyRange } from '../types/BodyRange';
|
|
import type { MentionBlot } from './mentions/blot';
|
|
import type { EmojiBlot } from './emoji/blot';
|
|
import { isNewlineOnlyOp, QuillFormattingStyle } from './formatting/menu';
|
|
import { isNotNil } from '../util/isNotNil';
|
|
import type { AciString } from '../types/ServiceId';
|
|
import { emojiToData } from '../components/emoji/lib';
|
|
|
|
export type MentionBlotValue = {
|
|
aci: AciString;
|
|
title: string;
|
|
};
|
|
|
|
export type FormattingBlotValue = {
|
|
style: BodyRange.Style;
|
|
};
|
|
|
|
export const isEmojiBlot = (blot: LeafBlot): blot is EmojiBlot =>
|
|
blot.value() && blot.value().emoji;
|
|
|
|
export const isMentionBlot = (blot: LeafBlot): blot is MentionBlot =>
|
|
blot.value() && blot.value().mention;
|
|
|
|
export const isFormatting = (blot: LeafBlot): blot is MentionBlot =>
|
|
blot.value() && blot.value().style;
|
|
|
|
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',
|
|
{ value: string; source?: 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 getTextFromOps = (ops: Array<DeltaOperation>): string =>
|
|
ops
|
|
.reduce((acc, op) => {
|
|
if (typeof op.insert === 'string') {
|
|
return acc + op.insert;
|
|
}
|
|
|
|
if (isInsertEmojiOp(op)) {
|
|
return acc + op.insert.emoji.value;
|
|
}
|
|
|
|
if (isInsertMentionOp(op)) {
|
|
return `${acc}@${op.insert.mention.title}`;
|
|
}
|
|
|
|
return acc;
|
|
}, '')
|
|
.trim();
|
|
|
|
const { BOLD, ITALIC, MONOSPACE, SPOILER, STRIKETHROUGH, NONE } =
|
|
BodyRange.Style;
|
|
|
|
function extractFormatRange({
|
|
bodyRanges,
|
|
index,
|
|
previousData,
|
|
hasStyle,
|
|
style,
|
|
}: {
|
|
bodyRanges: Array<DraftBodyRange>;
|
|
index: number;
|
|
previousData: { start: number } | undefined;
|
|
hasStyle: boolean;
|
|
style: BodyRange.Style;
|
|
}) {
|
|
if (hasStyle && !previousData) {
|
|
return { start: index };
|
|
}
|
|
if (!hasStyle && previousData) {
|
|
const { start } = previousData;
|
|
bodyRanges.push({
|
|
length: index - start,
|
|
start,
|
|
style,
|
|
});
|
|
return undefined;
|
|
}
|
|
|
|
return previousData;
|
|
}
|
|
|
|
function extractAllFormats(
|
|
bodyRanges: Array<DraftBodyRange>,
|
|
formats: Record<BodyRange.Style, { start: number } | undefined>,
|
|
index: number,
|
|
op?: Op
|
|
): Record<BodyRange.Style, { start: number } | undefined> {
|
|
const result = { ...formats };
|
|
const params = {
|
|
bodyRanges,
|
|
index,
|
|
};
|
|
|
|
result[BOLD] = extractFormatRange({
|
|
...params,
|
|
style: BOLD,
|
|
previousData: result[BOLD],
|
|
hasStyle: op?.attributes?.[QuillFormattingStyle.bold],
|
|
});
|
|
result[ITALIC] = extractFormatRange({
|
|
...params,
|
|
style: ITALIC,
|
|
previousData: result[ITALIC],
|
|
hasStyle: op?.attributes?.[QuillFormattingStyle.italic],
|
|
});
|
|
result[MONOSPACE] = extractFormatRange({
|
|
...params,
|
|
style: MONOSPACE,
|
|
previousData: result[MONOSPACE],
|
|
hasStyle: op?.attributes?.[QuillFormattingStyle.monospace],
|
|
});
|
|
result[SPOILER] = extractFormatRange({
|
|
...params,
|
|
style: SPOILER,
|
|
previousData: result[SPOILER],
|
|
hasStyle: op?.attributes?.[QuillFormattingStyle.spoiler],
|
|
});
|
|
result[STRIKETHROUGH] = extractFormatRange({
|
|
...params,
|
|
style: STRIKETHROUGH,
|
|
previousData: result[STRIKETHROUGH],
|
|
hasStyle: op?.attributes?.[QuillFormattingStyle.strike],
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
export const getTextAndRangesFromOps = (
|
|
ops: Array<Op>
|
|
): { text: string; bodyRanges: DraftBodyRanges } => {
|
|
const startingBodyRanges: Array<DraftBodyRange> = [];
|
|
let earliestMonospaceIndex = Number.MAX_SAFE_INTEGER;
|
|
let formats: Record<BodyRange.Style, { start: number } | undefined> = {
|
|
[BOLD]: undefined,
|
|
[ITALIC]: undefined,
|
|
[MONOSPACE]: undefined,
|
|
[SPOILER]: undefined,
|
|
[STRIKETHROUGH]: undefined,
|
|
[NONE]: undefined,
|
|
};
|
|
|
|
const preTrimText = ops.reduce((acc, op) => {
|
|
// We special-case all-newline ops because Quill doesn't apply styles to them
|
|
if (isNewlineOnlyOp(op)) {
|
|
return acc + op.insert;
|
|
}
|
|
|
|
// Start or finish format sections as needed
|
|
formats = extractAllFormats(startingBodyRanges, formats, acc.length, op);
|
|
|
|
const newMonospaceStart =
|
|
formats[MONOSPACE]?.start ?? earliestMonospaceIndex;
|
|
if (newMonospaceStart < earliestMonospaceIndex) {
|
|
earliestMonospaceIndex = newMonospaceStart;
|
|
}
|
|
|
|
if (typeof op.insert === 'string') {
|
|
return acc + op.insert;
|
|
}
|
|
|
|
if (isInsertEmojiOp(op)) {
|
|
return acc + op.insert.emoji.value;
|
|
}
|
|
|
|
if (isInsertMentionOp(op)) {
|
|
startingBodyRanges.push({
|
|
length: 1, // The length of `\uFFFC`
|
|
mentionAci: op.insert.mention.aci,
|
|
replacementText: op.insert.mention.title,
|
|
start: acc.length,
|
|
});
|
|
|
|
return `${acc}\uFFFC`;
|
|
}
|
|
|
|
return acc;
|
|
}, '');
|
|
|
|
// Close off any pending formats
|
|
extractAllFormats(startingBodyRanges, formats, preTrimText.length);
|
|
|
|
// Now repair bodyRanges after trimming
|
|
let trimStart = preTrimText.trimStart();
|
|
let trimmedFromStart = preTrimText.length - trimStart.length;
|
|
|
|
// We don't want to trim leading monospace text
|
|
if (earliestMonospaceIndex < trimmedFromStart) {
|
|
trimStart = preTrimText.slice(earliestMonospaceIndex);
|
|
trimmedFromStart = earliestMonospaceIndex;
|
|
}
|
|
|
|
const text = trimStart.trimEnd();
|
|
const textLength = text.length;
|
|
|
|
const bodyRanges = startingBodyRanges
|
|
.map(startingRange => {
|
|
let range = {
|
|
...startingRange,
|
|
start: startingRange.start - trimmedFromStart,
|
|
};
|
|
|
|
if (range.start >= text.length) {
|
|
return null;
|
|
}
|
|
|
|
const underStartBy = -range.start;
|
|
if (underStartBy > 0) {
|
|
const length = range.length - underStartBy;
|
|
if (length <= 0) {
|
|
return null;
|
|
}
|
|
|
|
range = {
|
|
...range,
|
|
start: 0,
|
|
length,
|
|
};
|
|
}
|
|
|
|
const end = range.start + range.length;
|
|
const overEndBy = end - textLength;
|
|
if (overEndBy > 0) {
|
|
range = {
|
|
...range,
|
|
length: range.length - overEndBy,
|
|
};
|
|
}
|
|
|
|
return range;
|
|
})
|
|
.filter(isNotNil);
|
|
|
|
return { text, bodyRanges };
|
|
};
|
|
|
|
export const getBlotTextPartitions = (
|
|
blotText: string | undefined,
|
|
index: number
|
|
): [string, string] => {
|
|
const lowerCaseBlotText = (blotText || '').toLowerCase();
|
|
const leftLeafText = lowerCaseBlotText.substr(0, index);
|
|
const rightLeafText = lowerCaseBlotText.substr(index);
|
|
|
|
return [leftLeafText, rightLeafText];
|
|
};
|
|
|
|
export const matchBlotTextPartitions = (
|
|
blot: LeafBlot,
|
|
index: number,
|
|
leftRegExp: RegExp,
|
|
rightRegExp?: RegExp
|
|
): Array<RegExpMatchArray | null> => {
|
|
const [leftText, rightText] = getBlotTextPartitions(blot.text, 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 getDeltaToRestartEmoji = (ops: Array<Op>): Delta => {
|
|
const changes = new Array<Op>();
|
|
for (const op of ops.slice(0, -1)) {
|
|
if (op.insert && typeof op.insert === 'string') {
|
|
changes.push({ retain: op.insert.length });
|
|
} else {
|
|
changes.push({ retain: 1 });
|
|
}
|
|
}
|
|
const last = ops.at(-1);
|
|
if (!last || !last.insert) {
|
|
throw new Error('No emoji to delete');
|
|
}
|
|
|
|
changes.push({ delete: 1 });
|
|
if ((last as InsertEmojiOp).insert.emoji?.source) {
|
|
changes.push({ insert: (last as InsertEmojiOp).insert.emoji?.source });
|
|
}
|
|
return new Delta(changes);
|
|
};
|
|
|
|
export const getDeltaToRemoveStaleMentions = (
|
|
ops: Array<Op>,
|
|
memberAcis: Array<AciString>
|
|
): Delta => {
|
|
const newOps = ops.reduce((memo, op) => {
|
|
if (op.insert) {
|
|
if (
|
|
isInsertMentionOp(op) &&
|
|
!memberAcis.includes(op.insert.mention.aci)
|
|
) {
|
|
const deleteOp = { delete: 1 };
|
|
const textOp = { insert: `@${op.insert.mention.title}` };
|
|
return [...memo, deleteOp, textOp];
|
|
}
|
|
|
|
if (typeof op.insert === 'string') {
|
|
const retainStringOp = { retain: op.insert.length };
|
|
return [...memo, retainStringOp];
|
|
}
|
|
|
|
const retainEmbedOp = { retain: 1 };
|
|
return [...memo, retainEmbedOp];
|
|
}
|
|
|
|
return [...memo, op];
|
|
}, Array<Op>());
|
|
|
|
return new Delta(newOps);
|
|
};
|
|
|
|
export const insertFormattingAndMentionsOps = (
|
|
nodes: ReadonlyArray<DisplayNode>
|
|
): ReadonlyArray<Op> => {
|
|
let ops: Array<Op> = [];
|
|
|
|
nodes.forEach(node => {
|
|
const startingOp: Op = {
|
|
insert: node.text,
|
|
attributes: {
|
|
[QuillFormattingStyle.bold]: node.isBold,
|
|
[QuillFormattingStyle.italic]: node.isItalic,
|
|
[QuillFormattingStyle.monospace]: node.isMonospace,
|
|
[QuillFormattingStyle.spoiler]: node.isSpoiler,
|
|
[QuillFormattingStyle.strike]: node.isStrikethrough,
|
|
},
|
|
};
|
|
ops = ops.concat(insertMentionOps([startingOp], node.mentions));
|
|
});
|
|
|
|
return ops;
|
|
};
|
|
|
|
export const insertMentionOps = (
|
|
incomingOps: Array<Op>,
|
|
bodyRanges: DraftBodyRanges
|
|
): Array<Op> => {
|
|
const ops = [...incomingOps];
|
|
|
|
const sortableBodyRanges: Array<DraftBodyRange> = bodyRanges.slice();
|
|
|
|
// Working backwards through bodyRanges (to avoid offsetting later mentions),
|
|
// Shift off the op with the text to the left of the last mention,
|
|
// Insert a mention based on the current bodyRange,
|
|
// Unshift the mention and surrounding text to leave the ops ready for the next range
|
|
sortableBodyRanges
|
|
.sort((a, b) => b.start - a.start)
|
|
.forEach(bodyRange => {
|
|
if (!BodyRange.isMention(bodyRange)) {
|
|
return;
|
|
}
|
|
|
|
const { start, length, mentionAci, replacementText } = bodyRange;
|
|
|
|
const op = ops.shift();
|
|
|
|
if (op) {
|
|
const { insert, attributes } = op;
|
|
|
|
if (typeof insert === 'string') {
|
|
const left = insert.slice(0, start);
|
|
const right = insert.slice(start + length);
|
|
|
|
const mention = {
|
|
aci: mentionAci,
|
|
title: replacementText,
|
|
};
|
|
|
|
ops.unshift({ insert: right, attributes });
|
|
ops.unshift({ insert: { mention }, attributes });
|
|
ops.unshift({ insert: left, attributes });
|
|
} else {
|
|
ops.unshift(op);
|
|
}
|
|
}
|
|
});
|
|
|
|
return ops;
|
|
};
|
|
|
|
export const insertEmojiOps = (
|
|
incomingOps: ReadonlyArray<Op>,
|
|
existingAttributes: AttributeMap
|
|
): Array<Op> => {
|
|
return incomingOps.reduce((ops, op) => {
|
|
if (typeof op.insert === 'string') {
|
|
const text = op.insert;
|
|
const { attributes } = op;
|
|
const re = emojiRegex();
|
|
let index = 0;
|
|
let match: RegExpExecArray | null;
|
|
|
|
// eslint-disable-next-line no-cond-assign
|
|
while ((match = re.exec(text))) {
|
|
const [emoji] = match;
|
|
const emojiData = emojiToData(emoji);
|
|
if (emojiData) {
|
|
ops.push({ insert: text.slice(index, match.index), attributes });
|
|
ops.push({
|
|
insert: { emoji: { value: emoji } },
|
|
attributes: { ...existingAttributes, ...attributes },
|
|
});
|
|
index = match.index + emoji.length;
|
|
}
|
|
}
|
|
|
|
ops.push({ insert: text.slice(index, text.length), attributes });
|
|
} else {
|
|
ops.push(op);
|
|
}
|
|
|
|
return ops;
|
|
}, [] as Array<Op>);
|
|
};
|