🚧 Refactor markdown preview extension
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Extension } from '@codemirror/state';
|
||||
import { Extension, RangeSetBuilder } from '@codemirror/state';
|
||||
import {
|
||||
ViewPlugin,
|
||||
DecorationSet,
|
||||
@@ -7,138 +7,156 @@ import {
|
||||
ViewUpdate,
|
||||
WidgetType
|
||||
} from '@codemirror/view';
|
||||
import { isCursorInRange, iterateTreeInVisibleRanges } from '../util';
|
||||
import { isCursorInRange } from '../util';
|
||||
|
||||
/**
|
||||
* Emoji plugin that converts :emoji_name: to actual emoji characters.
|
||||
*
|
||||
* This plugin:
|
||||
* Features:
|
||||
* - Detects emoji patterns like :smile:, :heart:, etc.
|
||||
* - Replaces them with actual emoji characters
|
||||
* - Shows the original text when cursor is nearby
|
||||
* - Uses RangeSetBuilder for optimal performance
|
||||
*/
|
||||
export const emoji = (): Extension => [emojiPlugin, baseTheme];
|
||||
|
||||
/**
|
||||
* Common emoji mappings.
|
||||
* Extended from common emoji shortcodes.
|
||||
* Emoji regex pattern for matching :emoji_name: syntax.
|
||||
*/
|
||||
const EMOJI_MAP: { [key: string]: string } = {
|
||||
// Smileys & Emotion
|
||||
smile: '😄',
|
||||
smiley: '😃',
|
||||
grin: '😁',
|
||||
laughing: '😆',
|
||||
satisfied: '😆',
|
||||
sweat_smile: '😅',
|
||||
rofl: '🤣',
|
||||
joy: '😂',
|
||||
slightly_smiling_face: '🙂',
|
||||
upside_down_face: '🙃',
|
||||
wink: '😉',
|
||||
blush: '😊',
|
||||
innocent: '😇',
|
||||
smiling_face_with_three_hearts: '🥰',
|
||||
heart_eyes: '😍',
|
||||
star_struck: '🤩',
|
||||
kissing_heart: '😘',
|
||||
kissing: '😗',
|
||||
relaxed: '☺️',
|
||||
kissing_closed_eyes: '😚',
|
||||
kissing_smiling_eyes: '😙',
|
||||
smiling_face_with_tear: '🥲',
|
||||
yum: '😋',
|
||||
stuck_out_tongue: '😛',
|
||||
stuck_out_tongue_winking_eye: '😜',
|
||||
zany_face: '🤪',
|
||||
stuck_out_tongue_closed_eyes: '😝',
|
||||
money_mouth_face: '🤑',
|
||||
hugs: '🤗',
|
||||
hand_over_mouth: '🤭',
|
||||
shushing_face: '🤫',
|
||||
thinking: '🤔',
|
||||
zipper_mouth_face: '🤐',
|
||||
raised_eyebrow: '🤨',
|
||||
neutral_face: '😐',
|
||||
expressionless: '😑',
|
||||
no_mouth: '😶',
|
||||
smirk: '😏',
|
||||
unamused: '😒',
|
||||
roll_eyes: '🙄',
|
||||
grimacing: '😬',
|
||||
lying_face: '🤥',
|
||||
relieved: '😌',
|
||||
pensive: '😔',
|
||||
sleepy: '😪',
|
||||
drooling_face: '🤤',
|
||||
sleeping: '😴',
|
||||
|
||||
// Hearts
|
||||
heart: '❤️',
|
||||
orange_heart: '🧡',
|
||||
yellow_heart: '💛',
|
||||
green_heart: '💚',
|
||||
blue_heart: '💙',
|
||||
purple_heart: '💜',
|
||||
brown_heart: '🤎',
|
||||
black_heart: '🖤',
|
||||
white_heart: '🤍',
|
||||
|
||||
// Gestures
|
||||
'+1': '👍',
|
||||
thumbsup: '👍',
|
||||
'-1': '👎',
|
||||
thumbsdown: '👎',
|
||||
fist: '✊',
|
||||
facepunch: '👊',
|
||||
punch: '👊',
|
||||
wave: '👋',
|
||||
clap: '👏',
|
||||
raised_hands: '🙌',
|
||||
pray: '🙏',
|
||||
handshake: '🤝',
|
||||
|
||||
// Nature
|
||||
sun: '☀️',
|
||||
moon: '🌙',
|
||||
star: '⭐',
|
||||
fire: '🔥',
|
||||
zap: '⚡',
|
||||
sparkles: '✨',
|
||||
tada: '🎉',
|
||||
rocket: '🚀',
|
||||
trophy: '🏆',
|
||||
|
||||
// Symbols
|
||||
check: '✔️',
|
||||
x: '❌',
|
||||
warning: '⚠️',
|
||||
bulb: '💡',
|
||||
question: '❓',
|
||||
exclamation: '❗',
|
||||
heavy_check_mark: '✔️',
|
||||
|
||||
// Common
|
||||
eyes: '👀',
|
||||
eye: '👁️',
|
||||
brain: '🧠',
|
||||
muscle: '💪',
|
||||
ok_hand: '👌',
|
||||
point_right: '👉',
|
||||
point_left: '👈',
|
||||
point_up: '☝️',
|
||||
point_down: '👇',
|
||||
};
|
||||
const EMOJI_REGEX = /:([a-z0-9_+\-]+):/g;
|
||||
|
||||
/**
|
||||
* Widget to display emoji character.
|
||||
* Common emoji mappings.
|
||||
*/
|
||||
const EMOJI_MAP: Map<string, string> = new Map([
|
||||
// Smileys & Emotion
|
||||
['smile', '😄'],
|
||||
['smiley', '😃'],
|
||||
['grin', '😁'],
|
||||
['laughing', '😆'],
|
||||
['satisfied', '😆'],
|
||||
['sweat_smile', '😅'],
|
||||
['rofl', '🤣'],
|
||||
['joy', '😂'],
|
||||
['slightly_smiling_face', '🙂'],
|
||||
['upside_down_face', '🙃'],
|
||||
['wink', '😉'],
|
||||
['blush', '😊'],
|
||||
['innocent', '😇'],
|
||||
['smiling_face_with_three_hearts', '🥰'],
|
||||
['heart_eyes', '😍'],
|
||||
['star_struck', '🤩'],
|
||||
['kissing_heart', '😘'],
|
||||
['kissing', '😗'],
|
||||
['relaxed', '☺️'],
|
||||
['kissing_closed_eyes', '😚'],
|
||||
['kissing_smiling_eyes', '😙'],
|
||||
['smiling_face_with_tear', '🥲'],
|
||||
['yum', '😋'],
|
||||
['stuck_out_tongue', '😛'],
|
||||
['stuck_out_tongue_winking_eye', '😜'],
|
||||
['zany_face', '🤪'],
|
||||
['stuck_out_tongue_closed_eyes', '😝'],
|
||||
['money_mouth_face', '🤑'],
|
||||
['hugs', '🤗'],
|
||||
['hand_over_mouth', '🤭'],
|
||||
['shushing_face', '🤫'],
|
||||
['thinking', '🤔'],
|
||||
['zipper_mouth_face', '🤐'],
|
||||
['raised_eyebrow', '🤨'],
|
||||
['neutral_face', '😐'],
|
||||
['expressionless', '😑'],
|
||||
['no_mouth', '😶'],
|
||||
['smirk', '😏'],
|
||||
['unamused', '😒'],
|
||||
['roll_eyes', '🙄'],
|
||||
['grimacing', '😬'],
|
||||
['lying_face', '🤥'],
|
||||
['relieved', '😌'],
|
||||
['pensive', '😔'],
|
||||
['sleepy', '😪'],
|
||||
['drooling_face', '🤤'],
|
||||
['sleeping', '😴'],
|
||||
|
||||
// Hearts
|
||||
['heart', '❤️'],
|
||||
['orange_heart', '🧡'],
|
||||
['yellow_heart', '💛'],
|
||||
['green_heart', '💚'],
|
||||
['blue_heart', '💙'],
|
||||
['purple_heart', '💜'],
|
||||
['brown_heart', '🤎'],
|
||||
['black_heart', '🖤'],
|
||||
['white_heart', '🤍'],
|
||||
|
||||
// Gestures
|
||||
['+1', '👍'],
|
||||
['thumbsup', '👍'],
|
||||
['-1', '👎'],
|
||||
['thumbsdown', '👎'],
|
||||
['fist', '✊'],
|
||||
['facepunch', '👊'],
|
||||
['punch', '👊'],
|
||||
['wave', '👋'],
|
||||
['clap', '👏'],
|
||||
['raised_hands', '🙌'],
|
||||
['pray', '🙏'],
|
||||
['handshake', '🤝'],
|
||||
|
||||
// Nature
|
||||
['sun', '☀️'],
|
||||
['moon', '🌙'],
|
||||
['star', '⭐'],
|
||||
['fire', '🔥'],
|
||||
['zap', '⚡'],
|
||||
['sparkles', '✨'],
|
||||
['tada', '🎉'],
|
||||
['rocket', '🚀'],
|
||||
['trophy', '🏆'],
|
||||
|
||||
// Symbols
|
||||
['check', '✔️'],
|
||||
['x', '❌'],
|
||||
['warning', '⚠️'],
|
||||
['bulb', '💡'],
|
||||
['question', '❓'],
|
||||
['exclamation', '❗'],
|
||||
['heavy_check_mark', '✔️'],
|
||||
|
||||
// Common
|
||||
['eyes', '👀'],
|
||||
['eye', '👁️'],
|
||||
['brain', '🧠'],
|
||||
['muscle', '💪'],
|
||||
['ok_hand', '👌'],
|
||||
['point_right', '👉'],
|
||||
['point_left', '👈'],
|
||||
['point_up', '☝️'],
|
||||
['point_down', '👇'],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Reverse lookup map for emoji to name.
|
||||
*/
|
||||
const EMOJI_REVERSE_MAP = new Map<string, string>();
|
||||
EMOJI_MAP.forEach((emoji, name) => {
|
||||
if (!EMOJI_REVERSE_MAP.has(emoji)) {
|
||||
EMOJI_REVERSE_MAP.set(emoji, name);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Emoji widget with optimized rendering.
|
||||
*/
|
||||
class EmojiWidget extends WidgetType {
|
||||
constructor(readonly emoji: string) {
|
||||
constructor(
|
||||
readonly emoji: string,
|
||||
readonly name: string
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
eq(other: EmojiWidget) {
|
||||
eq(other: EmojiWidget): boolean {
|
||||
return other.emoji === this.emoji;
|
||||
}
|
||||
|
||||
@@ -146,62 +164,108 @@ class EmojiWidget extends WidgetType {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'cm-emoji';
|
||||
span.textContent = this.emoji;
|
||||
span.title = ':' + Object.keys(EMOJI_MAP).find(
|
||||
key => EMOJI_MAP[key] === this.emoji
|
||||
) + ':';
|
||||
span.title = `:${this.name}:`;
|
||||
return span;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin to render emoji.
|
||||
* Match result for emoji patterns.
|
||||
*/
|
||||
interface EmojiMatch {
|
||||
from: number;
|
||||
to: number;
|
||||
name: string;
|
||||
emoji: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all emoji matches in a text range.
|
||||
*/
|
||||
function findEmojiMatches(text: string, offset: number): EmojiMatch[] {
|
||||
const matches: EmojiMatch[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
// Reset regex state
|
||||
EMOJI_REGEX.lastIndex = 0;
|
||||
|
||||
while ((match = EMOJI_REGEX.exec(text)) !== null) {
|
||||
const name = match[1];
|
||||
const emoji = EMOJI_MAP.get(name);
|
||||
|
||||
if (emoji) {
|
||||
matches.push({
|
||||
from: offset + match.index,
|
||||
to: offset + match.index + match[0].length,
|
||||
name,
|
||||
emoji
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build emoji decorations using RangeSetBuilder.
|
||||
*/
|
||||
function buildEmojiDecorations(view: EditorView): DecorationSet {
|
||||
const builder = new RangeSetBuilder<Decoration>();
|
||||
const doc = view.state.doc;
|
||||
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
const text = doc.sliceString(from, to);
|
||||
const matches = findEmojiMatches(text, from);
|
||||
|
||||
for (const match of matches) {
|
||||
// Skip if cursor is in this range
|
||||
if (isCursorInRange(view.state, [match.from, match.to])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.add(
|
||||
match.from,
|
||||
match.to,
|
||||
Decoration.replace({
|
||||
widget: new EmojiWidget(match.emoji, match.name)
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return builder.finish();
|
||||
}
|
||||
|
||||
/**
|
||||
* Emoji plugin with optimized update detection.
|
||||
*/
|
||||
class EmojiPlugin {
|
||||
decorations: DecorationSet;
|
||||
private lastSelectionHead: number = -1;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = this.buildDecorations(view);
|
||||
this.decorations = buildEmojiDecorations(view);
|
||||
this.lastSelectionHead = view.state.selection.main.head;
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged || update.viewportChanged || update.selectionSet) {
|
||||
this.decorations = this.buildDecorations(update.view);
|
||||
// Always rebuild on doc or viewport change
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = buildEmojiDecorations(update.view);
|
||||
this.lastSelectionHead = update.state.selection.main.head;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private buildDecorations(view: EditorView): DecorationSet {
|
||||
const widgets: Array<ReturnType<Decoration['range']>> = [];
|
||||
const doc = view.state.doc;
|
||||
// For selection changes, check if we moved significantly
|
||||
if (update.selectionSet) {
|
||||
const newHead = update.state.selection.main.head;
|
||||
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
// Use regex to find :emoji: patterns
|
||||
const text = doc.sliceString(from, to);
|
||||
const emojiRegex = /:([a-z0-9_+\-]+):/g;
|
||||
let match;
|
||||
|
||||
while ((match = emojiRegex.exec(text)) !== null) {
|
||||
const matchStart = from + match.index;
|
||||
const matchEnd = matchStart + match[0].length;
|
||||
|
||||
// Skip if cursor is in this range
|
||||
if (isCursorInRange(view.state, [matchStart, matchEnd])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const emojiName = match[1];
|
||||
const emojiChar = EMOJI_MAP[emojiName];
|
||||
|
||||
if (emojiChar) {
|
||||
// Replace the :emoji: with the actual emoji
|
||||
const widget = Decoration.replace({
|
||||
widget: new EmojiWidget(emojiChar)
|
||||
});
|
||||
widgets.push(widget.range(matchStart, matchEnd));
|
||||
}
|
||||
// Only rebuild if cursor moved to a different position
|
||||
if (newHead !== this.lastSelectionHead) {
|
||||
this.decorations = buildEmojiDecorations(update.view);
|
||||
this.lastSelectionHead = newHead;
|
||||
}
|
||||
}
|
||||
|
||||
return Decoration.set(widgets, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,13 +291,20 @@ const baseTheme = EditorView.baseTheme({
|
||||
* @param emoji - Emoji character
|
||||
*/
|
||||
export function addEmoji(name: string, emoji: string): void {
|
||||
EMOJI_MAP[name] = emoji;
|
||||
EMOJI_MAP.set(name, emoji);
|
||||
EMOJI_REVERSE_MAP.set(emoji, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available emoji names.
|
||||
*/
|
||||
export function getEmojiNames(): string[] {
|
||||
return Object.keys(EMOJI_MAP);
|
||||
return Array.from(EMOJI_MAP.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get emoji by name.
|
||||
*/
|
||||
export function getEmoji(name: string): string | undefined {
|
||||
return EMOJI_MAP.get(name);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user