Added markdown and mermaid preview

This commit is contained in:
2025-11-16 02:37:30 +08:00
parent 1d7aee4cea
commit 031aa49f9f
30 changed files with 5056 additions and 469 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -54,21 +54,24 @@
"@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",
"@prettier/plugin-xml": "^3.4.2",
"@replit/codemirror-lang-svelte": "^6.0.0",
"@toml-tools/lexer": "^1.0.0",
"@toml-tools/parser": "^1.0.0",
"@types/markdown-it": "^14.1.2",
"codemirror": "^6.0.2",
"codemirror-lang-elixir": "^4.0.0",
"colors-named": "^1.0.2",
"colors-named-hex": "^1.0.2",
"groovy-beautify": "^0.0.17",
"highlight.js": "^11.11.1",
"hsl-matcher": "^1.2.4",
"i": "^0.3.7",
"java-parser": "^3.0.1",
"jsox": "^1.2.123",
"linguist-languages": "^9.1.0",
"markdown-exit": "^1.0.0-beta.6",
"markdown-it": "^14.1.0",
"mermaid": "^11.12.1",
"npm": "^11.6.2",
"php-parser": "^3.2.5",
"pinia": "^3.0.4",
@@ -90,14 +93,18 @@
"eslint": "^9.39.1",
"eslint-plugin-vue": "^10.5.1",
"globals": "^16.5.0",
"happy-dom": "^20.0.10",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.4",
"unplugin-vue-components": "^30.0.0",
"vite": "^7.2.2",
"vite": "npm:rolldown-vite@latest",
"vite-plugin-node-polyfills": "^0.24.0",
"vitepress": "^2.0.0-alpha.12",
"vitest": "^4.0.8",
"vue-eslint-parser": "^10.2.0",
"vue-tsc": "^3.1.3"
},
"overrides": {
"vite": "npm:rolldown-vite@latest"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,329 @@
/**
* DOM Diff 算法单元测试
*/
import { describe, test, expect, beforeEach, afterEach } from 'vitest';
import { morphNode, morphHTML, morphWithKeys } from './domDiff';
describe('DOM Diff Algorithm', () => {
let container: HTMLElement;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
});
afterEach(() => {
document.body.removeChild(container);
});
describe('morphNode - 基础功能', () => {
test('应该更新文本节点内容', () => {
const fromNode = document.createTextNode('Hello');
const toNode = document.createTextNode('World');
container.appendChild(fromNode);
morphNode(fromNode, toNode);
expect(fromNode.nodeValue).toBe('World');
});
test('应该保持相同的文本节点不变', () => {
const fromNode = document.createTextNode('Hello');
const toNode = document.createTextNode('Hello');
container.appendChild(fromNode);
const originalNode = fromNode;
morphNode(fromNode, toNode);
expect(fromNode).toBe(originalNode);
expect(fromNode.nodeValue).toBe('Hello');
});
test('应该替换不同类型的节点', () => {
const fromNode = document.createElement('span');
fromNode.textContent = 'Hello';
const toNode = document.createElement('div');
toNode.textContent = 'World';
container.appendChild(fromNode);
morphNode(fromNode, toNode);
expect(container.firstChild?.nodeName).toBe('DIV');
expect(container.firstChild?.textContent).toBe('World');
});
});
describe('morphNode - 属性更新', () => {
test('应该添加新属性', () => {
const fromEl = document.createElement('div');
const toEl = document.createElement('div');
toEl.setAttribute('class', 'test');
toEl.setAttribute('id', 'myid');
container.appendChild(fromEl);
morphNode(fromEl, toEl);
expect(fromEl.getAttribute('class')).toBe('test');
expect(fromEl.getAttribute('id')).toBe('myid');
});
test('应该更新已存在的属性', () => {
const fromEl = document.createElement('div');
fromEl.setAttribute('class', 'old');
const toEl = document.createElement('div');
toEl.setAttribute('class', 'new');
container.appendChild(fromEl);
morphNode(fromEl, toEl);
expect(fromEl.getAttribute('class')).toBe('new');
});
test('应该删除不存在的属性', () => {
const fromEl = document.createElement('div');
fromEl.setAttribute('class', 'test');
fromEl.setAttribute('id', 'myid');
const toEl = document.createElement('div');
toEl.setAttribute('class', 'test');
container.appendChild(fromEl);
morphNode(fromEl, toEl);
expect(fromEl.getAttribute('class')).toBe('test');
expect(fromEl.hasAttribute('id')).toBe(false);
});
});
describe('morphNode - 子节点更新', () => {
test('应该添加新子节点', () => {
const fromEl = document.createElement('ul');
fromEl.innerHTML = '<li>1</li><li>2</li>';
const toEl = document.createElement('ul');
toEl.innerHTML = '<li>1</li><li>2</li><li>3</li>';
container.appendChild(fromEl);
morphNode(fromEl, toEl);
expect(fromEl.children.length).toBe(3);
expect(fromEl.children[2].textContent).toBe('3');
});
test('应该删除多余的子节点', () => {
const fromEl = document.createElement('ul');
fromEl.innerHTML = '<li>1</li><li>2</li><li>3</li>';
const toEl = document.createElement('ul');
toEl.innerHTML = '<li>1</li><li>2</li>';
container.appendChild(fromEl);
morphNode(fromEl, toEl);
expect(fromEl.children.length).toBe(2);
expect(fromEl.textContent).toBe('12');
});
test('应该更新子节点内容', () => {
const fromEl = document.createElement('div');
fromEl.innerHTML = '<p>Old</p>';
const toEl = document.createElement('div');
toEl.innerHTML = '<p>New</p>';
container.appendChild(fromEl);
const originalP = fromEl.querySelector('p');
morphNode(fromEl, toEl);
// 应该保持同一个 p 元素,只更新内容
expect(fromEl.querySelector('p')).toBe(originalP);
expect(fromEl.querySelector('p')?.textContent).toBe('New');
});
});
describe('morphHTML - HTML 字符串更新', () => {
test('应该从 HTML 字符串更新元素', () => {
const element = document.createElement('div');
element.innerHTML = '<p>Old</p>';
container.appendChild(element);
morphHTML(element, '<p>New</p>');
expect(element.innerHTML).toBe('<p>New</p>');
});
test('应该处理复杂的 HTML 结构', () => {
const element = document.createElement('div');
element.innerHTML = '<h1>Title</h1><p>Paragraph</p>';
container.appendChild(element);
morphHTML(element, '<h1>New Title</h1><p>New Paragraph</p><span>Extra</span>');
expect(element.children.length).toBe(3);
expect(element.querySelector('h1')?.textContent).toBe('New Title');
expect(element.querySelector('p')?.textContent).toBe('New Paragraph');
expect(element.querySelector('span')?.textContent).toBe('Extra');
});
});
describe('morphWithKeys - 基于 key 的智能 diff', () => {
test('应该保持相同 key 的节点', () => {
const fromEl = document.createElement('ul');
fromEl.innerHTML = `
<li data-key="a">A</li>
<li data-key="b">B</li>
<li data-key="c">C</li>
`;
const toEl = document.createElement('ul');
toEl.innerHTML = `
<li data-key="a">A Updated</li>
<li data-key="b">B</li>
<li data-key="c">C</li>
`;
container.appendChild(fromEl);
const originalA = fromEl.querySelector('[data-key="a"]');
morphWithKeys(fromEl, toEl);
expect(fromEl.querySelector('[data-key="a"]')).toBe(originalA);
expect(originalA?.textContent).toBe('A Updated');
});
test('应该重新排序节点', () => {
const fromEl = document.createElement('ul');
fromEl.innerHTML = `
<li data-key="a">A</li>
<li data-key="b">B</li>
<li data-key="c">C</li>
`;
const toEl = document.createElement('ul');
toEl.innerHTML = `
<li data-key="c">C</li>
<li data-key="a">A</li>
<li data-key="b">B</li>
`;
container.appendChild(fromEl);
morphWithKeys(fromEl, toEl);
const keys = Array.from(fromEl.children).map(child => child.getAttribute('data-key'));
expect(keys).toEqual(['c', 'a', 'b']);
});
test('应该添加新的 key 节点', () => {
const fromEl = document.createElement('ul');
fromEl.innerHTML = `
<li data-key="a">A</li>
<li data-key="b">B</li>
`;
const toEl = document.createElement('ul');
toEl.innerHTML = `
<li data-key="a">A</li>
<li data-key="b">B</li>
<li data-key="c">C</li>
`;
container.appendChild(fromEl);
morphWithKeys(fromEl, toEl);
expect(fromEl.children.length).toBe(3);
expect(fromEl.querySelector('[data-key="c"]')?.textContent).toBe('C');
});
test('应该删除不存在的 key 节点', () => {
const fromEl = document.createElement('ul');
fromEl.innerHTML = `
<li data-key="a">A</li>
<li data-key="b">B</li>
<li data-key="c">C</li>
`;
const toEl = document.createElement('ul');
toEl.innerHTML = `
<li data-key="a">A</li>
<li data-key="c">C</li>
`;
container.appendChild(fromEl);
morphWithKeys(fromEl, toEl);
expect(fromEl.children.length).toBe(2);
expect(fromEl.querySelector('[data-key="b"]')).toBeNull();
});
});
describe('性能测试', () => {
test('应该高效处理大量节点', () => {
const fromEl = document.createElement('ul');
for (let i = 0; i < 1000; i++) {
const li = document.createElement('li');
li.textContent = `Item ${i}`;
fromEl.appendChild(li);
}
const toEl = document.createElement('ul');
for (let i = 0; i < 1000; i++) {
const li = document.createElement('li');
li.textContent = `Updated Item ${i}`;
toEl.appendChild(li);
}
container.appendChild(fromEl);
const startTime = performance.now();
morphNode(fromEl, toEl);
const endTime = performance.now();
expect(endTime - startTime).toBeLessThan(100); // 应该在 100ms 内完成
expect(fromEl.children.length).toBe(1000);
expect(fromEl.children[0].textContent).toBe('Updated Item 0');
});
});
describe('边界情况', () => {
test('应该处理空节点', () => {
const fromEl = document.createElement('div');
const toEl = document.createElement('div');
container.appendChild(fromEl);
expect(() => morphNode(fromEl, toEl)).not.toThrow();
});
test('应该处理只有文本的节点', () => {
const fromEl = document.createElement('div');
fromEl.textContent = 'Hello';
const toEl = document.createElement('div');
toEl.textContent = 'World';
container.appendChild(fromEl);
morphNode(fromEl, toEl);
expect(fromEl.textContent).toBe('World');
});
test('应该处理嵌套的复杂结构', () => {
const fromEl = document.createElement('div');
fromEl.innerHTML = `
<div class="outer">
<div class="inner">
<span>Text</span>
</div>
</div>
`;
const toEl = document.createElement('div');
toEl.innerHTML = `
<div class="outer modified">
<div class="inner">
<span>Updated Text</span>
<strong>New</strong>
</div>
</div>
`;
container.appendChild(fromEl);
morphNode(fromEl, toEl);
expect(fromEl.querySelector('.outer')?.classList.contains('modified')).toBe(true);
expect(fromEl.querySelector('span')?.textContent).toBe('Updated Text');
expect(fromEl.querySelector('strong')?.textContent).toBe('New');
});
});
});

View File

@@ -0,0 +1,180 @@
/**
* 轻量级 DOM Diff 算法实现
* 基于 morphdom 思路,只更新变化的节点,保持未变化的节点不动
*/
/**
* 比较并更新两个 DOM 节点
* @param fromNode 原节点
* @param toNode 目标节点
*/
export function morphNode(fromNode: Node, toNode: Node): void {
// 节点类型不同,直接替换
if (fromNode.nodeType !== toNode.nodeType || fromNode.nodeName !== toNode.nodeName) {
fromNode.parentNode?.replaceChild(toNode.cloneNode(true), fromNode);
return;
}
// 文本节点:比较内容
if (fromNode.nodeType === Node.TEXT_NODE) {
if (fromNode.nodeValue !== toNode.nodeValue) {
fromNode.nodeValue = toNode.nodeValue;
}
return;
}
// 元素节点:更新属性和子节点
if (fromNode.nodeType === Node.ELEMENT_NODE) {
const fromEl = fromNode as Element;
const toEl = toNode as Element;
// 更新属性
morphAttributes(fromEl, toEl);
// 更新子节点
morphChildren(fromEl, toEl);
}
}
/**
* 更新元素属性
*/
function morphAttributes(fromEl: Element, toEl: Element): void {
// 移除旧属性
const fromAttrs = fromEl.attributes;
for (let i = fromAttrs.length - 1; i >= 0; i--) {
const attr = fromAttrs[i];
if (!toEl.hasAttribute(attr.name)) {
fromEl.removeAttribute(attr.name);
}
}
// 添加/更新新属性
const toAttrs = toEl.attributes;
for (let i = 0; i < toAttrs.length; i++) {
const attr = toAttrs[i];
const fromValue = fromEl.getAttribute(attr.name);
if (fromValue !== attr.value) {
fromEl.setAttribute(attr.name, attr.value);
}
}
}
/**
* 更新子节点(核心 diff 算法)
*/
function morphChildren(fromEl: Element, toEl: Element): void {
const fromChildren = Array.from(fromEl.childNodes);
const toChildren = Array.from(toEl.childNodes);
const fromLen = fromChildren.length;
const toLen = toChildren.length;
const minLen = Math.min(fromLen, toLen);
// 1. 更新公共部分
for (let i = 0; i < minLen; i++) {
morphNode(fromChildren[i], toChildren[i]);
}
// 2. 移除多余的旧节点
if (fromLen > toLen) {
for (let i = fromLen - 1; i >= toLen; i--) {
fromEl.removeChild(fromChildren[i]);
}
}
// 3. 添加新节点
if (toLen > fromLen) {
for (let i = fromLen; i < toLen; i++) {
fromEl.appendChild(toChildren[i].cloneNode(true));
}
}
}
/**
* 优化版:使用 key 进行更智能的 diff可选
* 适用于有 data-key 属性的元素
*/
export function morphWithKeys(fromEl: Element, toEl: Element): void {
const toChildren = Array.from(toEl.children) as Element[];
// 构建 from 的 key 映射
const fromKeyMap = new Map<string, Element>();
Array.from(fromEl.children).forEach((child) => {
const key = child.getAttribute('data-key');
if (key) {
fromKeyMap.set(key, child);
}
});
const processedKeys = new Set<string>();
// 按照 toChildren 的顺序处理
toChildren.forEach((toChild, toIndex) => {
const key = toChild.getAttribute('data-key');
if (!key) return;
processedKeys.add(key);
const fromChild = fromKeyMap.get(key);
if (fromChild) {
// 找到对应节点,更新内容
morphNode(fromChild, toChild);
// 确保节点在正确的位置
const currentNode = fromEl.children[toIndex];
if (currentNode !== fromChild) {
// 将 fromChild 移动到正确位置
fromEl.insertBefore(fromChild, currentNode);
}
} else {
// 新节点,插入到正确位置
const currentNode = fromEl.children[toIndex];
fromEl.insertBefore(toChild.cloneNode(true), currentNode || null);
}
});
// 删除不再存在的节点(从后往前删除,避免索引问题)
const childrenToRemove: Element[] = [];
fromKeyMap.forEach((child, key) => {
if (!processedKeys.has(key)) {
childrenToRemove.push(child);
}
});
childrenToRemove.forEach(child => {
fromEl.removeChild(child);
});
}
/**
* 高级 API直接从 HTML 字符串更新元素
*/
export function morphHTML(element: Element, htmlString: string): void {
const tempContainer = document.createElement('div');
tempContainer.innerHTML = htmlString;
// 更新元素的子节点列表
morphChildren(element, tempContainer);
}
/**
* 批量更新(使用 DocumentFragment
*/
export function batchMorph(element: Element, htmlString: string): void {
const tempContainer = document.createElement('div');
tempContainer.innerHTML = htmlString;
const fragment = document.createDocumentFragment();
Array.from(tempContainer.childNodes).forEach(node => {
fragment.appendChild(node);
});
// 清空原内容
while (element.firstChild) {
element.removeChild(element.firstChild);
}
// 批量插入
element.appendChild(fragment);
}

View File

@@ -13,16 +13,20 @@ import {getActiveNoteBlock} from '@/views/editor/extensions/codeblock/state';
import {getLanguage} from '@/views/editor/extensions/codeblock/lang-parser/languages';
import {formatBlockContent} from '@/views/editor/extensions/codeblock/formatCode';
import {createDebounce} from '@/common/utils/debounce';
import {toggleMarkdownPreview} from '@/views/editor/extensions/markdownPreview';
import {usePanelStore} from '@/stores/panelStore';
const editorStore = readonly(useEditorStore());
const configStore = readonly(useConfigStore());
const updateStore = readonly(useUpdateStore());
const windowStore = readonly(useWindowStore());
const systemStore = readonly(useSystemStore());
const panelStore = readonly(usePanelStore());
const {t} = useI18n();
const router = useRouter();
const canFormatCurrentBlock = ref(false);
const canPreviewMarkdown = ref(false);
const isLoaded = shallowRef(false);
const { documentStats } = toRefs(editorStore);
@@ -33,6 +37,11 @@ const isCurrentWindowOnTop = computed(() => {
return config.value.general.alwaysOnTop || systemStore.isWindowOnTop;
});
// 当前文档的预览是否打开
const isCurrentBlockPreviewing = computed(() => {
return panelStore.markdownPreview.isOpen && !panelStore.markdownPreview.isClosing;
});
// 切换窗口置顶状态
const toggleAlwaysOnTop = async () => {
const currentlyOnTop = isCurrentWindowOnTop.value;
@@ -60,11 +69,22 @@ const formatCurrentBlock = () => {
formatBlockContent(editorStore.editorView);
};
// 格式化按钮状态更新 - 使用更高效的检查逻辑
const updateFormatButtonState = () => {
const view = editorStore.editorView;
// 切换 Markdown 预览
const { debouncedFn: debouncedTogglePreview } = createDebounce(() => {
if (!canPreviewMarkdown.value || !editorStore.editorView) return;
toggleMarkdownPreview(editorStore.editorView as any);
}, { delay: 200 });
const togglePreview = () => {
debouncedTogglePreview();
};
// 统一更新按钮状态
const updateButtonStates = () => {
const view: any = editorStore.editorView;
if (!view) {
canFormatCurrentBlock.value = false;
canPreviewMarkdown.value = false;
return;
}
@@ -75,20 +95,25 @@ const updateFormatButtonState = () => {
// 提前返回,减少不必要的计算
if (!activeBlock) {
canFormatCurrentBlock.value = false;
canPreviewMarkdown.value = false;
return;
}
const language = getLanguage(activeBlock.language.name as any);
const languageName = activeBlock.language.name;
const language = getLanguage(languageName as any);
canFormatCurrentBlock.value = Boolean(language?.prettier);
canPreviewMarkdown.value = languageName.toLowerCase() === 'md';
} catch (error) {
console.warn('Error checking format capability:', error);
console.warn('Error checking block capabilities:', error);
canFormatCurrentBlock.value = false;
canPreviewMarkdown.value = false;
}
};
// 创建带1s防抖的更新函数
const { debouncedFn: debouncedUpdateFormat, cancel: cancelDebounce } = createDebounce(
updateFormatButtonState,
const { debouncedFn: debouncedUpdateButtonStates, cancel: cancelDebounce } = createDebounce(
updateButtonStates,
{ delay: 1000 }
);
@@ -102,9 +127,9 @@ const setupEditorListeners = (view: any) => {
// 使用对象缓存事件处理器,避免重复创建
const eventHandlers = {
click: updateFormatButtonState,
keyup: debouncedUpdateFormat,
focus: updateFormatButtonState
click: updateButtonStates,
keyup: debouncedUpdateButtonStates,
focus: updateButtonStates
} as const;
const events = Object.entries(eventHandlers).map(([type, handler]) => ({
@@ -131,11 +156,12 @@ watch(
if (newView) {
// 初始更新状态
updateFormatButtonState();
updateButtonStates();
// 设置新监听器
cleanupListeners = setupEditorListeners(newView);
} else {
canFormatCurrentBlock.value = false;
canPreviewMarkdown.value = false;
}
});
},
@@ -145,8 +171,8 @@ watch(
// 组件生命周期
onMounted(async () => {
isLoaded.value = true;
// 首次更新格式化状态
updateFormatButtonState();
// 首次更新按钮状态
updateButtonStates();
await systemStore.setWindowOnTop(isCurrentWindowOnTop.value);
});
@@ -229,6 +255,21 @@ const statsData = computed(() => ({
<!-- 块语言选择器 -->
<BlockLanguageSelector/>
<!-- Markdown预览按钮 -->
<div
v-if="canPreviewMarkdown"
class="preview-button"
:class="{ 'active': isCurrentBlockPreviewing }"
:title="isCurrentBlockPreviewing ? t('toolbar.closePreview') : t('toolbar.previewMarkdown')"
@click="togglePreview"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
</div>
<!-- 格式化按钮 - 支持点击操作 -->
<div
v-if="canFormatCurrentBlock"
@@ -512,6 +553,42 @@ const statsData = computed(() => ({
}
}
.preview-button {
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 2px;
border-radius: 3px;
transition: all 0.2s ease;
&:hover {
background-color: var(--border-color);
opacity: 0.8;
}
&.active {
background-color: rgba(100, 149, 237, 0.2);
svg {
stroke: #6495ed;
}
}
svg {
width: 14px;
height: 14px;
stroke: var(--text-muted);
transition: stroke 0.2s ease;
}
&:hover svg {
stroke: var(--text-secondary);
}
}
.settings-btn {
background: none;
border: none;

View File

@@ -19,6 +19,8 @@ export default {
searchLanguage: 'Search language...',
noLanguageFound: 'No language found',
formatHint: 'Click Format Block (Ctrl+Shift+F)',
previewMarkdown: 'Preview Markdown',
closePreview: 'Close Preview',
// Document selector
selectDocument: 'Select Document',
searchOrCreateDocument: 'Search or enter new document name...',

View File

@@ -19,6 +19,8 @@ export default {
searchLanguage: '搜索语言...',
noLanguageFound: '未找到匹配的语言',
formatHint: '点击格式化区块Ctrl+Shift+F',
previewMarkdown: '预览 Markdown',
closePreview: '关闭预览',
// 文档选择器
selectDocument: '选择文档',
searchOrCreateDocument: '搜索或输入新文档名...',

View File

@@ -4,6 +4,7 @@ import {DocumentService} from '@/../bindings/voidraft/internal/services';
import {OpenDocumentWindow} from '@/../bindings/voidraft/internal/services/windowservice';
import {Document} from '@/../bindings/voidraft/internal/models/models';
import {useTabStore} from "@/stores/tabStore";
import type {EditorViewState} from '@/stores/editorStore';
export const useDocumentStore = defineStore('document', () => {
const DEFAULT_DOCUMENT_ID = ref<number>(1); // 默认草稿文档ID
@@ -14,10 +15,8 @@ export const useDocumentStore = defineStore('document', () => {
const currentDocument = ref<Document | null>(null);
// === 编辑器状态持久化 ===
const documentStates = ref<Record<number, {
cursorPos: number;
scrollTop: number;
}>>({});
// 修复:使用统一的 EditorViewState 类型定义
const documentStates = ref<Record<number, EditorViewState>>({});
// === UI状态 ===
const showDocumentSelector = ref(false);

View File

@@ -28,6 +28,8 @@ import {generateContentHash} from "@/common/utils/hashUtils";
import {createTimerManager, type TimerManager} from '@/common/utils/timerUtils';
import {EDITOR_CONFIG} from '@/common/constant/editor';
import {createHttpClientExtension} from "@/views/editor/extensions/httpclient";
import {markdownPreviewExtension} from "@/views/editor/extensions/markdownPreview";
import {createDebounce} from '@/common/utils/debounce';
export interface DocumentStats {
lines: number;
@@ -35,6 +37,11 @@ export interface DocumentStats {
selectedCharacters: number;
}
// 修复:只保存光标位置,恢复时自动滚动到光标处(更简单可靠)
export interface EditorViewState {
cursorPos: number;
}
interface EditorInstance {
view: EditorView;
documentId: number;
@@ -47,10 +54,8 @@ interface EditorInstance {
lastContentHash: string;
lastParsed: Date;
} | null;
editorState?: {
cursorPos: number;
scrollTop: number;
};
// 修复:使用统一的类型,可选但不是 undefined | {...}
editorState?: EditorViewState;
}
export const useEditorStore = defineStore('editor', () => {
@@ -72,6 +77,8 @@ export const useEditorStore = defineStore('editor', () => {
// 编辑器加载状态
const isLoading = ref(false);
// 修复:使用操作计数器精确管理加载状态
const loadingOperations = ref(0);
// 异步操作管理器
const operationManager = new AsyncManager<number>();
@@ -79,6 +86,13 @@ export const useEditorStore = defineStore('editor', () => {
// 自动保存设置 - 从配置动态获取
const getAutoSaveDelay = () => configStore.config.editing.autoSaveDelay;
// 创建防抖的语法树缓存清理函数
const debouncedClearSyntaxCache = createDebounce((instance) => {
if (instance) {
instance.syntaxTreeCache = null;
}
}, { delay: 500 }); // 500ms 内的多次输入只清理一次
// === 私有方法 ===
/**
@@ -123,13 +137,13 @@ export const useEditorStore = defineStore('editor', () => {
};
/**
* 恢复编辑器的光标和滚动位置
* 恢复编辑器的光标位置(自动滚动到光标处)
*/
const restoreEditorState = (instance: EditorInstance, documentId: number): void => {
const savedState = instance.editorState || documentStore.documentStates[documentId];
const savedState = instance.editorState;
if (savedState) {
// 有保存的状态,恢复光标位置和滚动位置
// 有保存的状态,恢复光标位置
let pos = Math.min(savedState.cursorPos, instance.view.state.doc.length);
// 确保位置不在分隔符上
@@ -137,21 +151,23 @@ export const useEditorStore = defineStore('editor', () => {
pos = adjustCursorPosition(instance.view, pos);
}
// 修复:设置光标位置并居中滚动(更好的用户体验)
instance.view.dispatch({
selection: {anchor: pos, head: pos}
selection: {anchor: pos, head: pos},
effects: EditorView.scrollIntoView(pos, {
y: "center", // 垂直居中显示
yMargin: 100 // 上下留一些边距
})
});
// 恢复滚动位置
instance.view.scrollDOM.scrollTop = savedState.scrollTop;
// 更新实例状态
instance.editorState = savedState;
} else {
// 首次打开或没有记录,光标在文档末尾
const docLength = instance.view.state.doc.length;
instance.view.dispatch({
selection: {anchor: docLength, head: docLength},
scrollIntoView: true
effects: EditorView.scrollIntoView(docLength, {
y: "center",
yMargin: 100
})
});
}
};
@@ -239,6 +255,9 @@ export const useEditorStore = defineStore('editor', () => {
const httpExtension = createHttpClientExtension();
// Markdown预览扩展
const previewExtension = markdownPreviewExtension();
// 再次检查操作有效性
if (!operationManager.isOperationValid(operationId, documentId)) {
throw new Error('Operation cancelled');
@@ -271,7 +290,8 @@ export const useEditorStore = defineStore('editor', () => {
contentChangeExtension,
codeBlockExtension,
...dynamicExtensions,
...httpExtension
...httpExtension,
previewExtension
];
// 创建编辑器状态
@@ -294,7 +314,9 @@ export const useEditorStore = defineStore('editor', () => {
isDirty: false,
lastModified: new Date(),
autoSaveTimer: createTimerManager(),
syntaxTreeCache: null
syntaxTreeCache: null,
// 修复:创建实例时从 documentStore 读取持久化的编辑器状态
editorState: documentStore.documentStates[documentId]
};
// 使用LRU缓存的onEvict回调处理被驱逐的实例
@@ -332,10 +354,19 @@ export const useEditorStore = defineStore('editor', () => {
// 创建新的编辑器实例
const view = await createEditorInstance(content, operationId, documentId);
// 最终检查操作有效性
// 完善取消操作时的清理逻辑
if (!operationManager.isOperationValid(operationId, documentId)) {
// 如果操作已取消,清理创建的实例
view.destroy();
// 如果操作已取消,彻底清理创建的实例
try {
// 移除 DOM 元素(如果已添加到文档)
if (view.dom && view.dom.parentElement) {
view.dom.remove();
}
// 销毁编辑器视图
view.destroy();
} catch (error) {
console.error('Error cleaning up cancelled editor:', error);
}
throw new Error('Operation cancelled');
}
@@ -350,34 +381,11 @@ export const useEditorStore = defineStore('editor', () => {
if (!instance || !containerElement.value) return;
try {
// 保存当前编辑器的状态
if (currentEditor.value) {
const currentDocId = documentStore.currentDocumentId;
const currentInstance = currentDocId ? editorCache.get(currentDocId) : null;
if (currentInstance) {
// 保存到实例缓存
currentInstance.editorState = {
cursorPos: currentEditor.value.state.selection.main.head,
scrollTop: currentEditor.value.scrollDOM.scrollTop
};
// 同时保存到 documentStore 用于持久化
if (currentDocId) {
documentStore.documentStates[currentDocId] = {
cursorPos: currentEditor.value.state.selection.main.head,
scrollTop: currentEditor.value.scrollDOM.scrollTop
};
}
}
}
// 移除当前编辑器DOM
if (currentEditor.value && currentEditor.value.dom && currentEditor.value.dom.parentElement) {
currentEditor.value.dom.remove();
}
// 确保容器为空
containerElement.value.innerHTML = '';
// 将目标编辑器DOM添加到容器
containerElement.value.appendChild(instance.view.dom);
currentEditor.value = instance.view;
@@ -385,16 +393,18 @@ export const useEditorStore = defineStore('editor', () => {
// 设置扩展管理器视图
setExtensionManagerView(instance.view, documentId);
// 重新测量和聚焦编辑器
//使用 nextTick + requestAnimationFrame 确保 DOM 完全渲染
nextTick(() => {
// 恢复编辑器状态(光标位置和滚动位置)
restoreEditorState(instance, documentId);
// 聚焦编辑器
instance.view.focus();
// 使用缓存的语法树确保方法
ensureSyntaxTreeCached(instance.view, documentId);
requestAnimationFrame(() => {
// 恢复编辑器状态(光标位置和滚动位置)
restoreEditorState(instance, documentId);
// 聚焦编辑器
instance.view.focus();
// 使用缓存的语法树确保方法
ensureSyntaxTreeCached(instance.view, documentId);
});
});
} catch (error) {
console.error('Error showing editor:', error);
@@ -428,30 +438,20 @@ export const useEditorStore = defineStore('editor', () => {
};
// 内容变化处理
const onContentChange = (documentId: number) => {
const onContentChange = () => {
const documentId = documentStore.currentDocumentId;
if (!documentId) return;
const instance = editorCache.get(documentId);
if (!instance) return;
// 立即设置脏标记和修改时间(切换文档时需要判断)
instance.isDirty = true;
instance.lastModified = new Date();
// 清理语法树缓存,下次访问时重新构建
instance.syntaxTreeCache = null;
// 优使用防抖清理语法树缓存
debouncedClearSyntaxCache.debouncedFn(instance);
// 保存当前编辑器状态(光标位置和滚动位置
if (instance.view) {
instance.editorState = {
cursorPos: instance.view.state.selection.main.head,
scrollTop: instance.view.scrollDOM.scrollTop
};
// 同时保存到 documentStore 用于持久化
documentStore.documentStates[documentId] = {
cursorPos: instance.view.state.selection.main.head,
scrollTop: instance.view.scrollDOM.scrollTop
};
}
// 设置自动保存定时器
// 设置自动保存定时器(已经是防抖效果:每次重置定时器
instance.autoSaveTimer.set(() => {
saveEditorContent(documentId);
}, getAutoSaveDelay());
@@ -471,7 +471,8 @@ export const useEditorStore = defineStore('editor', () => {
// 加载编辑器
const loadEditor = async (documentId: number, content: string) => {
// 设置加载状态
// 修复:使用计数器精确管理加载状态
loadingOperations.value++;
isLoading.value = true;
// 开始新的操作
@@ -520,6 +521,9 @@ export const useEditorStore = defineStore('editor', () => {
instance.isDirty = false;
// 清理语法树缓存,因为内容已更新
instance.syntaxTreeCache = null;
// 修复:内容变了,清空光标位置,避免越界
instance.editorState = undefined;
delete documentStore.documentStates[documentId];
}
}
@@ -541,15 +545,20 @@ export const useEditorStore = defineStore('editor', () => {
// 完成操作
operationManager.completeOperation(operationId);
// 延迟一段时间后再取消加载状态
// 修复:使用计数器精确管理加载状态,避免快速切换时状态不准确
loadingOperations.value--;
// 延迟一段时间后再取消加载状态,但要确保所有操作都完成了
setTimeout(() => {
isLoading.value = false;
if (loadingOperations.value <= 0) {
loadingOperations.value = 0;
isLoading.value = false;
}
}, EDITOR_CONFIG.LOADING_DELAY);
}
};
// 移除编辑器
const removeEditor = (documentId: number) => {
const removeEditor = async (documentId: number) => {
const instance = editorCache.get(documentId);
if (instance) {
try {
@@ -558,6 +567,20 @@ export const useEditorStore = defineStore('editor', () => {
operationManager.cancelAllOperations();
}
// 修复:移除前先保存内容(如果有未保存的修改)
if (instance.isDirty) {
await saveEditorContent(documentId);
}
// 保存光标位置
if (instance.view && instance.view.state) {
const currentState: EditorViewState = {
cursorPos: instance.view.state.selection.main.head
};
// 保存到 documentStore 用于持久化
documentStore.documentStates[documentId] = currentState;
}
// 清除自动保存定时器
instance.autoSaveTimer.clear();
@@ -639,17 +662,14 @@ export const useEditorStore = defineStore('editor', () => {
operationManager.cancelAllOperations();
editorCache.clear((_documentId, instance) => {
// 在销毁前保存编辑器状态
// 修复:清空前只保存光标位置
if (instance.view) {
instance.editorState = {
cursorPos: instance.view.state.selection.main.head,
scrollTop: instance.view.scrollDOM.scrollTop
};
// 保存到 documentStore 用于持久化
documentStore.documentStates[instance.documentId] = {
cursorPos: instance.view.state.selection.main.head,
scrollTop: instance.view.scrollDOM.scrollTop
const currentState: EditorViewState = {
cursorPos: instance.view.state.selection.main.head
};
// 同时保存到实例和 documentStore
instance.editorState = currentState;
documentStore.documentStates[instance.documentId] = currentState;
}
// 清除自动保存定时器
@@ -693,12 +713,24 @@ export const useEditorStore = defineStore('editor', () => {
};
// 监听文档切换
watch(() => documentStore.currentDocument, async (newDoc) => {
watch(() => documentStore.currentDocument, async (newDoc, oldDoc) => {
if (newDoc && containerElement.value) {
// 使用 nextTick 确保DOM更新完成后再加载编辑器
await nextTick(() => {
loadEditor(newDoc.id, newDoc.content);
});
// 修复:在切换到新文档前,只保存旧文档的光标位置
if (oldDoc && oldDoc.id !== newDoc.id && currentEditor.value) {
const oldInstance = editorCache.get(oldDoc.id);
if (oldInstance) {
const currentState: EditorViewState = {
cursorPos: currentEditor.value.state.selection.main.head
};
// 同时保存到实例和 documentStore
oldInstance.editorState = currentState;
documentStore.documentStates[oldDoc.id] = currentState;
}
}
// 等待 DOM 更新完成,再加载新文档的编辑器
await nextTick();
loadEditor(newDoc.id, newDoc.content);
}
});

View File

@@ -0,0 +1,170 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import type { EditorView } from '@codemirror/view';
import { useDocumentStore } from './documentStore';
/**
* 单个文档的预览状态
*/
interface DocumentPreviewState {
isOpen: boolean;
isClosing: boolean;
blockFrom: number;
blockTo: number;
}
/**
* 面板状态管理 Store
* 管理编辑器中各种面板的显示状态按文档ID区分
*/
export const usePanelStore = defineStore('panel', () => {
// 当前编辑器视图引用
const editorView = ref<EditorView | null>(null);
// 每个文档的预览状态 Map<documentId, PreviewState>
const documentPreviews = ref<Map<number, DocumentPreviewState>>(new Map());
/**
* 获取当前文档的预览状态
*/
const markdownPreview = computed(() => {
const documentStore = useDocumentStore();
const currentDocId = documentStore.currentDocumentId;
if (currentDocId === null) {
return {
isOpen: false,
isClosing: false,
blockFrom: 0,
blockTo: 0
};
}
return documentPreviews.value.get(currentDocId) || {
isOpen: false,
isClosing: false,
blockFrom: 0,
blockTo: 0
};
});
/**
* 设置编辑器视图
*/
const setEditorView = (view: EditorView | null) => {
editorView.value = view;
};
/**
* 打开 Markdown 预览面板
*/
const openMarkdownPreview = (from: number, to: number) => {
const documentStore = useDocumentStore();
const currentDocId = documentStore.currentDocumentId;
if (currentDocId === null) return;
documentPreviews.value.set(currentDocId, {
isOpen: true,
isClosing: false,
blockFrom: from,
blockTo: to
});
};
/**
* 开始关闭 Markdown 预览面板
*/
const startClosingMarkdownPreview = () => {
const documentStore = useDocumentStore();
const currentDocId = documentStore.currentDocumentId;
if (currentDocId === null) return;
const state = documentPreviews.value.get(currentDocId);
if (state?.isOpen) {
documentPreviews.value.set(currentDocId, {
...state,
isClosing: true
});
}
};
/**
* 关闭 Markdown 预览面板
*/
const closeMarkdownPreview = () => {
const documentStore = useDocumentStore();
const currentDocId = documentStore.currentDocumentId;
if (currentDocId === null) return;
documentPreviews.value.set(currentDocId, {
isOpen: false,
isClosing: false,
blockFrom: 0,
blockTo: 0
});
};
/**
* 更新预览块的范围(用于实时预览)
*/
const updatePreviewRange = (from: number, to: number) => {
const documentStore = useDocumentStore();
const currentDocId = documentStore.currentDocumentId;
if (currentDocId === null) return;
const state = documentPreviews.value.get(currentDocId);
if (state?.isOpen) {
documentPreviews.value.set(currentDocId, {
...state,
blockFrom: from,
blockTo: to
});
}
};
/**
* 检查指定块是否正在预览
*/
const isBlockPreviewing = (from: number, to: number): boolean => {
const preview = markdownPreview.value;
return preview.isOpen &&
preview.blockFrom === from &&
preview.blockTo === to;
};
/**
* 重置所有面板状态
*/
const reset = () => {
documentPreviews.value.clear();
editorView.value = null;
};
/**
* 清理指定文档的预览状态(文档关闭时调用)
*/
const clearDocumentPreview = (documentId: number) => {
documentPreviews.value.delete(documentId);
};
return {
// 状态
editorView,
markdownPreview,
// 方法
setEditorView,
openMarkdownPreview,
startClosingMarkdownPreview,
closeMarkdownPreview,
updatePreviewRange,
isBlockPreviewing,
reset,
clearDocumentPreview
};
});

View File

@@ -1,5 +1,4 @@
import { EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view';
import { useDocumentStore } from '@/stores/documentStore';
import { useEditorStore } from '@/stores/editorStore';
/**
@@ -8,7 +7,6 @@ import { useEditorStore } from '@/stores/editorStore';
export function createContentChangePlugin() {
return ViewPlugin.fromClass(
class ContentChangePlugin {
private documentStore = useDocumentStore();
private editorStore = useEditorStore();
private lastContent = '';
@@ -24,11 +22,8 @@ export function createContentChangePlugin() {
this.lastContent = newContent;
// 通知编辑器管理器内容已变化
const currentDocId = this.documentStore.currentDocumentId;
if (currentDocId) {
this.editorStore.onContentChange(currentDocId);
}
this.editorStore.onContentChange();
}
destroy() {

View File

@@ -6,6 +6,34 @@
import { EditorView } from '@codemirror/view';
import { EditorSelection } from '@codemirror/state';
import { blockState } from './state';
import { Block } from './types';
/**
* 二分查找:找到包含指定位置的块
* blocks 数组按位置排序,使用二分查找 O(log n)
*/
function findBlockAtPos(blocks: Block[], pos: number): Block | null {
let left = 0;
let right = blocks.length - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
const block = blocks[mid];
if (pos < block.range.from) {
// 位置在当前块之前
right = mid - 1;
} else if (pos > block.range.to) {
// 位置在当前块之后
left = mid + 1;
} else {
// 位置在当前块范围内
return block;
}
}
return null;
}
/**
* 检查位置是否在分隔符区域内
@@ -13,14 +41,13 @@ import { blockState } from './state';
function isInDelimiter(view: EditorView, pos: number): boolean {
try {
const blocks = view.state.field(blockState, false);
if (!blocks) return false;
if (!blocks || blocks.length === 0) return false;
for (const block of blocks) {
if (pos >= block.delimiter.from && pos < block.delimiter.to) {
return true;
}
}
return false;
const block = findBlockAtPos(blocks, pos);
if (!block) return false;
// 检查是否在该块的分隔符区域内
return pos >= block.delimiter.from && pos < block.delimiter.to;
} catch {
return false;
}
@@ -34,22 +61,23 @@ function adjustPosition(view: EditorView, pos: number, forward: boolean): number
const blocks = view.state.field(blockState, false);
if (!blocks || blocks.length === 0) return pos;
for (const block of blocks) {
// 如果位置在分隔符内
if (pos >= block.delimiter.from && pos < block.delimiter.to) {
// 向前移动:跳到该块内容的开始
// 向后移动:跳到前一个块的内容末尾
if (forward) {
return block.content.from;
} else {
// 找到前一个块
const blockIndex = blocks.indexOf(block);
if (blockIndex > 0) {
const prevBlock = blocks[blockIndex - 1];
return prevBlock.content.to;
}
return block.delimiter.from;
const block = findBlockAtPos(blocks, pos);
if (!block) return pos;
// 如果位置在分隔符内
if (pos >= block.delimiter.from && pos < block.delimiter.to) {
// 向前移动:跳到该块内容的开始
// 向后移动:跳到前一个块的内容末尾
if (forward) {
return block.content.from;
} else {
// 找到前一个块的索引
const blockIndex = blocks.indexOf(block);
if (blockIndex > 0) {
const prevBlock = blocks[blockIndex - 1];
return prevBlock.content.to;
}
return block.delimiter.from;
}
}

View File

@@ -0,0 +1,62 @@
/**
* Markdown 预览扩展主入口
*/
import { EditorView } from "@codemirror/view";
import { useThemeStore } from "@/stores/themeStore";
import { usePanelStore } from "@/stores/panelStore";
import { useDocumentStore } from "@/stores/documentStore";
import { getActiveNoteBlock } from "../codeblock/state";
import { createMarkdownPreviewTheme } from "./styles";
import { previewPanelState, previewPanelPlugin, togglePreview, closePreviewWithAnimation } from "./state";
/**
* 切换预览面板的命令
*/
export function toggleMarkdownPreview(view: EditorView): boolean {
const panelStore = usePanelStore();
const documentStore = useDocumentStore();
const currentState = view.state.field(previewPanelState, false);
const activeBlock = getActiveNoteBlock(view.state as any);
// 如果当前没有激活的 Markdown 块,不执行操作
if (!activeBlock || activeBlock.language.name.toLowerCase() !== 'md') {
return false;
}
// 获取当前文档ID
const currentDocumentId = documentStore.currentDocumentId;
if (currentDocumentId === null) {
return false;
}
// 如果预览面板已打开(无论预览的是不是当前块),关闭预览
if (panelStore.markdownPreview.isOpen && !panelStore.markdownPreview.isClosing) {
// 使用带动画的关闭函数
closePreviewWithAnimation(view);
} else {
// 否则,打开当前块的预览
view.dispatch({
effects: togglePreview.of({
documentId: currentDocumentId,
blockFrom: activeBlock.content.from,
blockTo: activeBlock.content.to
})
});
// 注意store 状态由 ViewPlugin 在面板创建成功后更新
}
return true;
}
/**
* 导出 Markdown 预览扩展
*/
export function markdownPreviewExtension() {
const themeStore = useThemeStore();
const colors = themeStore.currentColors;
const theme = colors ? createMarkdownPreviewTheme(colors) : EditorView.baseTheme({});
return [previewPanelState, previewPanelPlugin, theme];
}

View File

@@ -0,0 +1,117 @@
/**
* Markdown 渲染器配置和自定义插件
*/
import MarkdownIt from 'markdown-it';
import {tasklist} from "@mdit/plugin-tasklist";
import {katex} from "@mdit/plugin-katex";
import markPlugin from "@/common/markdown-it/plugins/markdown-it-mark";
import hljs from 'highlight.js';
import 'highlight.js/styles/default.css';
import {full as emoji} from '@/common/markdown-it/plugins/markdown-it-emoji/'
import footnote_plugin from "@/common/markdown-it/plugins/markdown-it-footnote"
import sup_plugin from "@/common/markdown-it/plugins/markdown-it-sup"
import ins_plugin from "@/common/markdown-it/plugins/markdown-it-ins"
import deflist_plugin from "@/common/markdown-it/plugins/markdown-it-deflist"
import abbr_plugin from "@/common/markdown-it/plugins/markdown-it-abbr"
import sub_plugin from "@/common/markdown-it/plugins/markdown-it-sub"
import {MermaidIt} from "@/common/markdown-it/plugins/markdown-it-mermaid"
import {useThemeStore} from '@/stores/themeStore'
/**
* 自定义链接插件:使用 data-href 替代 href配合事件委托实现自定义跳转
*/
export function customLinkPlugin(md: MarkdownIt) {
// 保存默认的 link_open 渲染器
const defaultRender = md.renderer.rules.link_open || function (tokens, idx, options, env, self) {
return self.renderToken(tokens, idx, options);
};
// 重写 link_open 渲染器
md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
const token = tokens[idx];
// 获取 href 属性
const hrefIndex = token.attrIndex('href');
if (hrefIndex >= 0) {
const href = token.attrs![hrefIndex][1];
// 添加 data-href 属性保存原始链接
token.attrPush(['data-href', href]);
// 添加 class 用于样式
const classIndex = token.attrIndex('class');
if (classIndex < 0) {
token.attrPush(['class', 'markdown-link']);
} else {
token.attrs![classIndex][1] += ' markdown-link';
}
// 移除 href 属性,防止默认跳转
token.attrs!.splice(hrefIndex, 1);
}
return defaultRender(tokens, idx, options, env, self);
};
}
/**
* 创建 Markdown-It 实例
*/
export function createMarkdownRenderer(): MarkdownIt {
const themeStore = useThemeStore();
const mermaidTheme = themeStore.isDarkMode ? "dark" : "default";
return new MarkdownIt({
html: true,
linkify: true,
typographer: true,
breaks: true,
langPrefix: "language-",
highlight: (code, lang) => {
// 对于大代码块(>1000行跳过高亮以提升性能
if (code.length > 50000) {
return `<pre><code>${code}</code></pre>`;
}
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(code, {language: lang, ignoreIllegals: true}).value;
} catch (error) {
console.warn(`Failed to highlight code block with language: ${lang}`, error);
return code;
}
}
// 对于中等大小的代码块(>5000字符跳过自动检测
if (code.length > 5000) {
return code;
}
// 小代码块才使用自动检测
try {
return hljs.highlightAuto(code).value;
} catch (error) {
console.warn('Failed to auto-highlight code block', error);
return code;
}
}
})
.use(tasklist, {
disabled: false,
})
.use(customLinkPlugin)
.use(markPlugin)
.use(emoji)
.use(footnote_plugin)
.use(sup_plugin)
.use(ins_plugin)
.use(deflist_plugin)
.use(abbr_plugin)
.use(sub_plugin)
.use(katex)
.use(MermaidIt, {
theme: mermaidTheme
});
}

View File

@@ -0,0 +1,373 @@
/**
* Markdown 预览面板 UI 组件
*/
import {EditorView, Panel, ViewUpdate} from "@codemirror/view";
import MarkdownIt from 'markdown-it';
import * as runtime from "@wailsio/runtime";
import {previewPanelState} from "./state";
import {createMarkdownRenderer} from "./markdownRenderer";
import {updateMermaidTheme} from "@/common/markdown-it/plugins/markdown-it-mermaid";
import {useThemeStore} from "@/stores/themeStore";
import {watch} from "vue";
import {createDebounce} from "@/common/utils/debounce";
import {morphHTML} from "@/common/utils/domDiff";
/**
* Markdown 预览面板类
*/
export class MarkdownPreviewPanel {
private md: MarkdownIt;
private readonly dom: HTMLDivElement;
private readonly resizeHandle: HTMLDivElement;
private readonly content: HTMLDivElement;
private view: EditorView;
private themeUnwatch?: () => void;
private lastRenderedContent: string = "";
private debouncedUpdate: ReturnType<typeof createDebounce>;
private isDestroyed: boolean = false; // 标记面板是否已销毁
constructor(view: EditorView) {
this.view = view;
this.md = createMarkdownRenderer();
// 创建防抖更新函数
this.debouncedUpdate = createDebounce(() => {
this.updateContentInternal();
}, { delay: 1000 });
// 监听主题变化
const themeStore = useThemeStore();
this.themeUnwatch = watch(() => themeStore.isDarkMode, (isDark) => {
const newTheme = isDark ? "dark" : "default";
updateMermaidTheme(newTheme);
this.lastRenderedContent = ""; // 清空缓存,强制重新渲染
this.updateContentInternal();
});
// 创建 DOM 结构
this.dom = document.createElement("div");
this.dom.className = "cm-markdown-preview-panel";
this.resizeHandle = document.createElement("div");
this.resizeHandle.className = "cm-preview-resize-handle";
this.content = document.createElement("div");
this.content.className = "cm-preview-content";
this.dom.appendChild(this.resizeHandle);
this.dom.appendChild(this.content);
// 设置默认高度为编辑器高度的一半
const defaultHeight = Math.floor(this.view.dom.clientHeight / 2);
this.dom.style.height = `${defaultHeight}px`;
// 初始化拖动功能
this.initResize();
// 初始化链接点击处理
this.initLinkHandler();
// 初始渲染
this.updateContentInternal();
}
/**
* 初始化链接点击处理(事件委托)
*/
private initLinkHandler(): void {
this.content.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
// 查找最近的 <a> 标签
let linkElement = target;
while (linkElement && linkElement !== this.content) {
if (linkElement.tagName === 'A') {
const anchor = linkElement as HTMLAnchorElement;
const href = anchor.getAttribute('href');
// 处理脚注内部锚点链接
if (href && href.startsWith('#')) {
e.preventDefault();
// 在预览面板内查找目标元素
const targetId = href.substring(1);
// 使用 getElementById 而不是 querySelector因为 ID 可能包含特殊字符(如冒号)
const targetElement = document.getElementById(targetId);
if (targetElement && this.content.contains(targetElement)) {
// 平滑滚动到目标元素
targetElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
return;
}
// 处理带 data-href 的外部链接
if (anchor.hasAttribute('data-href')) {
e.preventDefault();
const url = anchor.getAttribute('data-href');
if (url && this.isValidUrl(url)) {
runtime.Browser.OpenURL(url);
}
return;
}
// 处理其他链接
if (href && !href.startsWith('#')) {
e.preventDefault();
// 只有有效的 URLhttp/https/mailto/file 等)才用浏览器打开
if (this.isValidUrl(href)) {
runtime.Browser.OpenURL(href);
} else {
// 相对路径或无效链接,显示提示
console.warn('Invalid or relative link in preview:', href);
}
return;
}
}
linkElement = linkElement.parentElement as HTMLElement;
}
});
}
/**
* 检查是否是有效的 URL包含协议
*/
private isValidUrl(url: string): boolean {
try {
// 检查是否包含协议
if (url.match(/^[a-zA-Z][a-zA-Z\d+\-.]*:/)) {
const parsedUrl = new URL(url);
// 允许的协议列表
const allowedProtocols = ['http:', 'https:', 'mailto:', 'file:', 'ftp:'];
return allowedProtocols.includes(parsedUrl.protocol);
}
return false;
} catch {
return false;
}
}
/**
* 初始化拖动调整高度功能
*/
private initResize(): void {
let startY = 0;
let startHeight = 0;
const onMouseMove = (e: MouseEvent) => {
const delta = startY - e.clientY;
const maxHeight = this.getMaxHeight();
const newHeight = Math.max(100, Math.min(maxHeight, startHeight + delta));
this.dom.style.height = `${newHeight}px`;
};
const onMouseUp = () => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
this.resizeHandle.classList.remove("dragging");
// 恢复 body 样式
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
this.resizeHandle.addEventListener("mousedown", (e) => {
e.preventDefault();
startY = e.clientY;
startHeight = this.dom.offsetHeight;
this.resizeHandle.classList.add("dragging");
// 设置 body 样式,防止拖动时光标闪烁
document.body.style.cursor = "ns-resize";
document.body.style.userSelect = "none";
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
});
}
/**
* 动态计算最大高度(编辑器高度)
*/
private getMaxHeight(): number {
return this.view.dom.clientHeight;
}
/**
* 内部更新预览内容(带缓存 + DOM Diff 优化)
*/
private updateContentInternal(): void {
// 如果面板已销毁,直接返回
if (this.isDestroyed) {
return;
}
try {
const state = this.view.state;
const currentPreviewState = state.field(previewPanelState, false);
if (!currentPreviewState) {
return;
}
const blockContent = state.doc.sliceString(
currentPreviewState.blockFrom,
currentPreviewState.blockTo
);
if (!blockContent || blockContent.trim().length === 0) {
return;
}
// 缓存检查:如果内容没变,不重新渲染
if (blockContent === this.lastRenderedContent) {
return;
}
// 对于大内容,使用异步渲染避免阻塞主线程
if (blockContent.length > 1000) {
this.renderLargeContentAsync(blockContent);
} else {
// 小内容使用 DOM Diff 优化渲染
this.renderWithDiff(blockContent);
}
} catch (error) {
console.warn("Error updating preview content:", error);
}
}
/**
* 使用 DOM Diff 渲染内容(保留未变化的节点)
*/
private renderWithDiff(content: string): void {
// 如果面板已销毁,直接返回
if (this.isDestroyed) {
return;
}
try {
const newHtml = this.md.render(content);
// 如果是首次渲染或内容为空,直接设置 innerHTML
if (!this.lastRenderedContent || this.content.children.length === 0) {
this.content.innerHTML = newHtml;
} else {
// 使用 DOM Diff 增量更新
morphHTML(this.content, newHtml);
}
this.lastRenderedContent = content;
} catch (error) {
console.warn("Error rendering with diff:", error);
// 降级到直接设置 innerHTML
if (!this.isDestroyed) {
this.content.innerHTML = this.md.render(content);
this.lastRenderedContent = content;
}
}
}
/**
* 异步渲染大内容(使用 DOM Diff 优化)
*/
private renderLargeContentAsync(content: string): void {
// 如果面板已销毁,直接返回
if (this.isDestroyed) {
return;
}
// 如果是首次渲染,显示加载状态
if (!this.lastRenderedContent) {
this.content.innerHTML = '<div class="markdown-loading">Rendering...</div>';
}
// 使用 requestIdleCallback 在浏览器空闲时渲染
const callback = window.requestIdleCallback || ((cb: IdleRequestCallback) => setTimeout(cb, 1));
callback(() => {
// 再次检查是否已销毁(异步回调时可能已经关闭)
if (this.isDestroyed) {
return;
}
try {
const html = this.md.render(content);
// 如果是首次渲染或之前内容为空,直接设置
if (!this.lastRenderedContent || this.content.children.length === 0) {
// 使用 DocumentFragment 减少 DOM 操作
const fragment = document.createRange().createContextualFragment(html);
this.content.innerHTML = '';
this.content.appendChild(fragment);
} else {
// 使用 DOM Diff 增量更新(保留滚动位置和未变化的节点)
morphHTML(this.content, html);
}
this.lastRenderedContent = content;
} catch (error) {
console.warn("Error rendering large content:", error);
if (!this.isDestroyed) {
this.content.innerHTML = '<div class="markdown-error">Render failed</div>';
}
}
});
}
/**
* 响应编辑器更新
*/
public update(update: ViewUpdate): void {
if (update.docChanged) {
// 文档改变时使用防抖更新
this.debouncedUpdate.debouncedFn();
} else if (update.selectionSet) {
// 光标移动时不触发更新
// 如果需要根据光标位置更新,可以在这里处理
}
}
/**
* 清理资源
*/
public destroy(): void {
// 标记为已销毁,防止异步回调继续执行
this.isDestroyed = true;
// 清理防抖
if (this.debouncedUpdate) {
this.debouncedUpdate.cancel();
}
// 清理主题监听
if (this.themeUnwatch) {
this.themeUnwatch();
this.themeUnwatch = undefined;
}
// 清空缓存
this.lastRenderedContent = "";
}
/**
* 获取 CodeMirror Panel 对象
*/
public getPanel(): Panel {
return {
top: false,
dom: this.dom,
update: (update: ViewUpdate) => this.update(update),
destroy: () => this.destroy()
};
}
}
/**
* 创建预览面板
*/
export function createPreviewPanel(view: EditorView): Panel {
const panel = new MarkdownPreviewPanel(view);
return panel.getPanel();
}

View File

@@ -0,0 +1,142 @@
/**
* Markdown 预览面板的 CodeMirror 状态管理
*/
import { EditorView, showPanel, ViewUpdate, ViewPlugin } from "@codemirror/view";
import { StateEffect, StateField } from "@codemirror/state";
import { getActiveNoteBlock } from "../codeblock/state";
import { usePanelStore } from "@/stores/panelStore";
import { createPreviewPanel } from "./panel";
import type { PreviewState } from "./types";
/**
* 定义切换预览面板的 Effect
*/
export const togglePreview = StateEffect.define<PreviewState | null>();
/**
* 关闭面板(带动画)
*/
export function closePreviewWithAnimation(view: EditorView): void {
const panelStore = usePanelStore();
// 标记开始关闭
panelStore.startClosingMarkdownPreview();
const panelElement = view.dom.querySelector('.cm-panels.cm-panels-bottom') as HTMLElement;
if (panelElement) {
panelElement.style.animation = 'panelSlideDown 0.3s cubic-bezier(0.4, 0, 0.2, 1)';
// 等待动画完成后再关闭面板
setTimeout(() => {
view.dispatch({
effects: togglePreview.of(null)
});
panelStore.closeMarkdownPreview();
}, 280);
} else {
view.dispatch({
effects: togglePreview.of(null)
});
panelStore.closeMarkdownPreview();
}
}
/**
* 定义预览面板的状态字段
*/
export const previewPanelState = StateField.define<PreviewState | null>({
create: () => null,
update(value, tr) {
const panelStore = usePanelStore();
for (let e of tr.effects) {
if (e.is(togglePreview)) {
value = e.value;
}
}
// 如果有预览状态,智能管理预览生命周期
if (value && !value.closing) {
const activeBlock = getActiveNoteBlock(tr.state as any);
// 关键修复:检查预览状态是否属于当前文档
// 如果 panelStore 中没有当前文档的预览状态(说明切换了文档),
// 则不执行关闭逻辑,保持其他文档的预览状态
if (!panelStore.markdownPreview.isOpen) {
// 当前文档没有预览,不处理
return value;
}
// 场景1离开 Markdown 块或无激活块 → 关闭预览
if (!activeBlock || activeBlock.language.name.toLowerCase() !== 'md') {
if (!panelStore.markdownPreview.isClosing) {
return { ...value, closing: true };
}
}
// 场景2切换到其他块起始位置变化→ 关闭预览
else if (activeBlock.content.from !== value.blockFrom) {
if (!panelStore.markdownPreview.isClosing) {
return { ...value, closing: true };
}
}
// 场景3还在同一个块内编辑只有结束位置变化→ 更新范围,实时预览
else if (activeBlock.content.to !== value.blockTo) {
// 更新 panelStore 中的预览范围
panelStore.updatePreviewRange(value.blockFrom, activeBlock.content.to);
return {
documentId: value.documentId,
blockFrom: value.blockFrom,
blockTo: activeBlock.content.to,
closing: false
};
}
}
return value;
},
provide: f => showPanel.from(f, state => state ? createPreviewPanel : null)
});
/**
* 创建监听插件
*/
export const previewPanelPlugin = ViewPlugin.fromClass(class {
private lastState: PreviewState | null | undefined = null;
private panelStore = usePanelStore();
constructor(private view: EditorView) {
this.lastState = view.state.field(previewPanelState, false);
this.panelStore.setEditorView(view);
}
update(update: ViewUpdate) {
const currentState = update.state.field(previewPanelState, false);
// 检测到面板打开(从 null 变为有值,且不是 closing
if (currentState && !currentState.closing && !this.lastState) {
// 验证面板 DOM 是否真正创建成功
requestAnimationFrame(() => {
const panelElement = this.view.dom.querySelector('.cm-markdown-preview-panel');
if (panelElement) {
// 面板创建成功,更新 store 状态
this.panelStore.openMarkdownPreview(currentState.blockFrom, currentState.blockTo);
}
});
}
// 检测到状态变为 closing
if (currentState?.closing && !this.lastState?.closing) {
// 触发关闭动画
closePreviewWithAnimation(this.view);
}
this.lastState = currentState;
}
destroy() {
// 不调用 reset(),因为那会清空所有文档的预览状态
// 只清理编辑器视图引用
this.panelStore.setEditorView(null);
}
});

View File

@@ -0,0 +1,356 @@
import { EditorView } from "@codemirror/view";
import type { ThemeColors } from "@/views/editor/theme/types";
/**
* 创建 Markdown 预览面板的主题样式
*/
export function createMarkdownPreviewTheme(colors: ThemeColors) {
// GitHub 官方颜色变量
const isDark = colors.dark;
// GitHub Light 主题颜色
const lightColors = {
fg: {
default: "#1F2328",
muted: "#656d76",
subtle: "#6e7781"
},
border: {
default: "#d0d7de",
muted: "#d8dee4"
},
canvas: {
default: "#ffffff",
subtle: "#f6f8fa"
},
accent: {
fg: "#0969da",
emphasis: "#0969da"
}
};
// GitHub Dark 主题颜色
const darkColors = {
fg: {
default: "#e6edf3",
muted: "#7d8590",
subtle: "#6e7681"
},
border: {
default: "#30363d",
muted: "#21262d"
},
canvas: {
default: "#0d1117",
subtle: "#161b22"
},
accent: {
fg: "#2f81f7",
emphasis: "#2f81f7"
}
};
const ghColors = isDark ? darkColors : lightColors;
return EditorView.theme({
// 面板容器
".cm-markdown-preview-panel": {
position: "relative",
display: "flex",
flexDirection: "column",
overflow: "hidden"
},
// 拖动调整大小的手柄
".cm-preview-resize-handle": {
width: "100%",
height: "3px",
backgroundColor: colors.borderColor,
cursor: "ns-resize",
position: "relative",
flexShrink: 0,
transition: "background-color 0.2s ease",
"&:hover": {
backgroundColor: colors.selection
},
"&.dragging": {
backgroundColor: colors.selection
}
},
// 内容区域
".cm-preview-content": {
flex: 1,
padding: "45px",
overflow: "auto",
fontSize: "16px",
lineHeight: "1.5",
color: ghColors.fg.default,
wordWrap: "break-word",
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji'",
boxSizing: "border-box",
// Loading state
"& .markdown-loading, & .markdown-error": {
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: "200px",
fontSize: "14px",
color: ghColors.fg.muted
},
"& .markdown-error": {
color: "#f85149"
},
// ========== 标题样式 ==========
"& h1, & h2, & h3, & h4, & h5, & h6": {
marginTop: "24px",
marginBottom: "16px",
fontWeight: "600",
lineHeight: "1.25",
color: ghColors.fg.default
},
"& h1": {
fontSize: "2em",
borderBottom: `1px solid ${ghColors.border.muted}`,
paddingBottom: "0.3em"
},
"& h2": {
fontSize: "1.5em",
borderBottom: `1px solid ${ghColors.border.muted}`,
paddingBottom: "0.3em"
},
"& h3": {
fontSize: "1.25em"
},
"& h4": {
fontSize: "1em"
},
"& h5": {
fontSize: "0.875em"
},
"& h6": {
fontSize: "0.85em",
color: ghColors.fg.muted
},
// ========== 段落和文本 ==========
"& p": {
marginTop: "0",
marginBottom: "16px"
},
"& strong": {
fontWeight: "600"
},
"& em": {
fontStyle: "italic"
},
"& del": {
textDecoration: "line-through",
opacity: "0.7"
},
// ========== 列表 ==========
"& ul, & ol": {
paddingLeft: "2em",
marginTop: "0",
marginBottom: "16px"
},
"& ul ul, & ul ol, & ol ol, & ol ul": {
marginTop: "0",
marginBottom: "0"
},
"& li": {
wordWrap: "break-all"
},
"& li > p": {
marginTop: "16px"
},
"& li + li": {
marginTop: "0.25em"
},
// 任务列表
"& .task-list-item": {
listStyleType: "none",
position: "relative",
paddingLeft: "1.5em"
},
"& .task-list-item + .task-list-item": {
marginTop: "3px"
},
"& .task-list-item input[type='checkbox']": {
font: "inherit",
overflow: "visible",
fontFamily: "inherit",
fontSize: "inherit",
lineHeight: "inherit",
boxSizing: "border-box",
padding: "0",
margin: "0 0.2em 0.25em -1.6em",
verticalAlign: "middle",
cursor: "pointer"
},
// ========== 代码块 ==========
"& code, & tt": {
fontFamily: "SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace",
fontSize: "85%",
padding: "0.2em 0.4em",
margin: "0",
backgroundColor: isDark ? "rgba(110, 118, 129, 0.4)" : "rgba(27, 31, 35, 0.05)",
borderRadius: "3px"
},
"& pre": {
position: "relative",
backgroundColor: isDark ? "#161b22" : "#f6f8fa",
padding: "40px 16px 16px 16px",
borderRadius: "6px",
overflow: "auto",
margin: "16px 0",
fontSize: "85%",
lineHeight: "1.45",
wordWrap: "normal",
// macOS 窗口样式 - 使用伪元素创建顶部栏
"&::before": {
content: '""',
position: "absolute",
top: "0",
left: "0",
right: "0",
height: "28px",
backgroundColor: isDark ? "#1c1c1e" : "#e8e8e8",
borderBottom: `1px solid ${ghColors.border.default}`,
borderRadius: "6px 6px 0 0"
},
// macOS 三个控制按钮
"&::after": {
content: '""',
position: "absolute",
top: "10px",
left: "12px",
width: "12px",
height: "12px",
borderRadius: "50%",
backgroundColor: isDark ? "#ec6a5f" : "#ff5f57",
boxShadow: `
18px 0 0 0 ${isDark ? "#f4bf4f" : "#febc2e"},
36px 0 0 0 ${isDark ? "#61c554" : "#28c840"}
`
}
},
"& pre code, & pre tt": {
display: "inline",
maxWidth: "auto",
padding: "0",
margin: "0",
overflow: "visible",
lineHeight: "inherit",
wordWrap: "normal",
backgroundColor: "transparent",
border: "0",
fontSize: "100%",
color: ghColors.fg.default,
wordBreak: "normal",
whiteSpace: "pre"
},
// ========== 引用块 ==========
"& blockquote": {
margin: "16px 0",
padding: "0 1em",
color: isDark ? "#7d8590" : "#6a737d",
borderLeft: isDark ? "0.25em solid #3b434b" : "0.25em solid #dfe2e5"
},
"& blockquote > :first-child": {
marginTop: "0"
},
"& blockquote > :last-child": {
marginBottom: "0"
},
// ========== 分割线 ==========
"& hr": {
height: "0.25em",
padding: "0",
margin: "24px 0",
backgroundColor: isDark ? "#21262d" : "#e1e4e8",
border: "0",
overflow: "hidden",
boxSizing: "content-box"
},
// ========== 表格 ==========
"& table": {
borderSpacing: "0",
borderCollapse: "collapse",
display: "block",
width: "100%",
overflow: "auto",
marginTop: "0",
marginBottom: "16px"
},
"& table tr": {
backgroundColor: isDark ? "#0d1117" : "#ffffff",
borderTop: isDark ? "1px solid #21262d" : "1px solid #c6cbd1"
},
"& table th, & table td": {
padding: "6px 13px",
border: isDark ? "1px solid #30363d" : "1px solid #dfe2e5"
},
"& table th": {
fontWeight: "600"
},
// ========== 链接 ==========
"& a, & .markdown-link": {
color: isDark ? "#58a6ff" : "#0366d6",
textDecoration: "none",
cursor: "pointer",
"&:hover": {
textDecoration: "underline"
}
},
// ========== 图片 ==========
"& img": {
maxWidth: "100%",
height: "auto",
borderRadius: "4px",
margin: "16px 0"
},
// ========== 其他元素 ==========
"& kbd": {
display: "inline-block",
padding: "3px 5px",
fontSize: "11px",
lineHeight: "10px",
color: ghColors.fg.default,
verticalAlign: "middle",
backgroundColor: ghColors.canvas.subtle,
border: `solid 1px ${isDark ? "rgba(110, 118, 129, 0.4)" : "rgba(175, 184, 193, 0.2)"}`,
borderBottom: `solid 2px ${isDark ? "rgba(110, 118, 129, 0.4)" : "rgba(175, 184, 193, 0.2)"}`,
borderRadius: "6px",
boxShadow: "inset 0 -1px 0 rgba(175, 184, 193, 0.2)"
},
// 首个子元素去除上边距
"& > *:first-child": {
marginTop: "0 !important"
},
// 最后一个子元素去除下边距
"& > *:last-child": {
marginBottom: "0 !important"
}
}
}, { dark: colors.dark });
}

View File

@@ -0,0 +1,12 @@
/**
* Markdown 预览面板相关类型定义
*/
// 预览面板状态
export interface PreviewState {
documentId: number; // 预览所属的文档ID
blockFrom: number;
blockTo: number;
closing?: boolean; // 标记面板正在关闭
}

View File

@@ -10,280 +10,304 @@ import type {ThemeColors} from './types';
* @returns CodeMirror Extension数组
*/
export function createBaseTheme(colors: ThemeColors): Extension {
// 编辑器主题样式
const theme = EditorView.theme({
'&': {
color: colors.foreground,
backgroundColor: colors.background,
},
// 编辑器主题样式
const theme = EditorView.theme({
'&': {
color: colors.foreground,
backgroundColor: colors.background,
},
// 确保编辑器容器背景一致
'.cm-editor': {
backgroundColor: colors.background,
},
// 确保编辑器容器背景一致
'.cm-editor': {
backgroundColor: colors.background,
},
// 确保滚动区域背景一致
'.cm-scroller': {
backgroundColor: colors.background,
},
// 确保滚动区域背景一致
'.cm-scroller': {
backgroundColor: colors.background,
transition: 'height 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
},
// 编辑器内容
'.cm-content': {
caretColor: colors.cursor,
paddingTop: '4px',
},
// 编辑器内容
'.cm-content': {
caretColor: colors.cursor,
paddingTop: '4px',
},
// 光标
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: colors.cursor,
borderLeftWidth: '2px',
paddingTop: '4px',
marginTop: '-2px',
},
// 光标
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: colors.cursor,
borderLeftWidth: '2px',
paddingTop: '4px',
marginTop: '-2px',
},
// 选择
'.cm-selectionBackground': {
backgroundColor: colors.selectionBlur,
},
'&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground': {
backgroundColor: colors.selection,
},
'.cm-content ::selection': {
backgroundColor: colors.selection,
},
'.cm-activeLine.code-empty-block-selected': {
backgroundColor: colors.selection,
},
// 选择
'.cm-selectionBackground': {
backgroundColor: colors.selectionBlur,
},
'&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground': {
backgroundColor: colors.selection,
},
'.cm-content ::selection': {
backgroundColor: colors.selection,
},
'.cm-activeLine.code-empty-block-selected': {
backgroundColor: colors.selection,
},
// 当前行高亮
'.cm-activeLine': {
backgroundColor: colors.activeLine
},
// 当前行高亮
'.cm-activeLine': {
backgroundColor: colors.activeLine
},
// 行号区域
'.cm-gutters': {
backgroundColor: colors.dark ? 'rgba(0,0,0, 0.1)' : 'rgba(0,0,0, 0.04)',
color: colors.lineNumber,
border: 'none',
borderRight: colors.dark ? 'none' : `1px solid ${colors.borderLight}`,
padding: '0 2px 0 4px',
userSelect: 'none',
},
'.cm-activeLineGutter': {
backgroundColor: 'transparent',
color: colors.activeLineNumber,
},
// 行号区域
'.cm-gutters': {
backgroundColor: colors.dark ? 'rgba(0,0,0, 0.1)' : 'rgba(0,0,0, 0.04)',
color: colors.lineNumber,
border: 'none',
borderRight: colors.dark ? 'none' : `1px solid ${colors.borderLight}`,
padding: '0 2px 0 4px',
userSelect: 'none',
},
'.cm-activeLineGutter': {
backgroundColor: 'transparent',
color: colors.activeLineNumber,
},
// 折叠功能
'.cm-foldGutter': {
marginLeft: '0px',
},
'.cm-foldGutter .cm-gutterElement': {
opacity: 0,
transition: 'opacity 400ms',
},
'.cm-gutters:hover .cm-gutterElement': {
opacity: 1,
},
'.cm-foldPlaceholder': {
backgroundColor: 'transparent',
border: 'none',
color: colors.comment,
},
// 折叠功能
'.cm-foldGutter': {
marginLeft: '0px',
},
'.cm-foldGutter .cm-gutterElement': {
opacity: 0,
transition: 'opacity 400ms',
},
'.cm-gutters:hover .cm-gutterElement': {
opacity: 1,
},
'.cm-foldPlaceholder': {
backgroundColor: 'transparent',
border: 'none',
color: colors.comment,
},
// 面板
'.cm-panels': {
backgroundColor: colors.dropdownBackground,
color: colors.foreground
},
'.cm-panels.cm-panels-top': {
borderBottom: '2px solid black'
},
'.cm-panels.cm-panels-bottom': {
borderTop: '2px solid black'
},
// 面板
'.cm-panels': {
// backgroundColor: colors.dropdownBackground,
// color: colors.foreground
},
'.cm-panels.cm-panels-top': {
borderBottom: '2px solid black'
},
'.cm-panels.cm-panels-bottom': {
animation: 'panelSlideUp 0.3s cubic-bezier(0.4, 0, 0.2, 1)'
},
'@keyframes panelSlideUp': {
from: {
transform: 'translateY(100%)',
opacity: '0'
},
to: {
transform: 'translateY(0)',
opacity: '1'
}
},
'@keyframes panelSlideDown': {
from: {
transform: 'translateY(0)',
opacity: '1'
},
to: {
transform: 'translateY(100%)',
opacity: '0'
}
},
// 搜索匹配
'.cm-searchMatch': {
backgroundColor: 'transparent',
outline: `1px solid ${colors.searchMatch}`,
},
'.cm-searchMatch.cm-searchMatch-selected': {
backgroundColor: colors.searchMatch,
color: colors.background,
},
'.cm-selectionMatch': {
backgroundColor: colors.dark ? '#50606D' : '#e6f3ff',
},
// 搜索匹配
'.cm-searchMatch': {
backgroundColor: 'transparent',
outline: `1px solid ${colors.searchMatch}`,
},
'.cm-searchMatch.cm-searchMatch-selected': {
backgroundColor: colors.searchMatch,
color: colors.background,
},
'.cm-selectionMatch': {
backgroundColor: colors.dark ? '#50606D' : '#e6f3ff',
},
// 括号匹配
'&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket': {
outline: `0.5px solid ${colors.searchMatch}`,
},
'&.cm-focused .cm-matchingBracket': {
backgroundColor: colors.matchingBracket,
color: 'inherit',
},
'&.cm-focused .cm-nonmatchingBracket': {
outline: colors.dark ? '0.5px solid #bc8f8f' : '0.5px solid #d73a49',
},
// 括号匹配
'&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket': {
outline: `0.5px solid ${colors.searchMatch}`,
},
'&.cm-focused .cm-matchingBracket': {
backgroundColor: colors.matchingBracket,
color: 'inherit',
},
'&.cm-focused .cm-nonmatchingBracket': {
outline: colors.dark ? '0.5px solid #bc8f8f' : '0.5px solid #d73a49',
},
// 编辑器焦点
'&.cm-editor.cm-focused': {
outline: 'none',
},
// 编辑器焦点
'&.cm-editor.cm-focused': {
outline: 'none',
},
// 工具提示
'.cm-tooltip': {
border: colors.dark ? 'none' : `1px solid ${colors.dropdownBorder}`,
backgroundColor: colors.surface,
color: colors.foreground,
boxShadow: colors.dark ? 'none' : '0 2px 8px rgba(0,0,0,0.1)',
},
'.cm-tooltip .cm-tooltip-arrow:before': {
borderTopColor: 'transparent',
borderBottomColor: 'transparent',
},
'.cm-tooltip .cm-tooltip-arrow:after': {
borderTopColor: colors.surface,
borderBottomColor: colors.surface,
},
'.cm-tooltip-autocomplete': {
'& > ul > li[aria-selected]': {
backgroundColor: colors.activeLine,
color: colors.foreground,
},
},
// 工具提示
'.cm-tooltip': {
border: colors.dark ? 'none' : `1px solid ${colors.dropdownBorder}`,
backgroundColor: colors.surface,
color: colors.foreground,
boxShadow: colors.dark ? 'none' : '0 2px 8px rgba(0,0,0,0.1)',
},
'.cm-tooltip .cm-tooltip-arrow:before': {
borderTopColor: 'transparent',
borderBottomColor: 'transparent',
},
'.cm-tooltip .cm-tooltip-arrow:after': {
borderTopColor: colors.surface,
borderBottomColor: colors.surface,
},
'.cm-tooltip-autocomplete': {
'& > ul > li[aria-selected]': {
backgroundColor: colors.activeLine,
color: colors.foreground,
},
},
// 代码块层(自定义)
'.code-blocks-layer': {
width: '100%',
},
'.code-blocks-layer .block-even, .code-blocks-layer .block-odd': {
width: '100%',
boxSizing: 'content-box',
},
'.code-blocks-layer .block-even': {
background: colors.background,
borderTop: `1px solid ${colors.borderColor}`,
},
'.code-blocks-layer .block-even:first-child': {
borderTop: 'none',
},
'.code-blocks-layer .block-odd': {
background: colors.backgroundSecondary,
borderTop: `1px solid ${colors.borderColor}`,
},
// 代码块层(自定义)
'.code-blocks-layer': {
width: '100%',
},
'.code-blocks-layer .block-even, .code-blocks-layer .block-odd': {
width: '100%',
boxSizing: 'content-box',
},
'.code-blocks-layer .block-even': {
background: colors.background,
borderTop: `1px solid ${colors.borderColor}`,
},
'.code-blocks-layer .block-even:first-child': {
borderTop: 'none',
},
'.code-blocks-layer .block-odd': {
background: colors.backgroundSecondary,
borderTop: `1px solid ${colors.borderColor}`,
},
// 数学计算结果(自定义)
'.code-blocks-math-result': {
paddingLeft: "12px",
position: "relative",
},
".code-blocks-math-result .inner": {
background: colors.dark ? '#0e1217' : '#48b57e',
color: colors.dark ? '#a0e7c7' : '#fff',
padding: '0px 4px',
borderRadius: '2px',
boxShadow: colors.dark ? '0 0 3px rgba(0,0,0, 0.3)' : '0 0 3px rgba(0,0,0, 0.1)',
cursor: 'pointer',
whiteSpace: "nowrap",
},
'.code-blocks-math-result-copied': {
position: "absolute",
top: "0px",
left: "0px",
marginLeft: "calc(100% + 10px)",
width: "60px",
transition: "opacity 500ms",
transitionDelay: "1000ms",
color: colors.dark ? 'rgba(220,240,230, 1.0)' : 'rgba(0,0,0, 0.8)',
},
'.code-blocks-math-result-copied.fade-out': {
opacity: 0,
},
// 数学计算结果(自定义)
'.code-blocks-math-result': {
paddingLeft: "12px",
position: "relative",
},
".code-blocks-math-result .inner": {
background: colors.dark ? '#0e1217' : '#48b57e',
color: colors.dark ? '#a0e7c7' : '#fff',
padding: '0px 4px',
borderRadius: '2px',
boxShadow: colors.dark ? '0 0 3px rgba(0,0,0, 0.3)' : '0 0 3px rgba(0,0,0, 0.1)',
cursor: 'pointer',
whiteSpace: "nowrap",
},
'.code-blocks-math-result-copied': {
position: "absolute",
top: "0px",
left: "0px",
marginLeft: "calc(100% + 10px)",
width: "60px",
transition: "opacity 500ms",
transitionDelay: "1000ms",
color: colors.dark ? 'rgba(220,240,230, 1.0)' : 'rgba(0,0,0, 0.8)',
},
'.code-blocks-math-result-copied.fade-out': {
opacity: 0,
},
// 代码块开始标记(自定义)
'.code-block-start': {
height: '12px',
position: 'relative',
},
'.code-block-start.first': {
height: '0px',
},
}, {dark: colors.dark});
// 代码块开始标记(自定义)
'.code-block-start': {
height: '12px',
position: 'relative',
},
'.code-block-start.first': {
height: '0px',
},
}, {dark: colors.dark});
// 语法高亮样式
const highlightStyle = HighlightStyle.define([
// 关键字
{tag: tags.keyword, color: colors.keyword},
// 操作符
{tag: [tags.operator, tags.operatorKeyword], color: colors.operator},
// 名称、变量
{tag: [tags.name, tags.deleted, tags.character, tags.macroName], color: colors.variable},
{tag: [tags.variableName], color: colors.variable},
{tag: [tags.labelName], color: colors.operator},
{tag: [tags.atom, tags.bool, tags.special(tags.variableName)], color: colors.variable},
// 函数
{tag: [tags.function(tags.variableName)], color: colors.function},
{tag: [tags.propertyName], color: colors.function},
// 类型、类
{tag: [tags.typeName], color: colors.type},
{tag: [tags.className], color: colors.class},
// 常量
{tag: [tags.color, tags.constant(tags.name), tags.standard(tags.name)], color: colors.constant},
// 字符串
{tag: [tags.processingInstruction, tags.string, tags.inserted], color: colors.string},
{tag: [tags.special(tags.string)], color: colors.string},
{tag: [tags.quote], color: colors.comment},
// 数字
{tag: [tags.number, tags.changed, tags.annotation, tags.modifier, tags.self, tags.namespace], color: colors.number},
// 正则表达式
{tag: [tags.url, tags.escape, tags.regexp, tags.link], color: colors.regexp},
// 注释
{tag: [tags.meta, tags.comment], color: colors.comment, fontStyle: 'italic'},
// 分隔符、括号
{tag: [tags.definition(tags.name), tags.separator], color: colors.variable},
{tag: [tags.brace], color: colors.variable},
{tag: [tags.squareBracket], color: colors.dark ? '#bf616a' : colors.keyword},
{tag: [tags.angleBracket], color: colors.dark ? '#d08770' : colors.operator},
{tag: [tags.attributeName], color: colors.variable},
// 标签
{tag: [tags.tagName], color: colors.number},
// 注解
{tag: [tags.annotation], color: colors.invalid},
// 特殊样式
{tag: tags.strong, fontWeight: 'bold'},
{tag: tags.emphasis, fontStyle: 'italic'},
{tag: tags.strikethrough, textDecoration: 'line-through'},
{tag: tags.link, color: colors.variable, textDecoration: 'underline'},
// 标题
{tag: tags.heading, fontWeight: 'bold', color: colors.heading},
{tag: [tags.heading1, tags.heading2], fontSize: '1.4em'},
{tag: [tags.heading3, tags.heading4], fontSize: '1.2em'},
{tag: [tags.heading5, tags.heading6], fontSize: '1.1em'},
// 无效内容
{tag: tags.invalid, color: colors.invalid},
]);
// 语法高亮样式
const highlightStyle = HighlightStyle.define([
// 关键字
{tag: tags.keyword, color: colors.keyword},
return [
theme,
syntaxHighlighting(highlightStyle),
];
// 操作符
{tag: [tags.operator, tags.operatorKeyword], color: colors.operator},
// 名称、变量
{tag: [tags.name, tags.deleted, tags.character, tags.macroName], color: colors.variable},
{tag: [tags.variableName], color: colors.variable},
{tag: [tags.labelName], color: colors.operator},
{tag: [tags.atom, tags.bool, tags.special(tags.variableName)], color: colors.variable},
// 函数
{tag: [tags.function(tags.variableName)], color: colors.function},
{tag: [tags.propertyName], color: colors.function},
// 类型、类
{tag: [tags.typeName], color: colors.type},
{tag: [tags.className], color: colors.class},
// 常量
{tag: [tags.color, tags.constant(tags.name), tags.standard(tags.name)], color: colors.constant},
// 字符串
{tag: [tags.processingInstruction, tags.string, tags.inserted], color: colors.string},
{tag: [tags.special(tags.string)], color: colors.string},
{tag: [tags.quote], color: colors.comment},
// 数字
{
tag: [tags.number, tags.changed, tags.annotation, tags.modifier, tags.self, tags.namespace],
color: colors.number
},
// 正则表达式
{tag: [tags.url, tags.escape, tags.regexp, tags.link], color: colors.regexp},
// 注释
{tag: [tags.meta, tags.comment], color: colors.comment, fontStyle: 'italic'},
// 分隔符、括号
{tag: [tags.definition(tags.name), tags.separator], color: colors.variable},
{tag: [tags.brace], color: colors.variable},
{tag: [tags.squareBracket], color: colors.dark ? '#bf616a' : colors.keyword},
{tag: [tags.angleBracket], color: colors.dark ? '#d08770' : colors.operator},
{tag: [tags.attributeName], color: colors.variable},
// 标签
{tag: [tags.tagName], color: colors.number},
// 注解
{tag: [tags.annotation], color: colors.invalid},
// 特殊样式
{tag: tags.strong, fontWeight: 'bold'},
{tag: tags.emphasis, fontStyle: 'italic'},
{tag: tags.strikethrough, textDecoration: 'line-through'},
{tag: tags.link, color: colors.variable, textDecoration: 'underline'},
// 标题
{tag: tags.heading, fontWeight: 'bold', color: colors.heading},
{tag: [tags.heading1, tags.heading2], fontSize: '1.4em'},
{tag: [tags.heading3, tags.heading4], fontSize: '1.2em'},
{tag: [tags.heading5, tags.heading6], fontSize: '1.1em'},
// 无效内容
{tag: tags.invalid, color: colors.invalid},
]);
return [
theme,
syntaxHighlighting(highlightStyle),
];
}

View File

@@ -6,14 +6,14 @@ import { useUpdateStore } from '@/stores/updateStore';
import SettingSection from '../components/SettingSection.vue';
import SettingItem from '../components/SettingItem.vue';
import ToggleSwitch from '../components/ToggleSwitch.vue';
import { createMarkdownExit } from 'markdown-exit'
import markdownit from 'markdown-it'
const { t } = useI18n();
const configStore = useConfigStore();
const updateStore = useUpdateStore();
// 初始化Remarkable实例并配置
const md = createMarkdownExit({
const md = markdownit({
html: true, // 允许HTML
linkify: false, // 不解析链接
typographer: true, // 开启智能引号

View File

@@ -7,6 +7,10 @@ import {nodePolyfills} from 'vite-plugin-node-polyfills';
export default defineConfig(({mode}: { mode: string }): object => {
const env: Record<string, string> = loadEnv(mode, process.cwd());
return {
test: {
environment: 'happy-dom',
globals: true,
},
publicDir: './public',
base: './',
resolve: {