Files
voidraft/frontend/src/views/editor/extensions/markdown/plugins/emoji.ts

197 lines
4.6 KiB
TypeScript

import { Extension, RangeSetBuilder } from '@codemirror/state';
import {
ViewPlugin,
DecorationSet,
Decoration,
EditorView,
ViewUpdate,
WidgetType
} from '@codemirror/view';
import { checkRangeOverlap, RangeTuple } from '../util';
import { emojies } from '@/common/constant/emojies';
/**
* Emoji plugin that converts :emoji_name: to actual emoji characters.
*
* Features:
* - Detects emoji patterns like :smile:, :heart:, etc.
* - Replaces them with actual emoji characters
* - Shows the original text when cursor is nearby
* - Optimized with cached matches and minimal rebuilds
*/
export const emoji = (): Extension => [emojiPlugin, baseTheme];
/** Non-global regex for matchAll (more efficient than global with lastIndex reset) */
const EMOJI_REGEX = /:([a-z0-9_+\-]+):/gi;
/**
* Emoji widget with optimized rendering.
*/
class EmojiWidget extends WidgetType {
constructor(
readonly emoji: string,
readonly name: string
) {
super();
}
eq(other: EmojiWidget): boolean {
return other.emoji === this.emoji;
}
toDOM(): HTMLElement {
const span = document.createElement('span');
span.className = 'cm-emoji';
span.textContent = this.emoji;
span.title = `:${this.name}:`;
return span;
}
}
/**
* Cached emoji match.
*/
interface EmojiMatch {
from: number;
to: number;
name: string;
emoji: string;
}
/**
* Find all emoji matches in visible ranges.
*/
function findAllEmojiMatches(view: EditorView): EmojiMatch[] {
const matches: EmojiMatch[] = [];
const doc = view.state.doc;
for (const { from, to } of view.visibleRanges) {
const text = doc.sliceString(from, to);
let match: RegExpExecArray | null;
EMOJI_REGEX.lastIndex = 0;
while ((match = EMOJI_REGEX.exec(text)) !== null) {
const name = match[1].toLowerCase();
const emojiChar = emojies[name];
if (emojiChar) {
matches.push({
from: from + match.index,
to: from + match.index + match[0].length,
name,
emoji: emojiChar
});
}
}
}
return matches;
}
/**
* Get which emoji the cursor is in (-1 if none).
*/
function getCursorEmojiIndex(matches: EmojiMatch[], selFrom: number, selTo: number): number {
const selRange: RangeTuple = [selFrom, selTo];
for (let i = 0; i < matches.length; i++) {
if (checkRangeOverlap([matches[i].from, matches[i].to], selRange)) {
return i;
}
}
return -1;
}
/**
* Build decorations from cached matches.
*/
function buildDecorations(matches: EmojiMatch[], selFrom: number, selTo: number): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
const selRange: RangeTuple = [selFrom, selTo];
for (const match of matches) {
// Skip if cursor overlaps this emoji
if (checkRangeOverlap([match.from, match.to], selRange)) {
continue;
}
builder.add(
match.from,
match.to,
Decoration.replace({
widget: new EmojiWidget(match.emoji, match.name)
})
);
}
return builder.finish();
}
/**
* Emoji plugin with cached matches and optimized updates.
*/
class EmojiPlugin {
decorations: DecorationSet;
private matches: EmojiMatch[] = [];
private cursorEmojiIdx = -1;
constructor(view: EditorView) {
this.matches = findAllEmojiMatches(view);
const { from, to } = view.state.selection.main;
this.cursorEmojiIdx = getCursorEmojiIndex(this.matches, from, to);
this.decorations = buildDecorations(this.matches, from, to);
}
update(update: ViewUpdate) {
const { docChanged, viewportChanged, selectionSet } = update;
// Rebuild matches on doc or viewport change
if (docChanged || viewportChanged) {
this.matches = findAllEmojiMatches(update.view);
const { from, to } = update.state.selection.main;
this.cursorEmojiIdx = getCursorEmojiIndex(this.matches, from, to);
this.decorations = buildDecorations(this.matches, from, to);
return;
}
// For selection changes, only rebuild if cursor enters/leaves an emoji
if (selectionSet) {
const { from, to } = update.state.selection.main;
const newIdx = getCursorEmojiIndex(this.matches, from, to);
if (newIdx !== this.cursorEmojiIdx) {
this.cursorEmojiIdx = newIdx;
this.decorations = buildDecorations(this.matches, from, to);
}
}
}
}
const emojiPlugin = ViewPlugin.fromClass(EmojiPlugin, {
decorations: (v) => v.decorations
});
/**
* Base theme for emoji.
*/
const baseTheme = EditorView.baseTheme({
'.cm-emoji': {
verticalAlign: 'middle',
cursor: 'default'
}
});
/**
* Get all available emoji names.
*/
export function getEmojiNames(): string[] {
return Object.keys(emojies);
}
/**
* Get emoji by name.
*/
export function getEmoji(name: string): string | undefined {
return emojies[name.toLowerCase()];
}