Files
fluxer/fluxer_app/src/features/lexical/composer/ComposerEmojiShortcode.ts
T

193 lines
6.5 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import UnicodeEmojis from '@app/features/expressions/utils/UnicodeEmojis';
import {
$captureSelectionOffsets,
$getComposerNodeDisplayStart,
$selectComposerNodeBoundary,
$selectComposerRange,
} from '@app/features/lexical/composer/composerOffsets';
import {$isComposerBlockquoteLineNode} from '@app/features/lexical/composer/nodes/ComposerBlockquoteLineNode';
import {$createComposerCustomEmojiNode} from '@app/features/lexical/composer/nodes/ComposerCustomEmojiNode';
import {
$createComposerStandardEmojiNode,
$isComposerStandardEmojiNode,
} from '@app/features/lexical/composer/nodes/ComposerStandardEmojiNode';
import {$isSyntaxMarkerNode} from '@app/features/lexical/composer/nodes/SyntaxMarkerNode';
import {findTypedEmojiShortcode, type TypedEmojiMatch} from '@app/features/messaging/utils/markdown/TypedEmojiMatch';
import type {ResolvedTypedEmoji} from '@app/features/messaging/utils/TypedEmojiShortcodeUtils';
import {type LexicalEditor, TextNode} from 'lexical';
export type ComposerEmojiResolver = (shortcodeName: string) => ResolvedTypedEmoji | null;
export function registerComposerEmojiShortcode(editor: LexicalEditor, resolve: ComposerEmojiResolver): () => void {
return editor.registerNodeTransform(TextNode, (node) => {
if (!editor.isComposing()) {
$convertEmojiShortcode(node, resolve);
}
});
}
function findUnicodeEmoji(text: string, startIndex: number): TypedEmojiMatch | null {
const matches = UnicodeEmojis.matchEmojiSurrogates(text, startIndex);
let match = matches.next().value;
if (match && UnicodeEmojis.isInsideRegionalIndicatorPair(text, match.end)) {
match = matches.next().value;
}
if (!match) {
return null;
}
const name = match.name;
if (!name) {
return null;
}
return {start: match.start, end: match.end, name};
}
interface EmojiToken extends TypedEmojiMatch {
surrogate: boolean;
}
function findNextEmojiToken(text: string, startIndex: number): EmojiToken | null {
const shortcode = findTypedEmojiShortcode(text, startIndex);
const surrogate = findUnicodeEmoji(text, startIndex);
if (shortcode == null) {
return surrogate == null ? null : {...surrogate, surrogate: true};
}
if (surrogate == null) {
return {...shortcode, surrogate: false};
}
return surrogate.start < shortcode.start ? {...surrogate, surrogate: true} : {...shortcode, surrogate: false};
}
function isEscapedAt(text: string, index: number): boolean {
let backslashes = 0;
let cursor = index - 1;
while (cursor >= 0 && text[cursor] === '\\') {
backslashes += 1;
cursor -= 1;
}
return backslashes % 2 === 1;
}
export function $convertEmojiShortcode(node: TextNode, resolve: ComposerEmojiResolver): void {
if ($isSyntaxMarkerNode(node) || node.hasFormat('code')) {
return;
}
const parent = node.getParent();
if (parent == null || (parent.getType() !== 'paragraph' && !$isComposerBlockquoteLineNode(parent))) {
return;
}
const text = node.getTextContent();
let searchFrom = 0;
while (true) {
const match = findNextEmojiToken(text, searchFrom);
if (match == null) {
return;
}
const codeMarkers = text.slice(0, match.start).match(/`/g);
if ((codeMarkers == null ? 0 : codeMarkers.length) % 2 === 1) {
return;
}
if (isEscapedAt(text, match.start)) {
if (match.surrogate) {
const shortcodeText = `:${match.name}:`;
const selection = $captureSelectionOffsets();
const nodeDisplayStart = $getComposerNodeDisplayStart(node);
const replaced = `${text.slice(0, match.start)}${shortcodeText}${text.slice(match.end)}`;
node.setTextContent(replaced);
if (selection != null) {
const adjusted = $adjustEmbedCaret(selection, nodeDisplayStart, match.start, match.end, shortcodeText.length);
$selectComposerRange(adjusted.anchor, adjusted.focus);
}
return;
}
searchFrom = match.end;
continue;
}
const previous = match.start === 0 ? node.getPreviousSibling() : null;
if (/^skin-tone-[1-5]$/.test(match.name) && $isComposerStandardEmojiNode(previous)) {
const baseName = previous.getEmojiName().split('::')[0]!;
const combined = resolve(`${baseName}::${match.name}`);
if (combined != null && combined.kind === 'standard') {
const selection = $captureSelectionOffsets();
const previousDisplayStart = $getComposerNodeDisplayStart(previous);
const previousOldLen = previous.getTextContentSize();
const segments = node.splitText(match.start, match.end);
const target = segments[0];
if (target == null) {
return;
}
const replacement = $createComposerStandardEmojiNode(
combined.name,
combined.surrogate,
combined.url,
combined.display,
);
previous.replace(replacement);
target.remove();
if (selection != null && previousDisplayStart != null) {
const adjusted = $adjustEmbedCaret(
selection,
previousDisplayStart,
0,
previousOldLen + (match.end - match.start),
combined.display.length,
);
$selectComposerRange(adjusted.anchor, adjusted.focus);
} else {
$selectComposerNodeBoundary(replacement, 'after');
}
return;
}
}
const resolved = resolve(match.name);
if (resolved == null) {
searchFrom = match.end;
continue;
}
const selection = $captureSelectionOffsets();
const nodeDisplayStart = $getComposerNodeDisplayStart(node);
const emojiNode =
resolved.kind === 'standard'
? $createComposerStandardEmojiNode(resolved.name, resolved.surrogate, resolved.url, resolved.display)
: $createComposerCustomEmojiNode(resolved.emojiId, resolved.animated, resolved.display, resolved.wire);
const segments = node.splitText(match.start, match.end);
const target = segments[match.start > 0 ? 1 : 0];
if (target == null) {
return;
}
target.replace(emojiNode);
if (selection != null) {
const adjusted = $adjustEmbedCaret(selection, nodeDisplayStart, match.start, match.end, resolved.display.length);
$selectComposerRange(adjusted.anchor, adjusted.focus);
}
return;
}
}
function $adjustEmbedCaret(
selection: {anchor: number; focus: number},
nodeDisplayStart: number | null,
tokenStart: number,
tokenEnd: number,
newLen: number,
): {anchor: number; focus: number} {
if (nodeDisplayStart == null) {
return selection;
}
const start = nodeDisplayStart + tokenStart;
const end = nodeDisplayStart + tokenEnd;
const delta = newLen - (tokenEnd - tokenStart);
const adjust = (offset: number): number => {
if (offset <= start) {
return offset;
}
if (offset >= end) {
return offset + delta;
}
return start + newLen;
};
return {anchor: adjust(selection.anchor), focus: adjust(selection.focus)};
}