diff --git a/bun.lock b/bun.lock index e17e35a..069e4ec 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index ce7681d..bd533ca 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/export/renderMarkdown.ts b/src/export/renderMarkdown.ts index de6a049..8a1c5c5 100644 --- a/src/export/renderMarkdown.ts +++ b/src/export/renderMarkdown.ts @@ -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)); diff --git a/webview/src/helpers/emoji.ts b/webview/src/helpers/emoji.ts new file mode 100644 index 0000000..6669a3b --- /dev/null +++ b/webview/src/helpers/emoji.ts @@ -0,0 +1,121 @@ +export const emojiData: Record = { + 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; +} diff --git a/webview/src/liveMode.ts b/webview/src/liveMode.ts index 8c8fde4..c56b1a0 100644 --- a/webview/src/liveMode.ts +++ b/webview/src/liveMode.ts @@ -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(); + +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');