mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
193 lines
6.5 KiB
TypeScript
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)};
|
|
}
|