🚧 Refactor markdown preview extension
This commit is contained in:
3060
frontend/package-lock.json
generated
3060
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
1945
frontend/src/common/constant/emojies.ts
Normal file
1945
frontend/src/common/constant/emojies.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
@@ -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'] });
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Token } from 'markdown-it';
|
||||
|
||||
/**
|
||||
* Emoji 渲染函数
|
||||
*/
|
||||
export default function emoji_html(tokens: Token[], idx: number): string {
|
||||
return tokens[idx].content;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()];
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
|
||||
402
frontend/src/views/editor/extensions/markdown/plugins/mermaid.ts
Normal file
402
frontend/src/views/editor/extensions/markdown/plugins/mermaid.ts
Normal 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());
|
||||
}
|
||||
@@ -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)'
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
// 处理更新按钮点击
|
||||
|
||||
Reference in New Issue
Block a user