Some improvements

This commit is contained in:
Fedor Indutnyy 2023-12-18 15:22:46 -08:00
parent 14a2714c1e
commit c53eefaf6d
19 changed files with 205 additions and 70 deletions

View file

@ -3,7 +3,6 @@
import type Quill from 'quill';
import Delta from 'quill-delta';
import _ from 'lodash';
import type { EmojiData } from '../../components/emoji/lib';
import {
convertShortName,
@ -15,32 +14,34 @@ type AutoSubstituteAsciiEmojisOptions = {
};
const emojiMap: Record<string, string> = {
':)': 'slightly_smiling_face',
':-)': 'slightly_smiling_face',
':(': 'slightly_frowning_face',
':-(': 'slightly_frowning_face',
':D': 'smiley',
':-D': 'smiley',
':*': 'kissing',
':-*': 'kissing',
':P': 'stuck_out_tongue',
':-D': 'grinning',
':-*': 'kissing_heart',
':-P': 'stuck_out_tongue',
';P': 'stuck_out_tongue_winking_eye',
';-P': 'stuck_out_tongue_winking_eye',
'D:': 'anguished',
"D-':": 'anguished',
':O': 'open_mouth',
':-O': 'open_mouth',
':-p': 'stuck_out_tongue',
":'(": 'cry',
":'-(": 'cry',
':/': 'confused',
':-/': 'confused',
';)': 'wink',
':-\\': 'confused',
';-)': 'wink',
'(Y)': '+1',
'(N)': '-1',
'(y)': '+1',
'(n)': '-1',
'<3': 'heart',
'^_^': 'grin',
'>_<': 'laughing',
};
function buildRegexp(obj: Record<string, string>): RegExp {
const sanitizedKeys = Object.keys(obj).map(x =>
x.replace(/([^a-zA-Z0-9])/g, '\\$1')
);
return new RegExp(`(${sanitizedKeys.join('|')})$`);
}
const EMOJI_REGEXP = buildRegexp(emojiMap);
export class AutoSubstituteAsciiEmojis {
options: AutoSubstituteAsciiEmojisOptions;
@ -50,13 +51,24 @@ export class AutoSubstituteAsciiEmojis {
this.options = options;
this.quill = quill;
this.quill.on(
'text-change',
_.debounce(() => this.onTextChange(), 100)
);
this.quill.on('text-change', (_now, _before, source) => {
if (source !== 'user') {
return;
}
// When pasting - Quill first updates contents with "user" source and only
// then updates the selection with "silent" source. This means that unless
// we wrap `onTextChange` with setTimeout - we are not going to see the
// updated cursor position.
setTimeout(() => this.onTextChange(), 0);
});
}
onTextChange(): void {
if (!window.storage.get('autoConvertEmoji', false)) {
return;
}
const range = this.quill.getSelection();
if (!range) {
@ -65,32 +77,44 @@ export class AutoSubstituteAsciiEmojis {
const [blot, index] = this.quill.getLeaf(range.index);
if (blot !== undefined && blot.text !== undefined) {
const blotText: string = blot.text;
Object.entries(emojiMap).some(([textEmoji, emojiName]) => {
if (blotText.substring(0, index).endsWith(textEmoji)) {
const emojiData = convertShortNameToData(
emojiName,
this.options.skinTone
);
if (emojiData) {
this.insertEmoji(
emojiData,
range.index - textEmoji.length,
textEmoji.length
);
return true;
}
}
return false;
});
if (blot?.text == null) {
return;
}
const textBeforeCursor = blot.text.slice(0, index);
const match = textBeforeCursor.match(EMOJI_REGEXP);
if (match == null) {
return;
}
const [, textEmoji] = match;
const emojiName = emojiMap[textEmoji];
const emojiData = convertShortNameToData(emojiName, this.options.skinTone);
if (emojiData) {
this.insertEmoji(
emojiData,
range.index - textEmoji.length,
textEmoji.length,
textEmoji
);
}
}
insertEmoji(emojiData: EmojiData, index: number, range: number): void {
insertEmoji(
emojiData: EmojiData,
index: number,
range: number,
source: string
): void {
const emoji = convertShortName(emojiData.short_name, this.options.skinTone);
const delta = new Delta().retain(index).delete(range).insert({ emoji });
this.quill.updateContents(delta, 'user');
const delta = new Delta()
.retain(index)
.delete(range)
.insert({
emoji: { value: emoji, source },
});
this.quill.updateContents(delta, 'api');
this.quill.setSelection(index + 1, 0);
}
}

View file

@ -12,6 +12,11 @@ const Embed: typeof Parchment.Embed = Quill.import('blots/embed');
// ts/components/conversation/Emojify.tsx
// ts/components/emoji/Emoji.tsx
export type EmojiBlotValue = Readonly<{
value: string;
source?: string;
}>;
export class EmojiBlot extends Embed {
static override blotName = 'emoji';
@ -19,21 +24,30 @@ export class EmojiBlot extends Embed {
static override className = 'emoji-blot';
static override create(emoji: string): Node {
static override create({ value: emoji, source }: EmojiBlotValue): Node {
const node = super.create(undefined) as HTMLElement;
node.dataset.emoji = emoji;
node.dataset.source = source;
const image = emojiToImage(emoji);
node.setAttribute('src', image || '');
node.setAttribute('data-emoji', emoji);
node.setAttribute('data-source', source || '');
node.setAttribute('title', emoji);
node.setAttribute('aria-label', emoji);
return node;
}
static override value(node: HTMLElement): string | undefined {
return node.dataset.emoji;
static override value(node: HTMLElement): EmojiBlotValue | undefined {
const { emoji, source } = node.dataset;
if (emoji === undefined) {
throw new Error(
`Failed to make EmojiBlot with emoji: ${emoji}, source: ${source}`
);
}
return { value: emoji, source };
}
}

View file

@ -247,7 +247,12 @@ export class EmojiCompletion {
): void {
const emoji = convertShortName(emojiData.short_name, this.options.skinTone);
const delta = new Delta().retain(index).delete(range).insert({ emoji });
const delta = new Delta()
.retain(index)
.delete(range)
.insert({
emoji: { value: emoji },
});
if (withTrailingSpace) {
// The extra space we add won't be formatted unless we manually provide attributes

View file

@ -15,8 +15,8 @@ export const matchEmojiImage: Matcher = (
node.classList.contains('emoji') ||
node.classList.contains('module-emoji__image--16px')
) {
const emoji = node.getAttribute('aria-label');
return new Delta().insert({ emoji }, attributes);
const value = node.getAttribute('aria-label');
return new Delta().insert({ emoji: { value } }, attributes);
}
return delta;
};
@ -27,8 +27,8 @@ export const matchEmojiBlot: Matcher = (
attributes: AttributeMap
): Delta => {
if (node.classList.contains('emoji-blot')) {
const { emoji } = node.dataset;
return new Delta().insert({ emoji }, attributes);
const { emoji: value, source } = node.dataset;
return new Delta().insert({ emoji: { value, source } }, attributes);
}
return delta;
};

View file

@ -13,6 +13,7 @@ import type {
} 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';
@ -27,6 +28,9 @@ 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;
@ -37,7 +41,10 @@ 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 type InsertEmojiOp = InsertOp<
'emoji',
{ value: string; source?: string }
>;
export const isRetainOp = (op?: Op): op is RetainOp =>
op !== undefined && op.retain !== undefined;
@ -64,7 +71,7 @@ export const getTextFromOps = (ops: Array<DeltaOperation>): string =>
}
if (isInsertEmojiOp(op)) {
return acc + op.insert.emoji;
return acc + op.insert.emoji.value;
}
if (isInsertMentionOp(op)) {
@ -187,7 +194,7 @@ export const getTextAndRangesFromOps = (
}
if (isInsertEmojiOp(op)) {
return acc + op.insert.emoji;
return acc + op.insert.emoji.value;
}
if (isInsertMentionOp(op)) {
@ -304,6 +311,27 @@ export const getDeltaToRestartMention = (ops: Array<Op>): Delta => {
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>
@ -422,7 +450,7 @@ export const insertEmojiOps = (
if (emojiData) {
ops.push({ insert: text.slice(index, match.index), attributes });
ops.push({
insert: { emoji },
insert: { emoji: { value: emoji } },
attributes: { ...existingAttributes, ...attributes },
});
index = match.index + emoji.length;