Some improvements
This commit is contained in:
parent
14a2714c1e
commit
c53eefaf6d
19 changed files with 205 additions and 70 deletions
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue