🚧 Refactor markdown preview extension

This commit is contained in:
2025-11-30 01:09:31 +08:00
parent 1ef5350b3f
commit 60d1494d45
34 changed files with 3109 additions and 6758 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -53,9 +53,7 @@
"@codemirror/view": "^6.38.8",
"@cospaia/prettier-plugin-clojure": "^0.0.2",
"@lezer/highlight": "^1.2.3",
"@lezer/lr": "^1.4.3",
"@mdit/plugin-katex": "^0.23.2",
"@mdit/plugin-tasklist": "^0.22.2",
"@lezer/lr": "^1.4.4",
"@prettier/plugin-xml": "^3.4.2",
"@replit/codemirror-lang-svelte": "^6.0.0",
"@toml-tools/lexer": "^1.0.0",
@@ -68,15 +66,15 @@
"hsl-matcher": "^1.2.4",
"java-parser": "^3.0.1",
"linguist-languages": "^9.1.0",
"marked": "^17.0.1",
"mermaid": "^11.12.1",
"npm": "^11.6.3",
"php-parser": "^3.2.5",
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
"prettier": "^3.6.2",
"prettier": "^3.7.2",
"sass": "^1.94.2",
"vue": "^3.5.24",
"vue-i18n": "^11.2.1",
"vue": "^3.5.25",
"vue-i18n": "^11.2.2",
"vue-pick-colors": "^1.8.0",
"vue-router": "^4.6.3"
},
@@ -88,18 +86,18 @@
"@wailsio/runtime": "latest",
"cross-env": "^10.1.0",
"eslint": "^9.39.1",
"eslint-plugin-vue": "^10.6.0",
"eslint-plugin-vue": "^10.6.2",
"globals": "^16.5.0",
"happy-dom": "^20.0.10",
"happy-dom": "^20.0.11",
"typescript": "^5.9.3",
"typescript-eslint": "^8.47.0",
"typescript-eslint": "^8.48.0",
"unplugin-vue-components": "^30.0.0",
"vite": "npm:rolldown-vite@latest",
"vite-plugin-node-polyfills": "^0.24.0",
"vitepress": "^2.0.0-alpha.12",
"vitest": "^4.0.13",
"vitest": "^4.0.14",
"vue-eslint-parser": "^10.2.0",
"vue-tsc": "^3.1.4"
"vue-tsc": "^3.1.5"
},
"overrides": {
"vite": "npm:rolldown-vite@latest"

View File

@@ -61,6 +61,19 @@
/* Markdown 内联代码样式 */
--cm-inline-code-bg: oklch(28% 0.02 255);
/* Markdown 上标/下标样式 */
--cm-superscript-color: inherit;
--cm-subscript-color: inherit;
/* Markdown 高亮样式 */
--cm-highlight-background: rgba(250, 204, 21, 0.35);
/* Markdown 脚注样式 */
--cm-footnote-ref-color: #818cf8;
--cm-footnote-ref-hover-bg: rgba(129, 140, 248, 0.15);
--cm-footnote-undefined-color: #f87171;
--cm-footnote-def-color: #818cf8;
}
/* 亮色主题 */
@@ -120,6 +133,19 @@
/* Markdown 内联代码样式 */
--cm-inline-code-bg: oklch(92.9% 0.013 255.508);
/* Markdown 上标/下标样式 */
--cm-superscript-color: inherit;
--cm-subscript-color: inherit;
/* Markdown 高亮样式 */
--cm-highlight-background: rgba(253, 224, 71, 0.45);
/* Markdown 脚注样式 */
--cm-footnote-ref-color: #6366f1;
--cm-footnote-ref-hover-bg: rgba(99, 102, 241, 0.15);
--cm-footnote-undefined-color: #ef4444;
--cm-footnote-def-color: #6366f1;
}
/* 跟随系统的浅色偏好 */
@@ -180,5 +206,18 @@
/* Markdown 内联代码样式 */
--cm-inline-code-bg: oklch(92.9% 0.013 255.508);
/* Markdown 上标/下标样式 */
--cm-superscript-color: inherit;
--cm-subscript-color: inherit;
/* Markdown 高亮样式 */
--cm-highlight-background: rgba(253, 224, 71, 0.45);
/* Markdown 脚注样式 */
--cm-footnote-ref-color: #6366f1;
--cm-footnote-ref-hover-bg: rgba(99, 102, 241, 0.15);
--cm-footnote-undefined-color: #ef4444;
--cm-footnote-def-color: #6366f1;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,159 +0,0 @@
// Enclose abbreviations in <abbr> tags
//
import MarkdownIt, {StateBlock, StateCore, Token} from 'markdown-it';
/**
* 环境接口,包含缩写定义
*/
interface AbbrEnv {
abbreviations?: { [key: string]: string };
}
/**
* markdown-it-abbr 插件
* 用于支持缩写语法
*/
export default function abbr_plugin(md: MarkdownIt): void {
const escapeRE = md.utils.escapeRE;
const arrayReplaceAt = md.utils.arrayReplaceAt;
// ASCII characters in Cc, Sc, Sm, Sk categories we should terminate on;
// you can check character classes here:
// http://www.unicode.org/Public/UNIDATA/UnicodeData.txt
const OTHER_CHARS = ' \r\n$+<=>^`|~';
const UNICODE_PUNCT_RE = md.utils.lib.ucmicro.P.source;
const UNICODE_SPACE_RE = md.utils.lib.ucmicro.Z.source;
function abbr_def(state: StateBlock, startLine: number, endLine: number, silent: boolean): boolean {
let labelEnd: number;
let pos = state.bMarks[startLine] + state.tShift[startLine];
const max = state.eMarks[startLine];
if (pos + 2 >= max) { return false; }
if (state.src.charCodeAt(pos++) !== 0x2A/* * */) { return false; }
if (state.src.charCodeAt(pos++) !== 0x5B/* [ */) { return false; }
const labelStart = pos;
for (; pos < max; pos++) {
const ch = state.src.charCodeAt(pos);
if (ch === 0x5B /* [ */) {
return false;
} else if (ch === 0x5D /* ] */) {
labelEnd = pos;
break;
} else if (ch === 0x5C /* \ */) {
pos++;
}
}
if (labelEnd! < 0 || state.src.charCodeAt(labelEnd! + 1) !== 0x3A/* : */) {
return false;
}
if (silent) { return true; }
const label = state.src.slice(labelStart, labelEnd!).replace(/\\(.)/g, '$1');
const title = state.src.slice(labelEnd! + 2, max).trim();
if (label.length === 0) { return false; }
if (title.length === 0) { return false; }
const env = state.env as AbbrEnv;
if (!env.abbreviations) { env.abbreviations = {}; }
// prepend ':' to avoid conflict with Object.prototype members
if (typeof env.abbreviations[':' + label] === 'undefined') {
env.abbreviations[':' + label] = title;
}
state.line = startLine + 1;
return true;
}
function abbr_replace(state: StateCore): void {
const blockTokens = state.tokens;
const env = state.env as AbbrEnv;
if (!env.abbreviations) { return; }
const regSimple = new RegExp('(?:' +
Object.keys(env.abbreviations).map(function (x: string) {
return x.substr(1);
}).sort(function (a: string, b: string) {
return b.length - a.length;
}).map(escapeRE).join('|') +
')');
const regText = '(^|' + UNICODE_PUNCT_RE + '|' + UNICODE_SPACE_RE +
'|[' + OTHER_CHARS.split('').map(escapeRE).join('') + '])' +
'(' + Object.keys(env.abbreviations).map(function (x: string) {
return x.substr(1);
}).sort(function (a: string, b: string) {
return b.length - a.length;
}).map(escapeRE).join('|') + ')' +
'($|' + UNICODE_PUNCT_RE + '|' + UNICODE_SPACE_RE +
'|[' + OTHER_CHARS.split('').map(escapeRE).join('') + '])'
const reg = new RegExp(regText, 'g');
for (let j = 0, l = blockTokens.length; j < l; j++) {
if (blockTokens[j].type !== 'inline') { continue; }
let tokens = blockTokens[j].children!;
// We scan from the end, to keep position when new tags added.
for (let i = tokens.length - 1; i >= 0; i--) {
const currentToken = tokens[i];
if (currentToken.type !== 'text') { continue; }
let pos = 0;
const text = currentToken.content;
reg.lastIndex = 0;
const nodes: Token[] = [];
// fast regexp run to determine whether there are any abbreviated words
// in the current token
if (!regSimple.test(text)) { continue; }
let m: RegExpExecArray | null;
while ((m = reg.exec(text))) {
if (m.index > 0 || m[1].length > 0) {
const token = new state.Token('text', '', 0);
token.content = text.slice(pos, m.index + m[1].length);
nodes.push(token);
}
const token_o = new state.Token('abbr_open', 'abbr', 1);
token_o.attrs = [['title', env.abbreviations[':' + m[2]]]];
nodes.push(token_o);
const token_t = new state.Token('text', '', 0);
token_t.content = m[2];
nodes.push(token_t);
const token_c = new state.Token('abbr_close', 'abbr', -1);
nodes.push(token_c);
reg.lastIndex -= m[3].length;
pos = reg.lastIndex;
}
if (!nodes.length) { continue; }
if (pos < text.length) {
const token = new state.Token('text', '', 0);
token.content = text.slice(pos);
nodes.push(token);
}
// replace current node
blockTokens[j].children = tokens = arrayReplaceAt(tokens, i, nodes);
}
}
}
md.block.ruler.before('reference', 'abbr_def', abbr_def, { alt: ['paragraph', 'reference'] });
md.core.ruler.after('linkify', 'abbr_replace', abbr_replace);
}

View File

@@ -1,209 +0,0 @@
// Process definition lists
//
import MarkdownIt, { StateBlock, Token } from 'markdown-it';
/**
* markdown-it-deflist 插件
* 用于支持定义列表语法
*/
export default function deflist_plugin(md: MarkdownIt): void {
const isSpace = md.utils.isSpace;
// Search `[:~][\n ]`, returns next pos after marker on success
// or -1 on fail.
function skipMarker(state: StateBlock, line: number): number {
let start = state.bMarks[line] + state.tShift[line];
const max = state.eMarks[line];
if (start >= max) { return -1; }
// Check bullet
const marker = state.src.charCodeAt(start++);
if (marker !== 0x7E/* ~ */ && marker !== 0x3A/* : */) { return -1; }
const pos = state.skipSpaces(start);
// require space after ":"
if (start === pos) { return -1; }
// no empty definitions, e.g. " : "
if (pos >= max) { return -1; }
return start;
}
function markTightParagraphs(state: StateBlock, idx: number): void {
const level = state.level + 2;
for (let i = idx + 2, l = state.tokens.length - 2; i < l; i++) {
if (state.tokens[i].level === level && state.tokens[i].type === 'paragraph_open') {
state.tokens[i + 2].hidden = true;
state.tokens[i].hidden = true;
i += 2;
}
}
}
function deflist(state: StateBlock, startLine: number, endLine: number, silent: boolean): boolean {
if (silent) {
// quirk: validation mode validates a dd block only, not a whole deflist
if (state.ddIndent < 0) { return false; }
return skipMarker(state, startLine) >= 0;
}
let nextLine = startLine + 1;
if (nextLine >= endLine) { return false; }
if (state.isEmpty(nextLine)) {
nextLine++;
if (nextLine >= endLine) { return false; }
}
if (state.sCount[nextLine] < state.blkIndent) { return false; }
let contentStart = skipMarker(state, nextLine);
if (contentStart < 0) { return false; }
// Start list
const listTokIdx = state.tokens.length;
let tight = true;
const token_dl_o: Token = state.push('dl_open', 'dl', 1);
const listLines: [number, number] = [startLine, 0];
token_dl_o.map = listLines;
//
// Iterate list items
//
let dtLine = startLine;
let ddLine = nextLine;
// One definition list can contain multiple DTs,
// and one DT can be followed by multiple DDs.
//
// Thus, there is two loops here, and label is
// needed to break out of the second one
//
/* eslint no-labels:0,block-scoped-var:0 */
OUTER:
for (;;) {
let prevEmptyEnd = false;
const token_dt_o: Token = state.push('dt_open', 'dt', 1);
token_dt_o.map = [dtLine, dtLine];
const token_i: Token = state.push('inline', '', 0);
token_i.map = [dtLine, dtLine];
token_i.content = state.getLines(dtLine, dtLine + 1, state.blkIndent, false).trim();
token_i.children = [];
state.push('dt_close', 'dt', -1);
for (;;) {
const token_dd_o: Token = state.push('dd_open', 'dd', 1);
const itemLines: [number, number] = [nextLine, 0];
token_dd_o.map = itemLines;
let pos = contentStart;
const max = state.eMarks[ddLine];
let offset = state.sCount[ddLine] + contentStart - (state.bMarks[ddLine] + state.tShift[ddLine]);
while (pos < max) {
const ch = state.src.charCodeAt(pos);
if (isSpace(ch)) {
if (ch === 0x09) {
offset += 4 - offset % 4;
} else {
offset++;
}
} else {
break;
}
pos++;
}
contentStart = pos;
const oldTight = state.tight;
const oldDDIndent = state.ddIndent;
const oldIndent = state.blkIndent;
const oldTShift = state.tShift[ddLine];
const oldSCount = state.sCount[ddLine];
const oldParentType = state.parentType;
state.blkIndent = state.ddIndent = state.sCount[ddLine] + 2;
state.tShift[ddLine] = contentStart - state.bMarks[ddLine];
state.sCount[ddLine] = offset;
state.tight = true;
state.parentType = 'deflist' as any;
state.md.block.tokenize(state, ddLine, endLine);
// If any of list item is tight, mark list as tight
if (!state.tight || prevEmptyEnd) {
tight = false;
}
// Item become loose if finish with empty line,
// but we should filter last element, because it means list finish
prevEmptyEnd = (state.line - ddLine) > 1 && state.isEmpty(state.line - 1);
state.tShift[ddLine] = oldTShift;
state.sCount[ddLine] = oldSCount;
state.tight = oldTight;
state.parentType = oldParentType;
state.blkIndent = oldIndent;
state.ddIndent = oldDDIndent;
state.push('dd_close', 'dd', -1);
itemLines[1] = nextLine = state.line;
if (nextLine >= endLine) { break OUTER; }
if (state.sCount[nextLine] < state.blkIndent) { break OUTER; }
contentStart = skipMarker(state, nextLine);
if (contentStart < 0) { break; }
ddLine = nextLine;
// go to the next loop iteration:
// insert DD tag and repeat checking
}
if (nextLine >= endLine) { break; }
dtLine = nextLine;
if (state.isEmpty(dtLine)) { break; }
if (state.sCount[dtLine] < state.blkIndent) { break; }
ddLine = dtLine + 1;
if (ddLine >= endLine) { break; }
if (state.isEmpty(ddLine)) { ddLine++; }
if (ddLine >= endLine) { break; }
if (state.sCount[ddLine] < state.blkIndent) { break; }
contentStart = skipMarker(state, ddLine);
if (contentStart < 0) { break; }
// go to the next loop iteration:
// insert DT and DD tags and repeat checking
}
// Finilize list
state.push('dl_close', 'dl', -1);
listLines[1] = nextLine;
state.line = nextLine;
// mark paragraphs tight if needed
if (tight) {
markTightParagraphs(state, listTokIdx);
}
return true;
}
md.block.ruler.before('paragraph', 'deflist', deflist, { alt: ['paragraph', 'reference', 'blockquote'] });
}

View File

@@ -1,4 +0,0 @@
export { default as bare } from './lib/bare';
export { default as light } from './lib/light';
export { default as full } from './lib/full';

View File

@@ -1,26 +0,0 @@
import MarkdownIt from 'markdown-it';
import emoji_html from './render';
import emoji_replace from './replace';
import normalize_opts, { EmojiOptions } from './normalize_opts';
/**
* Bare emoji 插件(不包含预定义的 emoji 数据)
*/
export default function emoji_plugin(md: MarkdownIt, options?: Partial<EmojiOptions>): void {
const defaults: EmojiOptions = {
defs: {},
shortcuts: {},
enabled: []
};
const opts = normalize_opts(md.utils.assign({}, defaults, options || {}) as EmojiOptions);
md.renderer.rules.emoji = emoji_html;
md.core.ruler.after(
'linkify',
'emoji',
emoji_replace(md, opts.defs, opts.shortcuts, opts.scanRE, opts.replaceRE)
);
}

View File

@@ -1,158 +0,0 @@
// Generated, don't edit
import { EmojiDefs } from '../normalize_opts';
const emojies: EmojiDefs = {
"grinning": "😀",
"smiley": "😃",
"smile": "😄",
"grin": "😁",
"laughing": "😆",
"satisfied": "😆",
"sweat_smile": "😅",
"joy": "😂",
"wink": "😉",
"blush": "😊",
"innocent": "😇",
"heart_eyes": "😍",
"kissing_heart": "😘",
"kissing": "😗",
"kissing_closed_eyes": "😚",
"kissing_smiling_eyes": "😙",
"yum": "😋",
"stuck_out_tongue": "😛",
"stuck_out_tongue_winking_eye": "😜",
"stuck_out_tongue_closed_eyes": "😝",
"neutral_face": "😐",
"expressionless": "😑",
"no_mouth": "😶",
"smirk": "😏",
"unamused": "😒",
"relieved": "😌",
"pensive": "😔",
"sleepy": "😪",
"sleeping": "😴",
"mask": "😷",
"dizzy_face": "😵",
"sunglasses": "😎",
"confused": "😕",
"worried": "😟",
"open_mouth": "😮",
"hushed": "😯",
"astonished": "😲",
"flushed": "😳",
"frowning": "😦",
"anguished": "😧",
"fearful": "😨",
"cold_sweat": "😰",
"disappointed_relieved": "😥",
"cry": "😢",
"sob": "😭",
"scream": "😱",
"confounded": "😖",
"persevere": "😣",
"disappointed": "😞",
"sweat": "😓",
"weary": "😩",
"tired_face": "😫",
"rage": "😡",
"pout": "😡",
"angry": "😠",
"smiling_imp": "😈",
"smiley_cat": "😺",
"smile_cat": "😸",
"joy_cat": "😹",
"heart_eyes_cat": "😻",
"smirk_cat": "😼",
"kissing_cat": "😽",
"scream_cat": "🙀",
"crying_cat_face": "😿",
"pouting_cat": "😾",
"heart": "❤️",
"hand": "✋",
"raised_hand": "✋",
"v": "✌️",
"point_up": "☝️",
"fist_raised": "✊",
"fist": "✊",
"monkey_face": "🐵",
"cat": "🐱",
"cow": "🐮",
"mouse": "🐭",
"coffee": "☕",
"hotsprings": "♨️",
"anchor": "⚓",
"airplane": "✈️",
"hourglass": "⌛",
"watch": "⌚",
"sunny": "☀️",
"star": "⭐",
"cloud": "☁️",
"umbrella": "☔",
"zap": "⚡",
"snowflake": "❄️",
"sparkles": "✨",
"black_joker": "🃏",
"mahjong": "🀄",
"phone": "☎️",
"telephone": "☎️",
"envelope": "✉️",
"pencil2": "✏️",
"black_nib": "✒️",
"scissors": "✂️",
"wheelchair": "♿",
"warning": "⚠️",
"aries": "♈",
"taurus": "♉",
"gemini": "♊",
"cancer": "♋",
"leo": "♌",
"virgo": "♍",
"libra": "♎",
"scorpius": "♏",
"sagittarius": "♐",
"capricorn": "♑",
"aquarius": "♒",
"pisces": "♓",
"heavy_multiplication_x": "✖️",
"heavy_plus_sign": "",
"heavy_minus_sign": "",
"heavy_division_sign": "➗",
"bangbang": "‼️",
"interrobang": "⁉️",
"question": "❓",
"grey_question": "❔",
"grey_exclamation": "❕",
"exclamation": "❗",
"heavy_exclamation_mark": "❗",
"wavy_dash": "〰️",
"recycle": "♻️",
"white_check_mark": "✅",
"ballot_box_with_check": "☑️",
"heavy_check_mark": "✔️",
"x": "❌",
"negative_squared_cross_mark": "❎",
"curly_loop": "➰",
"loop": "➿",
"part_alternation_mark": "〽️",
"eight_spoked_asterisk": "✳️",
"eight_pointed_black_star": "✴️",
"sparkle": "❇️",
"copyright": "©️",
"registered": "®️",
"tm": "™️",
"information_source": "",
"m": "Ⓜ️",
"black_circle": "⚫",
"white_circle": "⚪",
"black_large_square": "⬛",
"white_large_square": "⬜",
"black_medium_square": "◼️",
"white_medium_square": "◻️",
"black_medium_small_square": "◾",
"white_medium_small_square": "◽",
"black_small_square": "▪️",
"white_small_square": "▫️"
};
export default emojies;

View File

@@ -1,45 +0,0 @@
// Emoticons -> Emoji mapping.
//
// (!) Some patterns skipped, to avoid collisions
// without increase matcher complicity. Than can change in future.
//
// Places to look for more emoticons info:
//
// - http://en.wikipedia.org/wiki/List_of_emoticons#Western
// - https://github.com/wooorm/emoticon/blob/master/Support.md
// - http://factoryjoe.com/projects/emoticons/
//
import { EmojiShortcuts } from '../normalize_opts';
const shortcuts: EmojiShortcuts = {
angry: ['>:(', '>:-('],
blush: [':")', ':-")'],
broken_heart: ['</3', '<\\3'],
// :\ and :-\ not used because of conflict with markdown escaping
confused: [':/', ':-/'], // twemoji shows question
cry: [":'(", ":'-(", ':,(', ':,-('],
frowning: [':(', ':-('],
heart: ['<3'],
imp: [']:(', ']:-('],
innocent: ['o:)', 'O:)', 'o:-)', 'O:-)', '0:)', '0:-)'],
joy: [":')", ":'-)", ':,)', ':,-)', ":'D", ":'-D", ':,D', ':,-D'],
kissing: [':*', ':-*'],
laughing: ['x-)', 'X-)'],
neutral_face: [':|', ':-|'],
open_mouth: [':o', ':-o', ':O', ':-O'],
rage: [':@', ':-@'],
smile: [':D', ':-D'],
smiley: [':)', ':-)'],
smiling_imp: [']:)', ']:-)'],
sob: [":,'(", ":,'-(", ';(', ';-('],
stuck_out_tongue: [':P', ':-P'],
sunglasses: ['8-)', 'B-)'],
sweat: [',:(', ',:-('],
sweat_smile: [',:)', ',:-)'],
unamused: [':s', ':-S', ':z', ':-Z', ':$', ':-$'],
wink: [';)', ';-)']
};
export default shortcuts;

View File

@@ -1,21 +0,0 @@
import MarkdownIt from 'markdown-it';
import emojies_defs from './data/full';
import emojies_shortcuts from './data/shortcuts';
import bare_emoji_plugin from './bare';
import { EmojiOptions } from './normalize_opts';
/**
* Full emoji 插件(包含完整的 emoji 数据)
*/
export default function emoji_plugin(md: MarkdownIt, options?: Partial<EmojiOptions>): void {
const defaults: EmojiOptions = {
defs: emojies_defs,
shortcuts: emojies_shortcuts,
enabled: []
};
const opts = md.utils.assign({}, defaults, options || {}) as EmojiOptions;
bare_emoji_plugin(md, opts);
}

View File

@@ -1,21 +0,0 @@
import MarkdownIt from 'markdown-it';
import emojies_defs from './data/light';
import emojies_shortcuts from './data/shortcuts';
import bare_emoji_plugin from './bare';
import { EmojiOptions } from './normalize_opts';
/**
* Light emoji 插件(包含常用的 emoji 数据)
*/
export default function emoji_plugin(md: MarkdownIt, options?: Partial<EmojiOptions>): void {
const defaults: EmojiOptions = {
defs: emojies_defs,
shortcuts: emojies_shortcuts,
enabled: []
};
const opts = md.utils.assign({}, defaults, options || {}) as EmojiOptions;
bare_emoji_plugin(md, opts);
}

View File

@@ -1,95 +0,0 @@
/**
* Emoji 定义类型
*/
export interface EmojiDefs {
[key: string]: string;
}
/**
* Emoji 快捷方式类型
*/
export interface EmojiShortcuts {
[key: string]: string | string[];
}
/**
* 输入选项接口
*/
export interface EmojiOptions {
defs: EmojiDefs;
shortcuts: EmojiShortcuts;
enabled: string[];
}
/**
* 标准化后的选项接口
*/
export interface NormalizedEmojiOptions {
defs: EmojiDefs;
shortcuts: { [key: string]: string };
scanRE: RegExp;
replaceRE: RegExp;
}
/**
* 转义正则表达式特殊字符
*/
function quoteRE(str: string): string {
return str.replace(/[.?*+^$[\]\\(){}|-]/g, '\\$&');
}
/**
* 将输入选项转换为更可用的格式并编译搜索正则表达式
*/
export default function normalize_opts(options: EmojiOptions): NormalizedEmojiOptions {
let emojies = options.defs;
// Filter emojies by whitelist, if needed
if (options.enabled.length) {
emojies = Object.keys(emojies).reduce((acc: EmojiDefs, key: string) => {
if (options.enabled.indexOf(key) >= 0) acc[key] = emojies[key];
return acc;
}, {});
}
// Flatten shortcuts to simple object: { alias: emoji_name }
const shortcuts = Object.keys(options.shortcuts).reduce((acc: { [key: string]: string }, key: string) => {
// Skip aliases for filtered emojies, to reduce regexp
if (!emojies[key]) return acc;
if (Array.isArray(options.shortcuts[key])) {
(options.shortcuts[key] as string[]).forEach((alias: string) => { acc[alias] = key; });
return acc;
}
acc[options.shortcuts[key] as string] = key;
return acc;
}, {});
const keys = Object.keys(emojies);
let names: string;
// If no definitions are given, return empty regex to avoid replacements with 'undefined'.
if (keys.length === 0) {
names = '^$';
} else {
// Compile regexp
names = keys
.map((name: string) => { return `:${name}:`; })
.concat(Object.keys(shortcuts))
.sort()
.reverse()
.map((name: string) => { return quoteRE(name); })
.join('|');
}
const scanRE = RegExp(names);
const replaceRE = RegExp(names, 'g');
return {
defs: emojies,
shortcuts,
scanRE,
replaceRE
};
}

View File

@@ -1,9 +0,0 @@
import { Token } from 'markdown-it';
/**
* Emoji 渲染函数
*/
export default function emoji_html(tokens: Token[], idx: number): string {
return tokens[idx].content;
}

View File

@@ -1,97 +0,0 @@
import MarkdownIt, { StateCore, Token } from 'markdown-it';
import { EmojiDefs } from './normalize_opts';
/**
* Emoji 和快捷方式替换逻辑
*
* 注意:理论上,在内联链中解析 :smile: 并只留下快捷方式会更快。
* 但是,谁在乎呢...
*/
export default function create_rule(
md: MarkdownIt,
emojies: EmojiDefs,
shortcuts: { [key: string]: string },
scanRE: RegExp,
replaceRE: RegExp
) {
const arrayReplaceAt = md.utils.arrayReplaceAt;
const ucm = md.utils.lib.ucmicro;
const has = md.utils.has;
const ZPCc = new RegExp([ucm.Z.source, ucm.P.source, ucm.Cc.source].join('|'));
function splitTextToken(text: string, level: number, TokenConstructor: any): Token[] {
let last_pos = 0;
const nodes: Token[] = [];
text.replace(replaceRE, function (match: string, offset: number, src: string): string {
let emoji_name: string;
// Validate emoji name
if (has(shortcuts, match)) {
// replace shortcut with full name
emoji_name = shortcuts[match];
// Don't allow letters before any shortcut (as in no ":/" in http://)
if (offset > 0 && !ZPCc.test(src[offset - 1])) return '';
// Don't allow letters after any shortcut
if (offset + match.length < src.length && !ZPCc.test(src[offset + match.length])) {
return '';
}
} else {
emoji_name = match.slice(1, -1);
}
// Add new tokens to pending list
if (offset > last_pos) {
const token = new TokenConstructor('text', '', 0);
token.content = text.slice(last_pos, offset);
nodes.push(token);
}
const token = new TokenConstructor('emoji', '', 0);
token.markup = emoji_name;
token.content = emojies[emoji_name];
nodes.push(token);
last_pos = offset + match.length;
return '';
});
if (last_pos < text.length) {
const token = new TokenConstructor('text', '', 0);
token.content = text.slice(last_pos);
nodes.push(token);
}
return nodes;
}
return function emoji_replace(state: StateCore): void {
let token: Token;
const blockTokens = state.tokens;
let autolinkLevel = 0;
for (let j = 0, l = blockTokens.length; j < l; j++) {
if (blockTokens[j].type !== 'inline') { continue; }
let tokens = blockTokens[j].children!;
// We scan from the end, to keep position when new tags added.
// Use reversed logic in links start/end match
for (let i = tokens.length - 1; i >= 0; i--) {
token = tokens[i];
if (token.type === 'link_open' || token.type === 'link_close') {
if (token.info === 'auto') { autolinkLevel -= token.nesting; }
}
if (token.type === 'text' && autolinkLevel === 0 && scanRE.test(token.content)) {
// replace current node
blockTokens[j].children = tokens = arrayReplaceAt(
tokens, i, splitTextToken(token.content, token.level, state.Token)
);
}
}
}
};
}

View File

@@ -1,390 +0,0 @@
import MarkdownIt, {Renderer, StateBlock, StateCore, StateInline, Token} from 'markdown-it';
/**
* 脚注元数据接口
*/
interface FootnoteMeta {
id: number;
subId: number;
label: string;
}
/**
* 脚注列表项接口
*/
interface FootnoteItem {
label?: string;
content?: string;
tokens?: Token[];
count: number;
}
/**
* 环境接口
*/
interface FootnoteEnv {
footnotes?: {
refs?: { [key: string]: number };
list?: FootnoteItem[];
};
docId?: string;
}
/// /////////////////////////////////////////////////////////////////////////////
// Renderer partials
function render_footnote_anchor_name(tokens: Token[], idx: number, options: any, env: FootnoteEnv): string {
const n = Number(tokens[idx].meta.id + 1).toString();
let prefix = '';
if (typeof env.docId === 'string') prefix = `-${env.docId}-`;
return prefix + n;
}
function render_footnote_caption(tokens: Token[], idx: number): string {
let n = Number(tokens[idx].meta.id + 1).toString();
if (tokens[idx].meta.subId > 0) n += `:${tokens[idx].meta.subId}`;
return `[${n}]`;
}
function render_footnote_ref(tokens: Token[], idx: number, options: any, env: FootnoteEnv, slf: Renderer): string {
const id = slf.rules.footnote_anchor_name!(tokens, idx, options, env, slf);
const caption = slf.rules.footnote_caption!(tokens, idx, options, env, slf);
let refid = id;
if (tokens[idx].meta.subId > 0) refid += `:${tokens[idx].meta.subId}`;
return `<sup class="footnote-ref"><a href="#fn${id}" id="fnref${refid}">${caption}</a></sup>`;
}
function render_footnote_block_open(tokens: Token[], idx: number, options: any): string {
return (options.xhtmlOut ? '<hr class="footnotes-sep" />\n' : '<hr class="footnotes-sep">\n') +
'<section class="footnotes">\n' +
'<ol class="footnotes-list">\n';
}
function render_footnote_block_close(): string {
return '</ol>\n</section>\n';
}
function render_footnote_open(tokens: Token[], idx: number, options: any, env: FootnoteEnv, slf: Renderer): string {
let id = slf.rules.footnote_anchor_name!(tokens, idx, options, env, slf);
if (tokens[idx].meta.subId > 0) id += `:${tokens[idx].meta.subId}`;
return `<li id="fn${id}" class="footnote-item">`;
}
function render_footnote_close(): string {
return '</li>\n';
}
function render_footnote_anchor(tokens: Token[], idx: number, options: any, env: FootnoteEnv, slf: Renderer): string {
let id = slf.rules.footnote_anchor_name!(tokens, idx, options, env, slf);
if (tokens[idx].meta.subId > 0) id += `:${tokens[idx].meta.subId}`;
/* ↩ with escape code to prevent display as Apple Emoji on iOS */
return ` <a href="#fnref${id}" class="footnote-backref">\u21a9\uFE0E</a>`;
}
/**
* markdown-it-footnote 插件
* 用于支持脚注语法
*/
export default function footnote_plugin(md: MarkdownIt): void {
const parseLinkLabel = md.helpers.parseLinkLabel;
const isSpace = md.utils.isSpace;
md.renderer.rules.footnote_ref = render_footnote_ref;
md.renderer.rules.footnote_block_open = render_footnote_block_open;
md.renderer.rules.footnote_block_close = render_footnote_block_close;
md.renderer.rules.footnote_open = render_footnote_open;
md.renderer.rules.footnote_close = render_footnote_close;
md.renderer.rules.footnote_anchor = render_footnote_anchor;
// helpers (only used in other rules, no tokens are attached to those)
md.renderer.rules.footnote_caption = render_footnote_caption;
md.renderer.rules.footnote_anchor_name = render_footnote_anchor_name;
// Process footnote block definition
function footnote_def(state: StateBlock, startLine: number, endLine: number, silent: boolean): boolean {
const start = state.bMarks[startLine] + state.tShift[startLine];
const max = state.eMarks[startLine];
// line should be at least 5 chars - "[^x]:"
if (start + 4 > max) return false;
if (state.src.charCodeAt(start) !== 0x5B/* [ */) return false;
if (state.src.charCodeAt(start + 1) !== 0x5E/* ^ */) return false;
let pos: number;
for (pos = start + 2; pos < max; pos++) {
if (state.src.charCodeAt(pos) === 0x20) return false;
if (state.src.charCodeAt(pos) === 0x5D /* ] */) {
break;
}
}
if (pos === start + 2) return false; // no empty footnote labels
if (pos + 1 >= max || state.src.charCodeAt(++pos) !== 0x3A /* : */) return false;
if (silent) return true;
pos++;
const env = state.env as FootnoteEnv;
if (!env.footnotes) env.footnotes = {};
if (!env.footnotes.refs) env.footnotes.refs = {};
const label = state.src.slice(start + 2, pos - 2);
env.footnotes.refs[`:${label}`] = -1;
const token_fref_o = new state.Token('footnote_reference_open', '', 1);
token_fref_o.meta = { label };
token_fref_o.level = state.level++;
state.tokens.push(token_fref_o);
const oldBMark = state.bMarks[startLine];
const oldTShift = state.tShift[startLine];
const oldSCount = state.sCount[startLine];
const oldParentType = state.parentType;
const posAfterColon = pos;
const initial = state.sCount[startLine] + pos - (state.bMarks[startLine] + state.tShift[startLine]);
let offset = initial;
while (pos < max) {
const ch = state.src.charCodeAt(pos);
if (isSpace(ch)) {
if (ch === 0x09) {
offset += 4 - offset % 4;
} else {
offset++;
}
} else {
break;
}
pos++;
}
state.tShift[startLine] = pos - posAfterColon;
state.sCount[startLine] = offset - initial;
state.bMarks[startLine] = posAfterColon;
state.blkIndent += 4;
state.parentType = 'footnote' as any;
if (state.sCount[startLine] < state.blkIndent) {
state.sCount[startLine] += state.blkIndent;
}
state.md.block.tokenize(state, startLine, endLine);
state.parentType = oldParentType;
state.blkIndent -= 4;
state.tShift[startLine] = oldTShift;
state.sCount[startLine] = oldSCount;
state.bMarks[startLine] = oldBMark;
const token_fref_c = new state.Token('footnote_reference_close', '', -1);
token_fref_c.level = --state.level;
state.tokens.push(token_fref_c);
return true;
}
// Process inline footnotes (^[...])
function footnote_inline(state: StateInline, silent: boolean): boolean {
const max = state.posMax;
const start = state.pos;
if (start + 2 >= max) return false;
if (state.src.charCodeAt(start) !== 0x5E/* ^ */) return false;
if (state.src.charCodeAt(start + 1) !== 0x5B/* [ */) return false;
const labelStart = start + 2;
const labelEnd = parseLinkLabel(state, start + 1);
// parser failed to find ']', so it's not a valid note
if (labelEnd < 0) return false;
// We found the end of the link, and know for a fact it's a valid link;
// so all that's left to do is to call tokenizer.
//
if (!silent) {
const env = state.env as FootnoteEnv;
if (!env.footnotes) env.footnotes = {};
if (!env.footnotes.list) env.footnotes.list = [];
const footnoteId = env.footnotes.list.length;
const tokens: Token[] = [];
state.md.inline.parse(
state.src.slice(labelStart, labelEnd),
state.md,
state.env,
tokens
);
const token = state.push('footnote_ref', '', 0);
token.meta = { id: footnoteId };
env.footnotes.list[footnoteId] = {
content: state.src.slice(labelStart, labelEnd),
tokens,
count: 0
};
}
state.pos = labelEnd + 1;
state.posMax = max;
return true;
}
// Process footnote references ([^...])
function footnote_ref(state: StateInline, silent: boolean): boolean {
const max = state.posMax;
const start = state.pos;
// should be at least 4 chars - "[^x]"
if (start + 3 > max) return false;
const env = state.env as FootnoteEnv;
if (!env.footnotes || !env.footnotes.refs) return false;
if (state.src.charCodeAt(start) !== 0x5B/* [ */) return false;
if (state.src.charCodeAt(start + 1) !== 0x5E/* ^ */) return false;
let pos: number;
for (pos = start + 2; pos < max; pos++) {
if (state.src.charCodeAt(pos) === 0x20) return false;
if (state.src.charCodeAt(pos) === 0x0A) return false;
if (state.src.charCodeAt(pos) === 0x5D /* ] */) {
break;
}
}
if (pos === start + 2) return false; // no empty footnote labels
if (pos >= max) return false;
pos++;
const label = state.src.slice(start + 2, pos - 1);
if (typeof env.footnotes.refs[`:${label}`] === 'undefined') return false;
if (!silent) {
if (!env.footnotes.list) env.footnotes.list = [];
let footnoteId: number;
if (env.footnotes.refs[`:${label}`] < 0) {
footnoteId = env.footnotes.list.length;
env.footnotes.list[footnoteId] = { label, count: 0 };
env.footnotes.refs[`:${label}`] = footnoteId;
} else {
footnoteId = env.footnotes.refs[`:${label}`];
}
const footnoteSubId = env.footnotes.list[footnoteId].count;
env.footnotes.list[footnoteId].count++;
const token = state.push('footnote_ref', '', 0);
token.meta = { id: footnoteId, subId: footnoteSubId, label };
}
state.pos = pos;
state.posMax = max;
return true;
}
// Glue footnote tokens to end of token stream
function footnote_tail(state: StateCore): void {
let tokens: Token[] | null = null;
let current: Token[];
let currentLabel: string;
let insideRef = false;
const refTokens: { [key: string]: Token[] } = {};
const env = state.env as FootnoteEnv;
if (!env.footnotes) { return; }
state.tokens = state.tokens.filter(function (tok) {
if (tok.type === 'footnote_reference_open') {
insideRef = true;
current = [];
currentLabel = tok.meta.label;
return false;
}
if (tok.type === 'footnote_reference_close') {
insideRef = false;
// prepend ':' to avoid conflict with Object.prototype members
refTokens[':' + currentLabel] = current;
return false;
}
if (insideRef) { current.push(tok); }
return !insideRef;
});
if (!env.footnotes.list) { return; }
const list = env.footnotes.list;
state.tokens.push(new state.Token('footnote_block_open', '', 1));
for (let i = 0, l = list.length; i < l; i++) {
const token_fo = new state.Token('footnote_open', '', 1);
token_fo.meta = { id: i, label: list[i].label };
state.tokens.push(token_fo);
if (list[i].tokens) {
tokens = [];
const token_po = new state.Token('paragraph_open', 'p', 1);
token_po.block = true;
tokens.push(token_po);
const token_i = new state.Token('inline', '', 0);
token_i.children = list[i].tokens || null;
token_i.content = list[i].content || '';
tokens.push(token_i);
const token_pc = new state.Token('paragraph_close', 'p', -1);
token_pc.block = true;
tokens.push(token_pc);
} else if (list[i].label) {
tokens = refTokens[`:${list[i].label}`] || null;
}
if (tokens) state.tokens = state.tokens.concat(tokens);
let lastParagraph: Token | null;
if (state.tokens[state.tokens.length - 1].type === 'paragraph_close') {
lastParagraph = state.tokens.pop()!;
} else {
lastParagraph = null;
}
const t = list[i].count > 0 ? list[i].count : 1;
for (let j = 0; j < t; j++) {
const token_a = new state.Token('footnote_anchor', '', 0);
token_a.meta = { id: i, subId: j, label: list[i].label };
state.tokens.push(token_a);
}
if (lastParagraph) {
state.tokens.push(lastParagraph);
}
state.tokens.push(new state.Token('footnote_close', '', -1));
}
state.tokens.push(new state.Token('footnote_block_close', '', -1));
}
md.block.ruler.before('reference', 'footnote_def', footnote_def, { alt: ['paragraph', 'reference'] });
md.inline.ruler.after('image', 'footnote_inline', footnote_inline);
md.inline.ruler.after('footnote_inline', 'footnote_ref', footnote_ref);
md.core.ruler.after('inline', 'footnote_tail', footnote_tail);
}

View File

@@ -1,160 +0,0 @@
import MarkdownIt, { StateInline, Token } from 'markdown-it';
/**
* 分隔符接口定义
*/
interface Delimiter {
marker: number;
length: number;
jump: number;
token: number;
end: number;
open: boolean;
close: boolean;
}
/**
* 扫描结果接口定义
*/
interface ScanResult {
can_open: boolean;
can_close: boolean;
length: number;
}
/**
* Token 元数据接口定义
*/
interface TokenMeta {
delimiters?: Delimiter[];
}
/**
* markdown-it-ins 插件
* 用于支持插入文本语法 ++text++
*/
export default function ins_plugin(md: MarkdownIt): void {
// Insert each marker as a separate text token, and add it to delimiter list
//
function tokenize(state: StateInline, silent: boolean): boolean {
const start = state.pos;
const marker = state.src.charCodeAt(start);
if (silent) { return false; }
if (marker !== 0x2B/* + */) { return false; }
const scanned = state.scanDelims(state.pos, true) as ScanResult;
let len = scanned.length;
const ch = String.fromCharCode(marker);
if (len < 2) { return false; }
if (len % 2) {
const token: Token = state.push('text', '', 0);
token.content = ch;
len--;
}
for (let i = 0; i < len; i += 2) {
const token: Token = state.push('text', '', 0);
token.content = ch + ch;
if (!scanned.can_open && !scanned.can_close) { continue; }
state.delimiters.push({
marker,
length: 0, // disable "rule of 3" length checks meant for emphasis
jump: i / 2, // 1 delimiter = 2 characters
token: state.tokens.length - 1,
end: -1,
open: scanned.can_open,
close: scanned.can_close
} as Delimiter);
}
state.pos += scanned.length;
return true;
}
// Walk through delimiter list and replace text tokens with tags
//
function postProcess(state: StateInline, delimiters: Delimiter[]): void {
let token: Token;
const loneMarkers: number[] = [];
const max = delimiters.length;
for (let i = 0; i < max; i++) {
const startDelim = delimiters[i];
if (startDelim.marker !== 0x2B/* + */) {
continue;
}
if (startDelim.end === -1) {
continue;
}
const endDelim = delimiters[startDelim.end];
token = state.tokens[startDelim.token];
token.type = 'ins_open';
token.tag = 'ins';
token.nesting = 1;
token.markup = '++';
token.content = '';
token = state.tokens[endDelim.token];
token.type = 'ins_close';
token.tag = 'ins';
token.nesting = -1;
token.markup = '++';
token.content = '';
if (state.tokens[endDelim.token - 1].type === 'text' &&
state.tokens[endDelim.token - 1].content === '+') {
loneMarkers.push(endDelim.token - 1);
}
}
// If a marker sequence has an odd number of characters, it's splitted
// like this: `~~~~~` -> `~` + `~~` + `~~`, leaving one marker at the
// start of the sequence.
//
// So, we have to move all those markers after subsequent s_close tags.
//
while (loneMarkers.length) {
const i = loneMarkers.pop()!;
let j = i + 1;
while (j < state.tokens.length && state.tokens[j].type === 'ins_close') {
j++;
}
j--;
if (i !== j) {
token = state.tokens[j];
state.tokens[j] = state.tokens[i];
state.tokens[i] = token;
}
}
}
md.inline.ruler.before('emphasis', 'ins', tokenize);
md.inline.ruler2.before('emphasis', 'ins', function (state: StateInline): boolean {
const tokens_meta = state.tokens_meta as TokenMeta[];
const max = (state.tokens_meta || []).length;
postProcess(state, state.delimiters as Delimiter[]);
for (let curr = 0; curr < max; curr++) {
if (tokens_meta[curr] && tokens_meta[curr].delimiters) {
postProcess(state, tokens_meta[curr].delimiters!);
}
}
return true;
});
}

View File

@@ -1,160 +0,0 @@
import MarkdownIt, {StateInline, Token} from 'markdown-it';
/**
* 分隔符接口定义
*/
interface Delimiter {
marker: number;
length: number;
jump: number;
token: number;
end: number;
open: boolean;
close: boolean;
}
/**
* 扫描结果接口定义
*/
interface ScanResult {
can_open: boolean;
can_close: boolean;
length: number;
}
/**
* Token 元数据接口定义
*/
interface TokenMeta {
delimiters?: Delimiter[];
}
/**
* markdown-it-mark 插件
* 用于支持 ==标记文本== 语法
*/
export default function markPlugin(md: MarkdownIt): void {
// Insert each marker as a separate text token, and add it to delimiter list
//
function tokenize(state: StateInline, silent: boolean): boolean {
const start = state.pos;
const marker = state.src.charCodeAt(start);
if (silent) { return false; }
if (marker !== 0x3D/* = */) { return false; }
const scanned = state.scanDelims(state.pos, true) as ScanResult;
let len = scanned.length;
const ch = String.fromCharCode(marker);
if (len < 2) { return false; }
if (len % 2) {
const token: Token = state.push('text', '', 0);
token.content = ch;
len--;
}
for (let i = 0; i < len; i += 2) {
const token: Token = state.push('text', '', 0);
token.content = ch + ch;
if (!scanned.can_open && !scanned.can_close) { continue; }
state.delimiters.push({
marker,
length: 0, // disable "rule of 3" length checks meant for emphasis
jump: i / 2, // 1 delimiter = 2 characters
token: state.tokens.length - 1,
end: -1,
open: scanned.can_open,
close: scanned.can_close
} as Delimiter);
}
state.pos += scanned.length;
return true;
}
// Walk through delimiter list and replace text tokens with tags
//
function postProcess(state: StateInline, delimiters: Delimiter[]): void {
const loneMarkers: number[] = [];
const max = delimiters.length;
for (let i = 0; i < max; i++) {
const startDelim = delimiters[i];
if (startDelim.marker !== 0x3D/* = */) {
continue;
}
if (startDelim.end === -1) {
continue;
}
const endDelim = delimiters[startDelim.end];
const token_o = state.tokens[startDelim.token];
token_o.type = 'mark_open';
token_o.tag = 'mark';
token_o.nesting = 1;
token_o.markup = '==';
token_o.content = '';
const token_c = state.tokens[endDelim.token];
token_c.type = 'mark_close';
token_c.tag = 'mark';
token_c.nesting = -1;
token_c.markup = '==';
token_c.content = '';
if (state.tokens[endDelim.token - 1].type === 'text' &&
state.tokens[endDelim.token - 1].content === '=') {
loneMarkers.push(endDelim.token - 1);
}
}
// If a marker sequence has an odd number of characters, it's splitted
// like this: `~~~~~` -> `~` + `~~` + `~~`, leaving one marker at the
// start of the sequence.
//
// So, we have to move all those markers after subsequent s_close tags.
//
while (loneMarkers.length) {
const i = loneMarkers.pop()!;
let j = i + 1;
while (j < state.tokens.length && state.tokens[j].type === 'mark_close') {
j++;
}
j--;
if (i !== j) {
const token = state.tokens[j];
state.tokens[j] = state.tokens[i];
state.tokens[i] = token;
}
}
}
md.inline.ruler.before('emphasis', 'mark', tokenize);
md.inline.ruler2.before('emphasis', 'mark', function (state: StateInline): boolean {
let curr: number;
const tokens_meta = state.tokens_meta as TokenMeta[];
const max = (state.tokens_meta || []).length;
postProcess(state, state.delimiters as Delimiter[]);
for (curr = 0; curr < max; curr++) {
if (tokens_meta[curr] && tokens_meta[curr].delimiters) {
postProcess(state, tokens_meta[curr].delimiters!);
}
}
return true;
});
}

View File

@@ -1,106 +0,0 @@
import mermaid from "mermaid";
import {genUid, hashCode, sleep} from "./utils";
const mermaidCache = new Map<string, HTMLElement>();
// 缓存计数器,用于清除缓存
const mermaidCacheCount = new Map<string, number>();
let count = 0;
let countTmo = setTimeout(() => undefined, 0);
const addCount = () => {
clearTimeout(countTmo);
countTmo = setTimeout(() => {
count++;
clearCache();
}, 500);
};
const clearCache = () => {
for (const key of mermaidCacheCount.keys()) {
const value = mermaidCacheCount.get(key)!;
if (value + 3 < count) {
mermaidCache.delete(key);
mermaidCacheCount.delete(key);
}
}
};
/**
* 渲染 mermaid
* @param code mermaid 代码
* @param targetId 目标 id
* @param count 计数器
*/
const renderMermaid = async (code: string, targetId: string, count: number) => {
let limit = 100;
while (limit-- > 0) {
const container = document.getElementById(targetId);
if (!container) {
await sleep(100);
continue;
}
try {
const {svg} = await mermaid.render("mermaid-svg-" + genUid(), code, container);
container.innerHTML = svg;
mermaidCache.set(targetId, container);
mermaidCacheCount.set(targetId, count);
} catch (e) {
}
break;
}
};
export interface MermaidItOptions {
theme?: "default" | "dark" | "forest" | "neutral" | "base";
}
/**
* 更新 mermaid 主题
*/
export const updateMermaidTheme = (theme: "default" | "dark" | "forest" | "neutral" | "base") => {
mermaid.initialize({
startOnLoad: false,
theme: theme
});
// 清空缓存,强制重新渲染
mermaidCache.clear();
mermaidCacheCount.clear();
};
/**
* mermaid 插件
* @param md markdown-it
* @param options 配置选项
* @constructor MermaidIt
*/
export const MermaidIt = function (md: any, options?: MermaidItOptions): void {
const theme = options?.theme || "default";
mermaid.initialize({
startOnLoad: false,
theme: theme
});
const defaultRenderer = md.renderer.rules.fence.bind(md.renderer.rules);
md.renderer.rules.fence = (tokens: any, idx: any, options: any, env: any, self: any) => {
addCount();
const token = tokens[idx];
const info = token.info.trim();
if (info === "mermaid") {
const containerId = "mermaid-container-" + hashCode(token.content);
const container = document.createElement("div");
container.id = containerId;
if (mermaidCache.has(containerId)) {
container.innerHTML = mermaidCache.get(containerId)!.innerHTML;
mermaidCacheCount.set(containerId, count);
} else {
renderMermaid(token.content, containerId, count).then();
}
return container.outerHTML;
}
// 使用默认的渲染规则
return defaultRenderer(tokens, idx, options, env, self);
};
};

View File

@@ -1,49 +0,0 @@
import { v4 as uuidv4 } from "uuid";
/**
* uuid 生成函数
* @param split 分隔符
*/
export const genUid = (split = "") => {
return uuidv4().split("-").join(split);
};
/**
* 一个简易的sleep函数
*/
export const sleep = async (ms: number) => {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
};
/**
* 计算字符串的hash值
* 返回一个数字
* @param str
*/
export const hashCode = (str: string) => {
let hash = 0;
if (str.length === 0) return hash;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32bit integer
}
return hash;
};
/**
* 一个简易的阻塞函数
*/
export const awaitFor = async (cb: () => boolean, timeout = 0, errText = "超时暂停阻塞") => {
const start = Date.now();
while (true) {
if (cb()) return true;
if (timeout && Date.now() - start > timeout) {
console.error("阻塞超时: " + errText);
return false;
}
await sleep(100);
}
};

View File

@@ -1,66 +0,0 @@
// Process ~subscript~
import MarkdownIt, { StateInline, Token } from 'markdown-it';
// same as UNESCAPE_MD_RE plus a space
const UNESCAPE_RE = /\\([ \\!"#$%&'()*+,./:;<=>?@[\]^_`{|}~-])/g;
function subscript(state: StateInline, silent: boolean): boolean {
const max = state.posMax;
const start = state.pos;
if (state.src.charCodeAt(start) !== 0x7E/* ~ */) { return false; }
if (silent) { return false; } // don't run any pairs in validation mode
if (start + 2 >= max) { return false; }
state.pos = start + 1;
let found = false;
while (state.pos < max) {
if (state.src.charCodeAt(state.pos) === 0x7E/* ~ */) {
found = true;
break;
}
state.md.inline.skipToken(state);
}
if (!found || start + 1 === state.pos) {
state.pos = start;
return false;
}
const content = state.src.slice(start + 1, state.pos);
// don't allow unescaped spaces/newlines inside
if (content.match(/(^|[^\\])(\\\\)*\s/)) {
state.pos = start;
return false;
}
// found!
state.posMax = state.pos;
state.pos = start + 1;
// Earlier we checked !silent, but this implementation does not need it
const token_so: Token = state.push('sub_open', 'sub', 1);
token_so.markup = '~';
const token_t: Token = state.push('text', '', 0);
token_t.content = content.replace(UNESCAPE_RE, '$1');
const token_sc: Token = state.push('sub_close', 'sub', -1);
token_sc.markup = '~';
state.pos = state.posMax + 1;
state.posMax = max;
return true;
}
/**
* markdown-it-sub 插件
* 用于支持下标语法 ~text~
*/
export default function sub_plugin(md: MarkdownIt): void {
md.inline.ruler.after('emphasis', 'sub', subscript);
}

View File

@@ -1,66 +0,0 @@
// Process ^superscript^
import MarkdownIt, { StateInline, Token } from 'markdown-it';
// same as UNESCAPE_MD_RE plus a space
const UNESCAPE_RE = /\\([ \\!"#$%&'()*+,./:;<=>?@[\]^_`{|}~-])/g;
function superscript(state: StateInline, silent: boolean): boolean {
const max = state.posMax;
const start = state.pos;
if (state.src.charCodeAt(start) !== 0x5E/* ^ */) { return false; }
if (silent) { return false; } // don't run any pairs in validation mode
if (start + 2 >= max) { return false; }
state.pos = start + 1;
let found = false;
while (state.pos < max) {
if (state.src.charCodeAt(state.pos) === 0x5E/* ^ */) {
found = true;
break;
}
state.md.inline.skipToken(state);
}
if (!found || start + 1 === state.pos) {
state.pos = start;
return false;
}
const content = state.src.slice(start + 1, state.pos);
// don't allow unescaped spaces/newlines inside
if (content.match(/(^|[^\\])(\\\\)*\s/)) {
state.pos = start;
return false;
}
// found!
state.posMax = state.pos;
state.pos = start + 1;
// Earlier we checked !silent, but this implementation does not need it
const token_so: Token = state.push('sup_open', 'sup', 1);
token_so.markup = '^';
const token_t: Token = state.push('text', '', 0);
token_t.content = content.replace(UNESCAPE_RE, '$1');
const token_sc: Token = state.push('sup_close', 'sup', -1);
token_sc.markup = '^';
state.pos = state.posMax + 1;
state.posMax = max;
return true;
}
/**
* markdown-it-sup 插件
* 用于支持上标语法 ^text^
*/
export default function sup_plugin(md: MarkdownIt): void {
md.inline.ruler.after('emphasis', 'sup', superscript);
}

View File

@@ -6,6 +6,7 @@ import { useConfigStore } from './configStore';
import { useEditorStore } from './editorStore';
import type { ThemeColors } from '@/views/editor/theme/types';
import { cloneThemeColors, FALLBACK_THEME_NAME, themePresetList, themePresetMap } from '@/views/editor/theme/presets';
import { refreshMermaidTheme } from '@/views/editor/extensions/markdown/plugins/mermaid';
type ThemeOption = { name: string; type: ThemeType };
@@ -139,6 +140,9 @@ export const useThemeStore = defineStore('theme', () => {
const refreshEditorTheme = () => {
applyThemeToDOM(currentTheme.value);
// Refresh mermaid diagrams with new theme
refreshMermaidTheme();
const editorStore = useEditorStore();
editorStore?.applyThemeSettings();
};

View File

@@ -8,6 +8,8 @@ import {javascriptLanguage, typescriptLanguage} from "@codemirror/lang-javascrip
import {html, htmlLanguage} from "@codemirror/lang-html";
import {StandardSQL} from "@codemirror/lang-sql";
import {markdown, markdownLanguage} from "@codemirror/lang-markdown";
import {Subscript, Superscript} from "@lezer/markdown";
import {Highlight} from "@/views/editor/extensions/markdown/syntax/highlight";
import {javaLanguage} from "@codemirror/lang-java";
import {phpLanguage} from "@codemirror/lang-php";
import {cssLanguage} from "@codemirror/lang-css";
@@ -113,7 +115,7 @@ export const LANGUAGES: LanguageInfo[] = [
}),
new LanguageInfo("md", "Markdown", markdown({
base: markdownLanguage,
extensions: [],
extensions: [Subscript, Superscript, Highlight],
completeHTMLTags: true,
pasteURLAsLink: true,
htmlTagLanguage: html({

View File

@@ -64,5 +64,14 @@ export const blockquote = {
emoji = {
/** Emoji widget */
widget: 'cm-emoji'
},
/** Classes for mermaid diagram decorations. */
mermaid = {
/** Mermaid preview container */
preview: 'cm-mermaid-preview',
/** Loading state */
loading: 'cm-mermaid-loading',
/** Error state */
error: 'cm-mermaid-error'
}

View File

@@ -12,6 +12,9 @@ import { codeblockEnhanced } from './plugins/code-block-enhanced';
import { emoji } from './plugins/emoji';
import { horizontalRule } from './plugins/horizontal-rule';
import { inlineCode } from './plugins/inline-code';
import { subscriptSuperscript } from './plugins/subscript-superscript';
import { highlight } from './plugins/highlight';
import { mermaidPreview } from './plugins/mermaid';
/**
@@ -32,6 +35,9 @@ export const markdownExtensions: Extension = [
emoji(),
horizontalRule(),
inlineCode(),
subscriptSuperscript(),
highlight(),
mermaidPreview(),
];
export default markdownExtensions;

View File

@@ -8,6 +8,7 @@ import {
WidgetType
} from '@codemirror/view';
import { isCursorInRange } from '../util';
import { emojies } from '@/common/constant/emojies';
/**
* Emoji plugin that converts :emoji_name: to actual emoji characters.
@@ -17,133 +18,14 @@ import { isCursorInRange } from '../util';
* - Replaces them with actual emoji characters
* - Shows the original text when cursor is nearby
* - Uses RangeSetBuilder for optimal performance
* - Supports 1900+ emojis from the comprehensive emoji dictionary
*/
export const emoji = (): Extension => [emojiPlugin, baseTheme];
/**
* Emoji regex pattern for matching :emoji_name: syntax.
*/
const EMOJI_REGEX = /:([a-z0-9_+\-]+):/g;
/**
* 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);
}
});
const EMOJI_REGEX = /:([a-z0-9_+\-]+):/gi;
/**
* Emoji widget with optimized rendering.
@@ -190,8 +72,8 @@ function findEmojiMatches(text: string, offset: number): EmojiMatch[] {
EMOJI_REGEX.lastIndex = 0;
while ((match = EMOJI_REGEX.exec(text)) !== null) {
const name = match[1];
const emoji = EMOJI_MAP.get(name);
const name = match[1].toLowerCase();
const emoji = emojies[name];
if (emoji) {
matches.push({
@@ -285,26 +167,16 @@ const baseTheme = EditorView.baseTheme({
}
});
/**
* Add custom emoji to the map.
* @param name - Emoji name (without colons)
* @param emoji - Emoji character
*/
export function addEmoji(name: string, emoji: string): void {
EMOJI_MAP.set(name, emoji);
EMOJI_REVERSE_MAP.set(emoji, name);
}
/**
* Get all available emoji names.
*/
export function getEmojiNames(): string[] {
return Array.from(EMOJI_MAP.keys());
return Object.keys(emojies);
}
/**
* Get emoji by name.
*/
export function getEmoji(name: string): string | undefined {
return EMOJI_MAP.get(name);
return emojies[name.toLowerCase()];
}

View File

@@ -0,0 +1,115 @@
import { Extension, Range } from '@codemirror/state';
import { syntaxTree } from '@codemirror/language';
import {
ViewPlugin,
DecorationSet,
Decoration,
EditorView,
ViewUpdate
} from '@codemirror/view';
import { isCursorInRange, invisibleDecoration } from '../util';
/**
* Highlight plugin using syntax tree.
*
* Uses the custom Highlight extension to detect:
* - Highlight: ==text== → renders as highlighted text
*
* Examples:
* - This is ==important== text → This is <mark>important</mark> text
* - Please ==review this section== carefully
*/
export const highlight = (): Extension => [
highlightPlugin,
baseTheme
];
/**
* Build decorations for highlight using syntax tree.
*/
function buildDecorations(view: EditorView): DecorationSet {
const decorations: Range<Decoration>[] = [];
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
// Handle Highlight nodes
if (type.name === 'Highlight') {
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
// Get the mark nodes (the == characters)
const marks = node.getChildren('HighlightMark');
if (!cursorInRange && marks.length >= 2) {
// Hide the opening and closing == marks
decorations.push(invisibleDecoration.range(marks[0].from, marks[0].to));
decorations.push(invisibleDecoration.range(marks[marks.length - 1].from, marks[marks.length - 1].to));
// Apply highlight style to the content between marks
const contentStart = marks[0].to;
const contentEnd = marks[marks.length - 1].from;
if (contentStart < contentEnd) {
decorations.push(
Decoration.mark({
class: 'cm-highlight'
}).range(contentStart, contentEnd)
);
}
}
}
}
});
}
return Decoration.set(decorations, true);
}
/**
* Plugin class with optimized update detection.
*/
class HighlightPlugin {
decorations: DecorationSet;
private lastSelectionHead: number = -1;
constructor(view: EditorView) {
this.decorations = buildDecorations(view);
this.lastSelectionHead = view.state.selection.main.head;
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = buildDecorations(update.view);
this.lastSelectionHead = update.state.selection.main.head;
return;
}
if (update.selectionSet) {
const newHead = update.state.selection.main.head;
if (newHead !== this.lastSelectionHead) {
this.decorations = buildDecorations(update.view);
this.lastSelectionHead = newHead;
}
}
}
}
const highlightPlugin = ViewPlugin.fromClass(
HighlightPlugin,
{
decorations: (v) => v.decorations
}
);
/**
* Base theme for highlight.
* Uses mark decoration with a subtle background color.
*/
const baseTheme = EditorView.baseTheme({
'.cm-highlight': {
backgroundColor: 'var(--cm-highlight-background, rgba(255, 235, 59, 0.4))',
borderRadius: '2px',
}
});

View File

@@ -1,9 +1,11 @@
import { syntaxTree } from '@codemirror/language';
import { EditorState, StateField, Range } from '@codemirror/state';
import { EditorState, Range } from '@codemirror/state';
import {
Decoration,
DecorationSet,
EditorView,
ViewPlugin,
ViewUpdate,
WidgetType
} from '@codemirror/view';
import DOMPurify from 'dompurify';
@@ -15,34 +17,41 @@ interface EmbedBlockData {
content: string;
}
function extractHTMLBlocks(state: EditorState) {
/**
* Extract all HTML blocks from the document (both HTMLBlock and HTMLTag).
* Returns all blocks regardless of cursor position.
*/
function extractAllHTMLBlocks(state: EditorState): EmbedBlockData[] {
const blocks = new Array<EmbedBlockData>();
syntaxTree(state).iterate({
enter({ from, to, name }) {
if (name !== 'HTMLBlock') return;
if (isCursorInRange(state, [from, to])) return;
// Support both block-level HTML (HTMLBlock) and inline HTML tags (HTMLTag)
if (name !== 'HTMLBlock' && name !== 'HTMLTag') return;
const html = state.sliceDoc(from, to);
const content = DOMPurify.sanitize(html);
blocks.push({
from,
to,
content
});
// Skip empty content after sanitization
if (!content.trim()) return;
blocks.push({ from, to, content });
}
});
return blocks;
}
// Decoration to hide the original HTML source code
const hideDecoration = Decoration.replace({});
function blockToDecoration(blocks: EmbedBlockData[]): Range<Decoration>[] {
/**
* Build decorations for HTML blocks.
* Only shows preview for blocks where cursor is not inside.
*/
function buildDecorations(state: EditorState, blocks: EmbedBlockData[]): DecorationSet {
const decorations: Range<Decoration>[] = [];
for (const block of blocks) {
// Skip if cursor is in range
if (isCursorInRange(state, [block.from, block.to])) continue;
// Hide the original HTML source code
decorations.push(hideDecoration.range(block.from, block.to));
decorations.push(Decoration.replace({}).range(block.from, block.to));
// Add the preview widget at the end
decorations.push(
@@ -53,25 +62,57 @@ function blockToDecoration(blocks: EmbedBlockData[]): Range<Decoration>[] {
);
}
return decorations;
return Decoration.set(decorations, true);
}
export const htmlBlock = StateField.define<DecorationSet>({
create(state) {
return Decoration.set(blockToDecoration(extractHTMLBlocks(state)), true);
},
update(value, tx) {
if (tx.docChanged || tx.selection) {
return Decoration.set(
blockToDecoration(extractHTMLBlocks(tx.state)),
true
);
}
return value.map(tx.changes);
},
provide(field) {
return EditorView.decorations.from(field);
/**
* Check if selection affects any HTML block (cursor moved in/out of a block).
*/
function selectionAffectsBlocks(
state: EditorState,
prevState: EditorState,
blocks: EmbedBlockData[]
): boolean {
for (const block of blocks) {
const wasInRange = isCursorInRange(prevState, [block.from, block.to]);
const isInRange = isCursorInRange(state, [block.from, block.to]);
if (wasInRange !== isInRange) return true;
}
return false;
}
/**
* ViewPlugin for HTML block preview.
* Uses smart caching to avoid unnecessary updates during text selection.
*/
class HTMLBlockPlugin {
decorations: DecorationSet;
blocks: EmbedBlockData[];
constructor(view: EditorView) {
this.blocks = extractAllHTMLBlocks(view.state);
this.decorations = buildDecorations(view.state, this.blocks);
}
update(update: ViewUpdate) {
// If document changed, re-extract all blocks
if (update.docChanged) {
this.blocks = extractAllHTMLBlocks(update.state);
this.decorations = buildDecorations(update.state, this.blocks);
return;
}
// If selection changed, only rebuild if cursor moved in/out of a block
if (update.selectionSet) {
if (selectionAffectsBlocks(update.state, update.startState, this.blocks)) {
this.decorations = buildDecorations(update.state, this.blocks);
}
}
}
}
const htmlBlockPlugin = ViewPlugin.fromClass(HTMLBlockPlugin, {
decorations: (v) => v.decorations
});
class HTMLBlockWidget extends WidgetType {
@@ -123,12 +164,19 @@ class HTMLBlockWidget extends WidgetType {
*/
const baseTheme = EditorView.baseTheme({
'.cm-html-block-widget': {
display: 'block',
display: 'inline-block',
position: 'relative',
width: '100%',
overflow: 'auto'
maxWidth: '100%',
overflow: 'auto',
verticalAlign: 'middle'
},
'.cm-html-block-content': {
display: 'inline-block'
},
// Ensure images are properly sized
'.cm-html-block-content img': {
maxWidth: '100%',
height: 'auto',
display: 'block'
},
'.cm-html-block-edit-btn': {
@@ -157,4 +205,4 @@ const baseTheme = EditorView.baseTheme({
});
// Export the extension with theme
export const htmlBlockExtension = [htmlBlock, baseTheme];
export const htmlBlockExtension = [htmlBlockPlugin, baseTheme];

View File

@@ -0,0 +1,402 @@
import { Extension, Range } from '@codemirror/state';
import { syntaxTree } from '@codemirror/language';
import {
ViewPlugin,
DecorationSet,
Decoration,
EditorView,
ViewUpdate,
WidgetType
} from '@codemirror/view';
import { isCursorInRange } from '../util';
import mermaid from 'mermaid';
/**
* Mermaid diagram preview plugin.
*
* This plugin detects mermaid code blocks and renders them as SVG diagrams.
* Features:
* - Detects ```mermaid code blocks
* - Renders mermaid diagrams as inline SVG
* - Shows the original code when cursor is in the block
* - Caches rendered diagrams for performance
* - Supports theme switching (dark/light)
* - Supports all mermaid diagram types (flowchart, sequence, etc.)
*/
export const mermaidPreview = (): Extension => [
mermaidPlugin,
baseTheme
];
// Current mermaid theme
let currentMermaidTheme: 'default' | 'dark' = 'default';
let mermaidInitialized = false;
/**
* Detect the current theme from the DOM.
*/
function detectTheme(): 'default' | 'dark' {
const dataTheme = document.documentElement.getAttribute('data-theme');
if (dataTheme === 'light') {
return 'default';
}
if (dataTheme === 'dark') {
return 'dark';
}
// For 'auto', check system preference
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
return 'default';
}
/**
* Initialize mermaid with the specified theme.
*/
function initMermaid(theme: 'default' | 'dark' = currentMermaidTheme) {
mermaid.initialize({
startOnLoad: false,
theme,
securityLevel: 'strict',
flowchart: {
htmlLabels: true,
curve: 'basis'
},
sequence: {
showSequenceNumbers: false
},
logLevel: 'error'
});
currentMermaidTheme = theme;
mermaidInitialized = true;
}
/**
* Information about a mermaid code block.
*/
interface MermaidBlockInfo {
/** Start position of the code block */
from: number;
/** End position of the code block */
to: number;
/** The mermaid code content */
code: string;
/** Unique ID for rendering */
id: string;
}
/**
* Cache for rendered mermaid diagrams.
* Key is `${theme}:${code}` to support theme-specific caching.
*/
const renderCache = new Map<string, string>();
/**
* Generate cache key for a diagram.
*/
function getCacheKey(code: string): string {
return `${currentMermaidTheme}:${code}`;
}
/**
* Generate a unique ID for a mermaid diagram.
*/
let idCounter = 0;
function generateId(): string {
return `mermaid-${Date.now()}-${idCounter++}`;
}
/**
* Extract mermaid code blocks from the visible ranges.
*/
function extractMermaidBlocks(view: EditorView): MermaidBlockInfo[] {
const blocks: MermaidBlockInfo[] = [];
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: (node) => {
if (node.name !== 'FencedCode') return;
// Check if this is a mermaid code block
const codeInfoNode = node.node.getChild('CodeInfo');
if (!codeInfoNode) return;
const language = view.state.doc
.sliceString(codeInfoNode.from, codeInfoNode.to)
.trim()
.toLowerCase();
if (language !== 'mermaid') return;
// Extract the code content
const firstLine = view.state.doc.lineAt(node.from);
const lastLine = view.state.doc.lineAt(node.to);
const codeStart = firstLine.to + 1;
const codeEnd = lastLine.from - 1;
if (codeStart >= codeEnd) return;
const code = view.state.doc.sliceString(codeStart, codeEnd).trim();
if (code) {
blocks.push({
from: node.from,
to: node.to,
code,
id: generateId()
});
}
}
});
}
return blocks;
}
/**
* Mermaid preview widget that renders the diagram.
*/
class MermaidPreviewWidget extends WidgetType {
private svg: string | null = null;
private error: string | null = null;
private rendering = false;
constructor(
readonly code: string,
readonly blockId: string
) {
super();
// Check cache first (theme-specific)
const cached = renderCache.get(getCacheKey(code));
if (cached) {
this.svg = cached;
}
}
eq(other: MermaidPreviewWidget): boolean {
return other.code === this.code;
}
toDOM(view: EditorView): HTMLElement {
const container = document.createElement('div');
container.className = 'cm-mermaid-preview';
if (this.svg) {
// Use cached SVG
container.innerHTML = this.svg;
this.setupSvgStyles(container);
} else if (this.error) {
// Show error
const errorEl = document.createElement('div');
errorEl.className = 'cm-mermaid-error';
errorEl.textContent = `Mermaid Error: ${this.error}`;
container.appendChild(errorEl);
} else {
// Show loading and start rendering
const loading = document.createElement('div');
loading.className = 'cm-mermaid-loading';
loading.textContent = 'Rendering diagram...';
container.appendChild(loading);
// Render asynchronously
if (!this.rendering) {
this.rendering = true;
this.renderMermaid(container, view);
}
}
return container;
}
private async renderMermaid(container: HTMLElement, view: EditorView) {
// Ensure mermaid is initialized with current theme
const theme = detectTheme();
if (!mermaidInitialized || currentMermaidTheme !== theme) {
initMermaid(theme);
}
try {
const { svg } = await mermaid.render(this.blockId, this.code);
// Cache the result with theme-specific key
renderCache.set(getCacheKey(this.code), svg);
this.svg = svg;
// Update the container
container.innerHTML = svg;
container.classList.remove('cm-mermaid-loading');
this.setupSvgStyles(container);
// Trigger a re-render to update decorations
view.dispatch({});
} catch (err) {
this.error = err instanceof Error ? err.message : String(err);
// Clear the loading state and show error
container.innerHTML = '';
const errorEl = document.createElement('div');
errorEl.className = 'cm-mermaid-error';
errorEl.textContent = `Mermaid Error: ${this.error}`;
container.appendChild(errorEl);
}
}
private setupSvgStyles(container: HTMLElement) {
const svg = container.querySelector('svg');
if (svg) {
svg.style.maxWidth = '100%';
svg.style.height = 'auto';
svg.removeAttribute('height');
}
}
ignoreEvent(): boolean {
return true;
}
}
/**
* Build decorations for mermaid code blocks.
*/
function buildMermaidDecorations(view: EditorView): DecorationSet {
const decorations: Range<Decoration>[] = [];
const blocks = extractMermaidBlocks(view);
for (const block of blocks) {
// Skip if cursor is in this code block
if (isCursorInRange(view.state, [block.from, block.to])) {
continue;
}
// Add preview widget after the code block
decorations.push(
Decoration.widget({
widget: new MermaidPreviewWidget(block.code, block.id),
side: 1
}).range(block.to)
);
}
return Decoration.set(decorations, true);
}
/**
* Track the last known theme for change detection.
*/
let lastTheme: 'default' | 'dark' = detectTheme();
/**
* Mermaid preview plugin class.
*/
class MermaidPreviewPlugin {
decorations: DecorationSet;
private lastSelectionHead: number = -1;
constructor(view: EditorView) {
// Initialize mermaid with detected theme
lastTheme = detectTheme();
initMermaid(lastTheme);
this.decorations = buildMermaidDecorations(view);
this.lastSelectionHead = view.state.selection.main.head;
}
update(update: ViewUpdate) {
// Check if theme changed
const currentTheme = detectTheme();
if (currentTheme !== lastTheme) {
lastTheme = currentTheme;
// Theme changed, clear cache and reinitialize
renderCache.clear();
initMermaid(currentTheme);
this.decorations = buildMermaidDecorations(update.view);
this.lastSelectionHead = update.state.selection.main.head;
return;
}
if (update.docChanged || update.viewportChanged) {
this.decorations = buildMermaidDecorations(update.view);
this.lastSelectionHead = update.state.selection.main.head;
return;
}
if (update.selectionSet) {
const newHead = update.state.selection.main.head;
if (newHead !== this.lastSelectionHead) {
this.decorations = buildMermaidDecorations(update.view);
this.lastSelectionHead = newHead;
}
}
}
}
const mermaidPlugin = ViewPlugin.fromClass(MermaidPreviewPlugin, {
decorations: (v) => v.decorations
});
/**
* Base theme for mermaid preview.
*/
const baseTheme = EditorView.baseTheme({
'.cm-mermaid-preview': {
display: 'block',
backgroundColor: 'var(--cm-mermaid-bg, rgba(128, 128, 128, 0.05))',
borderRadius: '0.5rem',
overflow: 'auto',
textAlign: 'center'
},
'.cm-mermaid-preview svg': {
maxWidth: '100%',
height: 'auto'
},
'.cm-mermaid-loading': {
color: 'var(--cm-foreground)',
opacity: '0.6',
fontStyle: 'italic',
},
'.cm-mermaid-error': {
color: 'var(--cm-error, #ef4444)',
backgroundColor: 'var(--cm-error-bg, rgba(239, 68, 68, 0.1))',
borderRadius: '0.25rem',
fontSize: '0.875rem',
textAlign: 'left',
fontFamily: 'var(--voidraft-font-mono)',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word'
}
});
/**
* Clear the mermaid render cache.
* Call this when theme changes to re-render diagrams.
*/
export function clearMermaidCache(): void {
renderCache.clear();
}
/**
* Update mermaid theme based on current system theme.
* Call this when the application theme changes.
*/
export function refreshMermaidTheme(): void {
const theme = detectTheme();
if (theme !== currentMermaidTheme) {
renderCache.clear();
initMermaid(theme);
}
}
/**
* Force refresh all mermaid diagrams.
* Clears cache and reinitializes with current theme.
*/
export function forceRefreshMermaid(): void {
renderCache.clear();
initMermaid(detectTheme());
}

View File

@@ -0,0 +1,163 @@
import { Extension, Range } from '@codemirror/state';
import { syntaxTree } from '@codemirror/language';
import {
ViewPlugin,
DecorationSet,
Decoration,
EditorView,
ViewUpdate
} from '@codemirror/view';
import { isCursorInRange, invisibleDecoration } from '../util';
/**
* Subscript and Superscript plugin using syntax tree.
*
* Uses lezer-markdown's Subscript and Superscript extensions to detect:
* - Superscript: ^text^ → renders as superscript
* - Subscript: ~text~ → renders as subscript
*
* Examples:
* - 19^th^ → 19ᵗʰ (superscript)
* - H~2~O → H₂O (subscript)
*/
export const subscriptSuperscript = (): Extension => [
subscriptSuperscriptPlugin,
baseTheme
];
/**
* Build decorations for subscript and superscript using syntax tree.
*/
function buildDecorations(view: EditorView): DecorationSet {
const decorations: Range<Decoration>[] = [];
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, from: nodeFrom, to: nodeTo, node }) => {
// Handle Superscript nodes
if (type.name === 'Superscript') {
// Get the full content including marks
const fullContent = view.state.doc.sliceString(nodeFrom, nodeTo);
// Skip if this contains inline footnote pattern ^[
// This catches ^[text] being misinterpreted as superscript
if (fullContent.includes('^[') || fullContent.includes('[') && fullContent.includes(']')) {
return;
}
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
// Get the mark nodes (the ^ characters)
const marks = node.getChildren('SuperscriptMark');
if (!cursorInRange && marks.length >= 2) {
// Get inner content between marks
const innerContent = view.state.doc.sliceString(marks[0].to, marks[marks.length - 1].from);
// Skip if inner content looks like footnote (starts with [ or contains brackets)
if (innerContent.startsWith('[') || innerContent.includes('[') || innerContent.includes(']')) {
return;
}
// Hide the opening and closing ^ marks
decorations.push(invisibleDecoration.range(marks[0].from, marks[0].to));
decorations.push(invisibleDecoration.range(marks[marks.length - 1].from, marks[marks.length - 1].to));
// Apply superscript style to the content between marks
const contentStart = marks[0].to;
const contentEnd = marks[marks.length - 1].from;
if (contentStart < contentEnd) {
decorations.push(
Decoration.mark({
class: 'cm-superscript'
}).range(contentStart, contentEnd)
);
}
}
}
// Handle Subscript nodes
if (type.name === 'Subscript') {
const cursorInRange = isCursorInRange(view.state, [nodeFrom, nodeTo]);
// Get the mark nodes (the ~ characters)
const marks = node.getChildren('SubscriptMark');
if (!cursorInRange && marks.length >= 2) {
// Hide the opening and closing ~ marks
decorations.push(invisibleDecoration.range(marks[0].from, marks[0].to));
decorations.push(invisibleDecoration.range(marks[marks.length - 1].from, marks[marks.length - 1].to));
// Apply subscript style to the content between marks
const contentStart = marks[0].to;
const contentEnd = marks[marks.length - 1].from;
if (contentStart < contentEnd) {
decorations.push(
Decoration.mark({
class: 'cm-subscript'
}).range(contentStart, contentEnd)
);
}
}
}
}
});
}
return Decoration.set(decorations, true);
}
/**
* Plugin class with optimized update detection.
*/
class SubscriptSuperscriptPlugin {
decorations: DecorationSet;
private lastSelectionHead: number = -1;
constructor(view: EditorView) {
this.decorations = buildDecorations(view);
this.lastSelectionHead = view.state.selection.main.head;
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = buildDecorations(update.view);
this.lastSelectionHead = update.state.selection.main.head;
return;
}
if (update.selectionSet) {
const newHead = update.state.selection.main.head;
if (newHead !== this.lastSelectionHead) {
this.decorations = buildDecorations(update.view);
this.lastSelectionHead = newHead;
}
}
}
}
const subscriptSuperscriptPlugin = ViewPlugin.fromClass(
SubscriptSuperscriptPlugin,
{
decorations: (v) => v.decorations
}
);
/**
* Base theme for subscript and superscript.
* Uses mark decoration instead of widget to avoid layout issues.
*/
const baseTheme = EditorView.baseTheme({
'.cm-superscript': {
verticalAlign: 'super',
fontSize: '0.8em',
color: 'var(--cm-superscript-color, inherit)'
},
'.cm-subscript': {
verticalAlign: 'sub',
fontSize: '0.8em',
color: 'var(--cm-subscript-color, inherit)'
}
});

View File

@@ -0,0 +1,72 @@
/**
* Highlight extension for Lezer Markdown parser.
*
* Parses ==highlight== syntax similar to Obsidian/Mark style.
*
* Syntax: ==text== → renders as highlighted text
*
* Example:
* - This is ==important== text → This is <mark>important</mark> text
*/
import { MarkdownConfig } from '@lezer/markdown';
/**
* Highlight extension for Lezer Markdown.
*
* Defines:
* - Highlight: The container node for highlighted content
* - HighlightMark: The == delimiter marks
*/
export const Highlight: MarkdownConfig = {
defineNodes: [
{ name: 'Highlight' },
{ name: 'HighlightMark' }
],
parseInline: [{
name: 'Highlight',
parse(cx, next, pos) {
// Check for == delimiter (= is ASCII 61)
if (next !== 61 || cx.char(pos + 1) !== 61) {
return -1;
}
// Don't match === or more (horizontal rule or other constructs)
if (cx.char(pos + 2) === 61) {
return -1;
}
// Look for closing == delimiter
for (let i = pos + 2; i < cx.end - 1; i++) {
const char = cx.char(i);
// Don't allow newlines within highlight
if (char === 10) { // newline
return -1;
}
// Found potential closing ==
if (char === 61 && cx.char(i + 1) === 61) {
// Make sure it's not ===
if (i + 2 < cx.end && cx.char(i + 2) === 61) {
continue;
}
// Create the element with marks
const element = cx.elt('Highlight', pos, i + 2, [
cx.elt('HighlightMark', pos, pos + 2),
cx.elt('HighlightMark', i, i + 2)
]);
return cx.addElement(element);
}
}
return -1;
},
// Parse after emphasis to avoid conflicts with other inline parsers
after: 'Emphasis'
}]
};
export default Highlight;

View File

@@ -6,19 +6,16 @@ import { useUpdateStore } from '@/stores/updateStore';
import SettingSection from '../components/SettingSection.vue';
import SettingItem from '../components/SettingItem.vue';
import ToggleSwitch from '../components/ToggleSwitch.vue';
import markdownit from 'markdown-it'
import { marked } from 'marked';
const { t } = useI18n();
const configStore = useConfigStore();
const updateStore = useUpdateStore();
// 初始化Remarkable实例并配置
const md = markdownit({
html: true, // 允许HTML
linkify: false, // 不解析链接
typographer: true, // 开启智能引号
xhtmlOut: true, // 使用xhtml语法输出
breaks: true, // 允许换行
// 配置marked
marked.setOptions({
breaks: true, // 允许换行
gfm: true, // GitHub风格Markdown
});
// 计算属性
@@ -29,10 +26,10 @@ const autoCheckUpdates = computed({
}
});
// 使用Remarkable解析Markdown
// 使用marked解析Markdown
const parseMarkdown = (markdown: string) => {
if (!markdown) return '';
return md.render(markdown);
return marked.parse(markdown) as string;
};
// 处理更新按钮点击