feat: add emoji support

This commit is contained in:
Vadim Melnicuk
2026-02-28 09:05:46 +00:00
parent 7e0b32d8b4
commit 325cb89452
5 changed files with 174 additions and 0 deletions
+3
View File
@@ -22,6 +22,7 @@
"highlight.js": "^11.11.1",
"lucide": "^0.564.0",
"markdown-it": "^14.1.0",
"markdown-it-emoji": "^3.0.0",
"mermaid": "^11.4.1",
"puppeteer-core": "^24.1.1",
"sanitize-html": "^2.17.0",
@@ -399,6 +400,8 @@
"markdown-it": ["markdown-it@14.1.1", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA=="],
"markdown-it-emoji": ["markdown-it-emoji@3.0.0", "", {}, "sha512-+rUD93bXHubA4arpEZO3q80so0qgoFJEKRkRbjKX8RTdca89v2kfyF+xR3i2sQTwql9tpPZPOQN5B+PunspXRg=="],
"marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
"mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="],
+1
View File
@@ -292,6 +292,7 @@
"highlight.js": "^11.11.1",
"lucide": "^0.564.0",
"markdown-it": "^14.1.0",
"markdown-it-emoji": "^3.0.0",
"mermaid": "^11.4.1",
"puppeteer-core": "^24.1.1",
"sanitize-html": "^2.17.0"
+2
View File
@@ -1,4 +1,5 @@
import MarkdownIt from 'markdown-it';
import { light as emoji } from 'markdown-it-emoji';
import hljs from 'highlight.js';
import sanitizeHtml from 'sanitize-html';
import { rewriteExportImageSrc } from './assetPaths';
@@ -53,6 +54,7 @@ export function renderMarkdownToHtml(options: RenderMarkdownOptions): RenderMark
].join('');
}
});
md.use(emoji);
installTaskListTransform(md);
const defaultImageRule = md.renderer.rules.image ?? ((tokens, idx, opts, _env, self) => self.renderToken(tokens, idx, opts));
+121
View File
@@ -0,0 +1,121 @@
export const emojiData: Record<string, string> = {
smile: '😄', grinning: '😁', joy: '😂', rofl: '🤣', sob: '😭',
angry: '😠', rage: '😡',
heart: '❤️', pink_heart: '💗', broken_heart: '💔',
orange_heart: '🧡', yellow_heart: '💛', green_heart: '💚',
blue_heart: '💙', purple_heart: '💜', black_heart: '🖤',
white_heart: '🤍', brown_heart: '🤎',
star: '⭐', stars: '🌟', sparkles: '✨', zap: '⚡', fire: '🔥',
hundred: '💯', check: '✅', x: '❌', warning: '⚠️',
plus: '', minus: '', divided: '➗', multiply: '✖️',
arrow_right: '➡️', arrow_left: '⬅️', arrow_up: '⬆️', arrow_down: '⬇️',
pencil: '✏️', memo: '📝', page_facing_up: '📄', link: '🔗',
paperclip: '📎', scissors: '✂️', bomb: '💣', boom: '💥', dizzy: '💫',
droplet: '💧', cloud: '☀️', sunny: '☀️', moon: '🌙', snowflake: '❄️',
snowman: '⛄', umbrella: '☔', rainbow: '🌈',
earth_americas: '🌎', earth_africa: '🌍', earth_asia: '🌏', globe: '🌐',
skull: '💀', alien: '👽', robot: '🤖', ghost: '👻', santa: '🎅',
angel: '👼', boy: '👦', girl: '👧', woman: '👩', man: '👨', baby: '👶',
thumbsup: '👍', thumbsdown: '👎', punch: '👊', fist: '👊', wave: '👋',
raised_hand: '✋', clap: '👏', pray: '🙏', muscle: '💪',
ear: '👂', eye: '👁️', eyes: '👀', tongue: '👅', kiss: '💋',
cupid: '💘', heart_with_ribbon: '💝', speech_balloon: '💬',
thought_balloon: '💭', bell: '🔔', musical_note: '🎵',
phone: '📞', envelope: '✉️',
pizza: '🍕', hamburger: '🍔', fries: '🍟', spaghetti: '🍝',
taco: '🌮', burrito: '🌯', popcorn: '🍿', egg: '🥚', rice: '🍚',
sushi: '🍣', bread: '🍞', cake: '🍰', cookie: '🍪', candy: '🍬',
lollipop: '🍭', coffee: '☕', tea: '☕', beer: '🍺', beers: '🍻',
wine_glass: '🍷', cocktail: '🍸', tropical_drink: '🍹',
strawberry: '🍓', grapes: '🍇', watermelon: '🍉', lemon: '🍋',
banana: '🍌', apple: '🍎', cherry: '🍒', tomato: '🍅', corn: '🌽',
house: '🏠', school: '🏫', hospital: '🏥', bank: '🏦', hotel: '🏨',
church: '⛪', mosque: '🕌', fountain: '⛲', tent: '⛺',
car: '🚗', taxi: '🚕', bus: '🚌', train: '🚃', airplane: '✈️',
boat: '⛵', ship: '🚢', ambulance: '🚑', fire_engine: '🚒',
police_car: '🚓', trophy: '🏆', medal: '🏅', flag: '🏴',
sun: '☀️', mountain: '⛰️', volcano: '🌋', beach: '🏖️',
palm_tree: '🌴', cactus: '🌵', flower: '🌸', rose: '🌹', tulip: '🌷',
dog: '🐶', cat: '🐱', mouse: '🐭', rabbit: '🐰', bear: '🐻',
panda: '🐼', koala: '🐨', tiger: '🐯', lion: '🦁', cow: '🐮',
pig: '🐷', frog: '🐸', monkey: '🐵', chicken: '🐔', penguin: '🐧',
bird: '🐦', eagle: '🦅', owl: '🦉', wolf: '🐺', bee: '🐝',
bug: '🐛', snail: '🐌', butterfly: '🦋', fish: '🐟', dolphin: '🐬',
whale: '🐳', octopus: '🐙', dragon: '🐉', snake: '🐍', horse: '🐴',
unicorn: '🦄', bull: '🐂', ram: '🐏', zebra: '🦓', deer: '🦌',
camel: '🐪', elephant: '🐘',
horse_racing: '🏇', skiing: '⛷️', surfing: '🏄', swimming: '🏊',
fishing: '🎣', boxing: '🥊', basketball: '🏀', football: '🏈',
baseball: '⚾', soccer: '⚽', tennis: '🎾', volleyball: '🏐',
golf: '⛳', running: '🏃', walking: '🚶', dancing: '💃',
cyclist: '🚴', person_lifting: '🏋️', bath: '🛀', sleeping: '😴',
toilet: '🚽', shower: '🚿', bedroom: '🛏️', couch: '🛋️',
clock: '🕐', alarm: '⏰', hourglass: '⏳', calendar: '📅', watch: '⌚',
key: '🔑', lock: '🔒', unlock: '🔓', bulb: '💡', flashlight: '🔦',
candle: '🕯️', money: '💵', credit_card: '💳', gift: '🎁',
balloon: '🎈', confetti: '🎊', tada: '🎉', movie: '🎬', game: '🎮',
dice: '🎲', chess: '♟️', guitar: '🎸', piano: '🎹', violin: '🎻',
trumpet: '🎺', saxophone: '🎷', drum: '🥁', art: '🎨',
camera: '📷', video: '📹', tv: '📺', smartphone: '📱', computer: '💻',
keyboard: '⌨️', floppy_disk: '💾', cd: '💿', dvd: '📀',
microscope: '🔬', telescope: '🔭', book: '📖', books: '📚',
newspaper: '📰', notebook: '📓', pen: '🖊️', money_with_wings: '💸',
wc: '🚾', wheelchair: '♿', no_smoking: '🚭', potable_water: '🚰',
mens: '🚹', womens: '🚺', restroom: '🚻', baby_symbol: '🚼',
door: '🚪', coin: '🪙', yen: '💴',
keycap_0: '0️⃣', keycap_1: '1️⃣', keycap_2: '2️⃣', keycap_3: '3️⃣',
keycap_4: '4️⃣', keycap_5: '5️⃣', keycap_6: '6️⃣', keycap_7: '7️⃣',
keycap_8: '8️⃣', keycap_9: '9️⃣', keycap_10: '🔟',
hash: '#️⃣', asterisk: '*️⃣',
a: '🅰️', b: '🅱️', o: '⭕', o2: '🅾️',
negative_squared_cross_mark: '❎', white_check_mark: '✅',
black_square_button: '🔲', white_square_button: '🔳',
diamond_shape_with_a_dot_inside: '💠',
black_medium_square: '◼️', white_medium_square: '◻️',
black_medium_small_square: '◾', white_medium_small_square: '◽',
black_small_square: '▪️', white_small_square: '▫️',
large_red_circle: '🔴', large_blue_circle: '🔵',
large_orange_diamond: '🔶', large_blue_diamond: '🔷',
small_orange_diamond: '🔸', small_blue_diamond: '🔹',
red_triangle_pointed_down: '🔻', red_triangle_pointed_up: '🔺',
diamond: '💎', checkered_flag: '🏁',
flag_us: '🇺🇸', flag_gb: '🇬🇧', flag_ca: '🇨🇦', flag_au: '🇦🇺',
flag_de: '🇩🇪', flag_fr: '🇫🇷', flag_it: '🇮🇹', flag_jp: '🇯🇵',
flag_kr: '🇰🇷', flag_cn: '🇨🇳', flag_br: '🇧🇷', flag_in: '🇮🇳',
flag_mx: '🇲🇽', flag_es: '🇪🇸', flag_ru: '🇷🇺', flag_za: '🇿🇦',
flag_ae: '🇦🇪', flag_sg: '🇸🇬', flag_hk: '🇭🇰', flag_tw: '🇹🇼',
flag_nl: '🇳🇱', flag_se: '🇸🇪', flag_no: '🇳🇴', flag_dk: '🇩🇰',
flag_fi: '🇫🇮', flag_ch: '🇨🇭', flag_be: '🇧🇪', flag_at: '🇦🇹',
flag_pt: '🇵🇹', flag_pl: '🇵🇱', flag_gr: '🇬🇷', flag_cz: '🇨🇿',
flag_ro: '🇷🇴', flag_th: '🇹🇭', flag_vn: '🇻🇳', flag_id: '🇮🇩',
flag_my: '🇲🇾', flag_ph: '🇵🇭', flag_tr: '🇹🇷', flag_il: '🇮🇱',
flag_eg: '🇪🇬', flag_ua: '🇺🇦', flag_ar: '🇦🇷'
};
export interface EmojiRange {
emoji: string;
from: number;
to: number;
}
const emojiPattern = /:([a-zA-Z0-9_+-]+):/g;
export function collectEmojiRangesFromText(text: string, lineFrom: number): EmojiRange[] {
const ranges: EmojiRange[] = [];
emojiPattern.lastIndex = 0;
let match;
while ((match = emojiPattern.exec(text)) !== null) {
const emoji = emojiData[match[1]];
if (emoji) {
ranges.push({
emoji,
from: lineFrom + match.index,
to: lineFrom + match.index + match[0].length
});
}
}
return ranges;
}
+47
View File
@@ -16,6 +16,7 @@ import {
import { ImageWidget, getImageData, isImageUrl } from './helpers/images';
import { highlightStyle } from './theme';
import { collectSingleTildeStrikePairs, collectStrikethroughRanges } from './helpers/strikeMarkers';
import { collectEmojiRangesFromText } from './helpers/emoji';
import { headingLevelFromName, resolvedSyntaxTree } from './helpers/markdownSyntax';
import {
getCollapsedHeadingSections,
@@ -843,6 +844,7 @@ function buildDecorations(state) {
addFallbackTableDecorations(ranges, state, tree, parsedTableRanges);
addSingleTildeStrikeDecorations(ranges, state, activeLines, strikeRanges, codeBlockLines);
addListLineDecorations(ranges, state, indentSelectedLines, frontmatter, codeBlockLines);
addEmojiDecorations(ranges, state, codeBlockLines);
for (const section of collapsedHeadingSections) {
addLineClass(ranges, state, section.lineFrom, section.lineTo, collapsedHeadingLineDeco);
addRange(ranges, section.collapseFrom, section.collapseTo, collapsedHeadingBodyDeco);
@@ -940,6 +942,51 @@ function collectCodeBlockLines(state, tree) {
return lines;
}
const emojiWidgetCache = new Map<string, WidgetType>();
function getEmojiWidget(emoji: string): WidgetType {
let widget = emojiWidgetCache.get(emoji);
if (!widget) {
widget = new (class extends WidgetType {
toDOM() {
const span = document.createElement('span');
span.className = 'meo-md-emoji';
span.textContent = emoji;
return span;
}
ignoreEvent() {
return true;
}
})();
emojiWidgetCache.set(emoji, widget);
}
return widget;
}
function addEmojiDecorations(builder, state, codeBlockLines = null) {
for (let lineNo = 1; lineNo <= state.doc.lines; lineNo += 1) {
if (codeBlockLines?.has(lineNo)) {
continue;
}
const line = state.doc.line(lineNo);
const lineText = state.doc.sliceString(line.from, line.to);
if (lineText.indexOf(':') === -1) {
continue;
}
const emojiRanges = collectEmojiRangesFromText(lineText, line.from);
for (const emojiRange of emojiRanges) {
builder.push(
Decoration.replace({
widget: getEmojiWidget(emojiRange.emoji),
inclusive: false
}).range(emojiRange.from, emojiRange.to)
);
}
}
}
const liveDecorationField = StateField.define({
create(state) {
return safeBuildDecorations(state, Decoration.none, 'create');