Compare commits
8 Commits
b25abcb9d8
...
f2216119ae
| Author | SHA1 | Date | |
|---|---|---|---|
| f2216119ae | |||
| 07d737af0f | |||
| 485d4b1cc4 | |||
| 77a22bbfb1 | |||
| 800129e245 | |||
| 86781d8bbc | |||
| f569258633 | |||
| 67406f661a |
23
input.css
23
input.css
@ -583,9 +583,22 @@
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* table-wrap:渲染器给宽表格套的滚动容器,移动端窄屏可横向滚动。
|
||||
(见 src/api/markdown.rs::wrap_tables)。min-width 让窄表仍撑满、宽表撑开触发滚动。
|
||||
padding 为 table 的 1px box-shadow 描边预留空间:overflow 容器(overflow-x:auto 会
|
||||
强制 overflow-y 也建滚动盒)会裁切向外 spread 的阴影,导致右下边框断裂。
|
||||
负 margin 抵消 padding 的外部尺寸,使 wrap 不撑大父容器、左上仍贴齐正文边缘。 */
|
||||
.md-content .table-wrap {
|
||||
margin: -1px;
|
||||
padding: 1px;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.md-content table {
|
||||
margin-bottom: var(--content-gap-paper);
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
border-radius: var(--radius-paper);
|
||||
@ -1031,6 +1044,16 @@
|
||||
details.toc .inner {
|
||||
margin: 0 1rem;
|
||||
}
|
||||
|
||||
/* 旧文章兜底:未重建的文章是裸 table(无 .table-wrap 包裹),移动端需 block 化才能滚动。
|
||||
选择器 .md-content > div > table 精确命中「片段容器直接子代的裸 table」,
|
||||
不会误伤新文章 .table-wrap 内的 table(后者多一层 div,圆角视觉完整)。
|
||||
block 化会损 thead/tbody 圆角,但仅移动端 + 仅旧文章,可接受。 */
|
||||
.md-content > div > table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pageEnter {
|
||||
|
||||
3
libs/pnpm-lock.yaml
generated
3
libs/pnpm-lock.yaml
generated
@ -124,6 +124,9 @@ importers:
|
||||
'@tiptap/suggestion':
|
||||
specifier: ^3.27.3
|
||||
version: 3.27.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.27.3(@tiptap/pm@3.27.3))(@tiptap/pm@3.27.3)
|
||||
'@yggdrasil/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../shared
|
||||
katex:
|
||||
specifier: ^0.16.22
|
||||
version: 0.16.47
|
||||
|
||||
@ -25,3 +25,131 @@ export const THEME_CHANGE_EVENT = 'yggdrasil:theme-change';
|
||||
export function prefersReducedMotion(): boolean {
|
||||
return !!window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mermaid Catppuccin 主题变量
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// 这是 mermaid 渲染配色的单一真相源。前台 yggdrasil-core 与后台 tiptap-editor
|
||||
// 都从这里取,保证「编辑器预览 = 线上文章页」视觉一致(各 IIFE 不能 import 彼此,
|
||||
// 但都能 inline 本包)。
|
||||
//
|
||||
// mermaid 把颜色烤进 SVG 内联 style,无法靠 CSS 原地改主题,故须在 initialize 时注入。
|
||||
// 用 `theme: 'base'`(非 'default'/'dark'):base 主题不硬编码颜色,themeVariables 能完全
|
||||
// 控制调色板;'default' 主题硬编码 mainBkg=#ECECFF 等会阻断覆盖。
|
||||
//
|
||||
// 设计哲学:极简卡片化——节点用 surface 色阶(卡片感)、边框/连线用 subtext 色阶(克制)、
|
||||
// 文字用 primary text。不滥用强调色,绿/紫等 accent 留给作者用 classDef 手动强调。
|
||||
// hex 值取自 themes/Catppuccin Latte.tmTheme 与 Catppuccin Mocha.tmTheme。
|
||||
//
|
||||
// 覆盖的字段涵盖 flowchart / sequence / class 三类图(测试文章用到的全部类型)。
|
||||
|
||||
/** mermaid themeVariables 的字段集合(mermaid 实际接受更多字段,这里只列项目用到的)。 */
|
||||
export type MermaidThemeVariables = Record<string, unknown>;
|
||||
|
||||
/** Latte(亮)主题:节点 surface 色阶、文字 #4c4f69、连线 subtext1 #5c5f77。 */
|
||||
export const MERMAID_LATTE_VARS: MermaidThemeVariables = {
|
||||
background: '#dce0e8', // = --color-paper-code-block,图背景与 pre 无缝衔接
|
||||
// 节点填充:surface 色阶递进(主/次/三级),卡片质感
|
||||
primaryColor: '#e6e9ef',
|
||||
secondaryColor: '#ccd0da',
|
||||
tertiaryColor: '#bcc0cc',
|
||||
mainBkg: '#e6e9ef',
|
||||
nodeBkg: '#e6e9ef',
|
||||
secondBkg: '#ccd0da',
|
||||
// 节点边框:surface1 偏冷灰
|
||||
primaryBorderColor: '#bcc0cc',
|
||||
secondaryBorderColor: '#acb0be',
|
||||
tertiaryBorderColor: '#9ca0b0',
|
||||
nodeBorder: '#bcc0cc',
|
||||
clusterBorder: '#bcc0cc',
|
||||
labelBoxBorderColor: '#bcc0cc',
|
||||
// 文字:primary text #4c4f69
|
||||
primaryTextColor: '#4c4f69',
|
||||
secondaryTextColor: '#5c5f77',
|
||||
tertiaryTextColor: '#6c6f85',
|
||||
textColor: '#4c4f69',
|
||||
nodeTextColor: '#4c4f69',
|
||||
titleColor: '#4c4f69',
|
||||
classText: '#4c4f69',
|
||||
labelTextColor: '#4c4f69',
|
||||
// 连线/箭头:subtext1 #5c5f77
|
||||
lineColor: '#5c5f77',
|
||||
defaultLinkColor: '#5c5f77',
|
||||
arrowheadColor: '#5c5f77',
|
||||
// 时序图
|
||||
actorBkg: '#e6e9ef',
|
||||
actorBorder: '#bcc0cc',
|
||||
actorTextColor: '#4c4f69',
|
||||
actorLineColor: '#5c5f77',
|
||||
signalColor: '#5c5f77',
|
||||
signalTextColor: '#4c4f69',
|
||||
loopTextColor: '#4c4f69',
|
||||
sequenceNumberColor: '#eff1f5',
|
||||
activationBkgColor: '#ccd0da',
|
||||
activationBorderColor: '#bcc0cc',
|
||||
// 边标签 / 子图背景
|
||||
edgeLabelBackground: '#eff1f5',
|
||||
labelBoxBkgColor: '#eff1f5',
|
||||
clusterBkg: 'rgba(239, 241, 245, 0.5)',
|
||||
// 注释:低饱和黄(Latte yellow #df8e1d)
|
||||
noteBkgColor: 'rgba(223, 142, 29, 0.15)',
|
||||
noteBorderColor: '#df8e1d',
|
||||
noteTextColor: '#4c4f69',
|
||||
// 字体:与正文 sans 对齐,中文友好
|
||||
fontFamily:
|
||||
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif",
|
||||
fontSize: '16px',
|
||||
};
|
||||
|
||||
/** Mocha(暗)主题:节点 surface 色阶、文字 #cdd6f4、连线 subtext0 #a6adc8。 */
|
||||
export const MERMAID_MOCHA_VARS: MermaidThemeVariables = {
|
||||
background: '#313244', // = --color-paper-code-block(暗)
|
||||
primaryColor: '#45475a',
|
||||
secondaryColor: '#585b70',
|
||||
tertiaryColor: '#1e1e2e',
|
||||
mainBkg: '#45475a',
|
||||
nodeBkg: '#45475a',
|
||||
secondBkg: '#585b70',
|
||||
primaryBorderColor: '#585b70',
|
||||
secondaryBorderColor: '#45475a',
|
||||
tertiaryBorderColor: '#313244',
|
||||
nodeBorder: '#585b70',
|
||||
clusterBorder: '#585b70',
|
||||
labelBoxBorderColor: '#585b70',
|
||||
primaryTextColor: '#cdd6f4',
|
||||
secondaryTextColor: '#bac2de',
|
||||
tertiaryTextColor: '#a6adc8',
|
||||
textColor: '#cdd6f4',
|
||||
nodeTextColor: '#cdd6f4',
|
||||
titleColor: '#cdd6f4',
|
||||
classText: '#cdd6f4',
|
||||
labelTextColor: '#cdd6f4',
|
||||
lineColor: '#a6adc8',
|
||||
defaultLinkColor: '#a6adc8',
|
||||
arrowheadColor: '#a6adc8',
|
||||
actorBkg: '#45475a',
|
||||
actorBorder: '#585b70',
|
||||
actorTextColor: '#cdd6f4',
|
||||
actorLineColor: '#a6adc8',
|
||||
signalColor: '#a6adc8',
|
||||
signalTextColor: '#cdd6f4',
|
||||
loopTextColor: '#cdd6f4',
|
||||
sequenceNumberColor: '#1e1e2e',
|
||||
activationBkgColor: '#585b70',
|
||||
activationBorderColor: '#6c7086',
|
||||
edgeLabelBackground: '#1e1e2e',
|
||||
labelBoxBkgColor: '#1e1e2e',
|
||||
clusterBkg: 'rgba(30, 30, 46, 0.5)',
|
||||
noteBkgColor: 'rgba(249, 226, 175, 0.12)',
|
||||
noteBorderColor: '#f9e2af',
|
||||
noteTextColor: '#cdd6f4',
|
||||
fontFamily:
|
||||
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif",
|
||||
fontSize: '16px',
|
||||
};
|
||||
|
||||
/** 按主题返回对应 Catppuccin themeVariables 的副本(调用方可安全 mutate)。 */
|
||||
export function mermaidThemeVarsFor(theme: ThemeName): MermaidThemeVariables {
|
||||
return theme === 'dark' ? { ...MERMAID_MOCHA_VARS } : { ...MERMAID_LATTE_VARS };
|
||||
}
|
||||
|
||||
@ -22,6 +22,7 @@
|
||||
"@tiptap/pm": "^3.27.3",
|
||||
"@tiptap/starter-kit": "^3.27.3",
|
||||
"@tiptap/suggestion": "^3.27.3",
|
||||
"@yggdrasil/shared": "workspace:*",
|
||||
"katex": "^0.16.22",
|
||||
"lowlight": "^3.3.0"
|
||||
}
|
||||
|
||||
@ -39,6 +39,10 @@ describe('CodeBlockNodeView', () => {
|
||||
} as any);
|
||||
expect(view.dom.querySelector('.tiptap-codeblock-lang')?.textContent).toBe('python');
|
||||
expect(view.dom.querySelector('.tiptap-codeblock-run')).not.toBeNull();
|
||||
expect(view.dom.querySelector<HTMLElement>('.tiptap-codeblock-toolbar')?.style.display).toBe(
|
||||
'',
|
||||
);
|
||||
expect(view.dom.classList.contains('has-toolbar')).toBe(true);
|
||||
});
|
||||
|
||||
it('普通块(python):显示语言标签,无运行按钮', () => {
|
||||
@ -48,16 +52,47 @@ describe('CodeBlockNodeView', () => {
|
||||
} as any);
|
||||
expect(view.dom.querySelector('.tiptap-codeblock-lang')?.textContent).toBe('python');
|
||||
expect(view.dom.querySelector('.tiptap-codeblock-run')).toBeNull();
|
||||
expect(view.dom.querySelector<HTMLElement>('.tiptap-codeblock-toolbar')?.style.display).toBe(
|
||||
'',
|
||||
);
|
||||
expect(view.dom.classList.contains('has-toolbar')).toBe(true);
|
||||
});
|
||||
|
||||
it('无 language 的块:标签为空或占位,无运行按钮', () => {
|
||||
it('无 language 的块:隐藏 toolbar,无运行按钮', () => {
|
||||
const view = new CodeBlockNodeView({
|
||||
node: mockNode(''),
|
||||
editor: mockEditor(),
|
||||
} as any);
|
||||
expect(view.dom.querySelector('.tiptap-codeblock-run')).toBeNull();
|
||||
expect(view.dom.querySelector<HTMLElement>('.tiptap-codeblock-toolbar')?.style.display).toBe(
|
||||
'none',
|
||||
);
|
||||
expect(view.dom.classList.contains('has-toolbar')).toBe(false);
|
||||
});
|
||||
|
||||
it('node 语言由空更新为 python 时 update() 显示 toolbar', () => {
|
||||
const view = new CodeBlockNodeView({
|
||||
node: mockNode(''),
|
||||
editor: mockEditor(),
|
||||
} as any);
|
||||
expect(view.dom.querySelector<HTMLElement>('.tiptap-codeblock-toolbar')?.style.display).toBe(
|
||||
'none',
|
||||
);
|
||||
expect(view.dom.classList.contains('has-toolbar')).toBe(false);
|
||||
|
||||
view.update(mockNode('python'));
|
||||
expect(view.dom.querySelector('.tiptap-codeblock-lang')?.textContent).toBe('python');
|
||||
expect(view.dom.querySelector<HTMLElement>('.tiptap-codeblock-toolbar')?.style.display).toBe(
|
||||
'',
|
||||
);
|
||||
expect(view.dom.classList.contains('has-toolbar')).toBe(true);
|
||||
|
||||
view.update(mockNode(''));
|
||||
expect(view.dom.querySelector<HTMLElement>('.tiptap-codeblock-toolbar')?.style.display).toBe(
|
||||
'none',
|
||||
);
|
||||
expect(view.dom.classList.contains('has-toolbar')).toBe(false);
|
||||
});
|
||||
it('node 语言变化时 update() 刷新标签', () => {
|
||||
const view = new CodeBlockNodeView({
|
||||
node: mockNode('python'),
|
||||
|
||||
@ -5,68 +5,171 @@ import { Markdown } from '@tiptap/markdown';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* 脚注语法回归测试。
|
||||
*
|
||||
* @tiptap/markdown 的 escapeMarkdownSyntax 将 `[`→`\[`、`]`→`\]`,
|
||||
* 导致 `[^1]` 被序列化为 `\[^1\]`,pulldown-cmark 将其当作字面文本。
|
||||
* TiptapEditorInstance.unescapeFootnoteSyntax 在序列化后做后处理还原。
|
||||
*
|
||||
* 此测试直接验证 escapeMarkdownSyntax 的行为以及 unescape 正则的修复效果。
|
||||
*/
|
||||
import { FootnoteDef, FootnoteNumbering, FootnoteRef } from '../footnote';
|
||||
|
||||
const UNESCAPE_RE = /\\\[\^([^\n\\]*?)\\\]/g;
|
||||
const unescapeFootnote = (md: string) => md.replace(UNESCAPE_RE, '[^$1]');
|
||||
/**
|
||||
* 脚注节点回归测试。
|
||||
*
|
||||
* 新模型:[^id] 解析成 atom 节点(footnoteRef/footnoteDef),序列化走
|
||||
* renderMarkdown 直接拼字面量,绕过 @tiptap/markdown 的 escapeMarkdownSyntax。
|
||||
* 旧版 unescapeFootnoteSyntax 后处理已移除——不再需要。
|
||||
*/
|
||||
|
||||
function makeEditor() {
|
||||
return new Editor({
|
||||
element: document.body,
|
||||
extensions: [StarterKit.configure({ heading: { levels: [1, 2, 3] } }), Markdown],
|
||||
extensions: [
|
||||
StarterKit.configure({ heading: { levels: [1, 2, 3] } }),
|
||||
Markdown,
|
||||
FootnoteRef,
|
||||
FootnoteDef,
|
||||
FootnoteNumbering,
|
||||
],
|
||||
content: '',
|
||||
});
|
||||
}
|
||||
|
||||
describe('脚注语法 - escapeMarkdownSyntax 破坏与修复', () => {
|
||||
/** 遍历文档,收集指定类型节点的 attrs。 */
|
||||
function collectNodes(editor: Editor, type: string): Array<Record<string, unknown>> {
|
||||
const found: Array<Record<string, unknown>> = [];
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (node.type.name === type) {
|
||||
found.push({ ...node.attrs });
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
/** 从 editor.storage 取脚注编号表(storage 上无类型,需断言)。 */
|
||||
function getNumbering(editor: Editor): Map<string, number> {
|
||||
const storage = editor.storage as unknown as {
|
||||
footnoteNumbering: { numbering: Map<string, number> };
|
||||
};
|
||||
return storage.footnoteNumbering.numbering;
|
||||
}
|
||||
|
||||
function getDefinitions(editor: Editor): Map<string, string> {
|
||||
const storage = editor.storage as unknown as {
|
||||
footnoteNumbering: { definitions: Map<string, string> };
|
||||
};
|
||||
return storage.footnoteNumbering.definitions;
|
||||
}
|
||||
|
||||
describe('脚注节点 - 解析与序列化', () => {
|
||||
let editor: Editor;
|
||||
|
||||
beforeEach(() => {
|
||||
editor = makeEditor();
|
||||
});
|
||||
|
||||
it('escapeMarkdownSyntax 确实会把 [^1] 转义为 \\[^1\\]', () => {
|
||||
editor.commands.setContent('引用[^1]', { contentType: 'markdown' });
|
||||
const raw = editor.getMarkdown();
|
||||
// 未修复时 raw 为 `\[^1\]`——这就是脚注失效的直接原因
|
||||
expect(raw).toContain('\\[^1\\]');
|
||||
it('引用 [^1] 解析成 footnoteRef 节点,不再被转义', () => {
|
||||
editor.commands.setContent('正文[^1]结束', { contentType: 'markdown' });
|
||||
const refs = collectNodes(editor, 'footnoteRef');
|
||||
expect(refs).toHaveLength(1);
|
||||
expect(refs[0]?.label).toBe('1');
|
||||
// 关键:序列化输出不应含转义形式 \[^1\]
|
||||
const md = editor.getMarkdown();
|
||||
expect(md).toContain('[^1]');
|
||||
expect(md).not.toContain('\\[^1\\]');
|
||||
});
|
||||
|
||||
it('unescape 正则还原脚注引用 \\[^1\\] → [^1]', () => {
|
||||
expect(unescapeFootnote('\\[^1\\]')).toBe('[^1]');
|
||||
it('定义 [^1]: 内容 解析成 footnoteDef 节点并往返无损', () => {
|
||||
editor.commands.setContent('正文[^1]\n\n[^1]: 这是脚注内容', {
|
||||
contentType: 'markdown',
|
||||
});
|
||||
const defs = collectNodes(editor, 'footnoteDef');
|
||||
expect(defs).toHaveLength(1);
|
||||
expect(defs[0]?.label).toBe('1');
|
||||
expect(defs[0]?.content).toBe('这是脚注内容');
|
||||
|
||||
const md = editor.getMarkdown();
|
||||
expect(md).toContain('[^1]: 这是脚注内容');
|
||||
});
|
||||
|
||||
it('unescape 正则还原含空格的脚注 label', () => {
|
||||
expect(unescapeFootnote('\\[^my note\\]')).toBe('[^my note]');
|
||||
it('多行定义(缩进续行)被收集为单个 content', () => {
|
||||
const src = ['正文[^a]', '', '[^a]: 第一行', ' 第二行', ' 第三行', ''].join('\n');
|
||||
editor.commands.setContent(src, { contentType: 'markdown' });
|
||||
const defs = collectNodes(editor, 'footnoteDef');
|
||||
expect(defs).toHaveLength(1);
|
||||
expect(defs[0]?.content).toBe('第一行\n第二行\n第三行');
|
||||
});
|
||||
|
||||
it('unescape 正则还原脚注定义 \\[^1\\]: → [^1]:', () => {
|
||||
expect(unescapeFootnote('\\[^1\\]: 定义内容')).toBe('[^1]: 定义内容');
|
||||
it('多行定义序列化后用 4 空格缩进续行(GFM 格式)', () => {
|
||||
const src = ['正文[^a]', '', '[^a]: 第一行', ' 第二行', ''].join('\n');
|
||||
editor.commands.setContent(src, { contentType: 'markdown' });
|
||||
const md = editor.getMarkdown();
|
||||
// 首行无缩进,续行 4 空格——与 pulldown-cmark GFM 续行规则一致。
|
||||
expect(md).toContain('[^a]: 第一行');
|
||||
expect(md).toContain(' 第二行');
|
||||
});
|
||||
|
||||
it('unescape 正则不影响普通链接 [text](url)', () => {
|
||||
const md = '\\[text\\](https://example.com)';
|
||||
// 普通链接的 \[text\] 不以 ^ 开头,不会被误改
|
||||
expect(unescapeFootnote(md)).toBe(md);
|
||||
it('label 含空格也能正确解析与序列化', () => {
|
||||
editor.commands.setContent('正文[^my note]', { contentType: 'markdown' });
|
||||
const refs = collectNodes(editor, 'footnoteRef');
|
||||
expect(refs[0]?.label).toBe('my note');
|
||||
expect(editor.getMarkdown()).toContain('[^my note]');
|
||||
});
|
||||
|
||||
it('unescape 正则一次处理多个脚注引用', () => {
|
||||
const md = '引用了\\[^1\\]和\\[^pc\\]和\\[^2\\]';
|
||||
expect(unescapeFootnote(md)).toBe('引用了[^1]和[^pc]和[^2]');
|
||||
it('普通链接 [text](url) 不被脚注 tokenizer 误吞', () => {
|
||||
editor.commands.setContent('链接[示例](http://x.com)此处', {
|
||||
contentType: 'markdown',
|
||||
});
|
||||
const refs = collectNodes(editor, 'footnoteRef');
|
||||
expect(refs).toHaveLength(0);
|
||||
const md = editor.getMarkdown();
|
||||
expect(md).toContain('[示例](http://x.com)');
|
||||
// 也不应被转义成 \[示例\]
|
||||
expect(md).not.toContain('\\[示例\\]');
|
||||
});
|
||||
|
||||
it('完整往返:unescape(getMarkdown()) 保留脚注引用语法', () => {
|
||||
editor.commands.setContent('正文[^1]', { contentType: 'markdown' });
|
||||
const fixed = unescapeFootnote(editor.getMarkdown());
|
||||
expect(fixed).toContain('[^1]');
|
||||
expect(fixed).not.toContain('\\[^1\\]');
|
||||
it('多次引用同一 label 产生多个 footnoteRef 节点', () => {
|
||||
editor.commands.setContent('前[^1]后[^1]再[^2]', { contentType: 'markdown' });
|
||||
const refs = collectNodes(editor, 'footnoteRef');
|
||||
expect(refs).toHaveLength(3);
|
||||
expect(refs.map((r) => r.label)).toEqual(['1', '1', '2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('脚注编号 - 实时分配', () => {
|
||||
let editor: Editor;
|
||||
|
||||
beforeEach(() => {
|
||||
editor = makeEditor();
|
||||
});
|
||||
|
||||
it('编号按引用首次出现顺序分配', () => {
|
||||
editor.commands.setContent('先[^b]后[^a]', { contentType: 'markdown' });
|
||||
const numbering = getNumbering(editor);
|
||||
// b 先出现→1, a 后出现→2
|
||||
expect(numbering.get('b')).toBe(1);
|
||||
expect(numbering.get('a')).toBe(2);
|
||||
});
|
||||
|
||||
it('重复引用同一 label 共享同一编号', () => {
|
||||
editor.commands.setContent('前[^1]中[^1]后[^2]', { contentType: 'markdown' });
|
||||
const numbering = getNumbering(editor);
|
||||
expect(numbering.get('1')).toBe(1);
|
||||
expect(numbering.get('2')).toBe(2);
|
||||
});
|
||||
|
||||
it('删除引用后编号重排', () => {
|
||||
editor.commands.setContent('A[^x]B[^y]', { contentType: 'markdown' });
|
||||
expect(getNumbering(editor).get('x')).toBe(1);
|
||||
expect(getNumbering(editor).get('y')).toBe(2);
|
||||
|
||||
// 删除第一个引用 [^x](用全量替换简化)。
|
||||
editor.commands.setContent('AB[^y]', { contentType: 'markdown' });
|
||||
const numbering = getNumbering(editor);
|
||||
expect(numbering.has('x')).toBe(false);
|
||||
// y 现在是唯一的,编号应是 1。
|
||||
expect(numbering.get('y')).toBe(1);
|
||||
});
|
||||
|
||||
it('定义内容收集到 definitions 供引用预览', () => {
|
||||
editor.commands.setContent('正文[^1]\n\n[^1]: 定义内容', {
|
||||
contentType: 'markdown',
|
||||
});
|
||||
expect(getDefinitions(editor).get('1')).toBe('定义内容');
|
||||
});
|
||||
});
|
||||
|
||||
212
libs/tiptap-editor/src/__tests__/mermaid.test.ts
Normal file
212
libs/tiptap-editor/src/__tests__/mermaid.test.ts
Normal file
@ -0,0 +1,212 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* mermaid 代码块预览测试。
|
||||
*
|
||||
* mock `./mermaid` 模块的 renderMermaid/getCurrentTheme/loadMermaidRenderer,
|
||||
* 绕过真实 mermaid 运行时(happy-dom 不支持 SVG 引擎)。重点验证 NodeView 的
|
||||
* 生命周期:预览区创建/移除、debounce、竞态取消、主题重渲染、错误态、清理。
|
||||
*/
|
||||
|
||||
const mockRenderMermaid = vi.fn();
|
||||
const mockGetCurrentTheme = vi.fn().mockReturnValue('light');
|
||||
|
||||
vi.mock('../mermaid', () => ({
|
||||
renderMermaid: (...args: unknown[]) => mockRenderMermaid(...args),
|
||||
getCurrentTheme: () => mockGetCurrentTheme(),
|
||||
}));
|
||||
|
||||
// THEME_CHANGE_EVENT 实际从 @yggdrasil/shared import,需一并 stub(vi.mock 对裸模块名)。
|
||||
vi.mock('@yggdrasil/shared', () => ({
|
||||
THEME_CHANGE_EVENT: 'yggdrasil:theme-change',
|
||||
mermaidThemeVarsFor: () => ({}),
|
||||
}));
|
||||
|
||||
const CODEBLOCK_TYPE = { name: 'codeBlock' };
|
||||
|
||||
function mockNode(language: string, textContent = '') {
|
||||
return {
|
||||
type: CODEBLOCK_TYPE,
|
||||
attrs: { language },
|
||||
textContent,
|
||||
} as any;
|
||||
}
|
||||
|
||||
function mockEditor() {
|
||||
return { storage: {} } as any;
|
||||
}
|
||||
|
||||
// 动态 import,确保 vi.mock 生效。
|
||||
const { CodeBlockNodeView } = await import('../code-block-view');
|
||||
|
||||
/** 追踪所有测试创建的 view,afterEach 统一 destroy,避免 window 事件监听器跨测试泄漏。 */
|
||||
const views: Array<{ destroy: () => void }> = [];
|
||||
function makeView(opts: { node: any; editor: any }) {
|
||||
const view = new CodeBlockNodeView({ ...opts, getPos: undefined } as any);
|
||||
views.push(view);
|
||||
return view;
|
||||
}
|
||||
|
||||
describe('CodeBlockNodeView mermaid 预览', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
mockRenderMermaid.mockReset();
|
||||
mockGetCurrentTheme.mockReturnValue('light');
|
||||
});
|
||||
afterEach(() => {
|
||||
// 统一清理:destroy 每个 view(移除主题监听 + 清 timer),再切回真实 timer。
|
||||
for (const v of views.splice(0)) v.destroy();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('mermaid 块:构造后出现预览区,debounce 后渲染 SVG', async () => {
|
||||
mockRenderMermaid.mockResolvedValue({ svg: '<svg>flow</svg>' });
|
||||
const view = makeView({
|
||||
node: mockNode('mermaid', 'graph TD\n A-->B'),
|
||||
editor: mockEditor(),
|
||||
} as any);
|
||||
|
||||
// 预览区立即存在,显示加载态。
|
||||
const preview = view.dom.querySelector('.tiptap-codeblock-mermaid-preview');
|
||||
expect(preview).not.toBeNull();
|
||||
expect(preview?.classList.contains('mermaid-loading')).toBe(true);
|
||||
|
||||
// 推进 debounce 500ms,flush 微任务。
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(mockRenderMermaid).toHaveBeenCalledWith('graph TD\n A-->B', 'light');
|
||||
expect(preview?.innerHTML).toBe('<svg>flow</svg>');
|
||||
expect(preview?.classList.contains('mermaid-loading')).toBe(false);
|
||||
});
|
||||
|
||||
it('非 mermaid 块(python):无预览区', () => {
|
||||
const view = makeView({
|
||||
node: mockNode('python', 'print(1)'),
|
||||
editor: mockEditor(),
|
||||
} as any);
|
||||
expect(view.dom.querySelector('.tiptap-codeblock-mermaid-preview')).toBeNull();
|
||||
});
|
||||
|
||||
it('源码变化(update)触发 debounce 重渲染', async () => {
|
||||
mockRenderMermaid.mockResolvedValue({ svg: '<svg>v2</svg>' });
|
||||
const view = makeView({
|
||||
node: mockNode('mermaid', 'graph TD\n A-->B'),
|
||||
editor: mockEditor(),
|
||||
} as any);
|
||||
await vi.advanceTimersByTimeAsync(500); // 首次渲染
|
||||
|
||||
// 源码变化(update 传入新 node)。
|
||||
view.update(mockNode('mermaid', 'graph TD\n A-->C') as any);
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(mockRenderMermaid).toHaveBeenLastCalledWith('graph TD\n A-->C', 'light');
|
||||
expect(view.dom.querySelector('.tiptap-codeblock-mermaid-preview')?.innerHTML).toBe(
|
||||
'<svg>v2</svg>',
|
||||
);
|
||||
});
|
||||
|
||||
it('language 从 python 切到 mermaid:创建预览区并渲染', async () => {
|
||||
mockRenderMermaid.mockResolvedValue({ svg: '<svg>m</svg>' });
|
||||
const view = makeView({
|
||||
node: mockNode('python', 'x'),
|
||||
editor: mockEditor(),
|
||||
} as any);
|
||||
expect(view.dom.querySelector('.tiptap-codeblock-mermaid-preview')).toBeNull();
|
||||
|
||||
view.update(mockNode('mermaid', 'graph TD\n A-->B') as any);
|
||||
expect(view.dom.querySelector('.tiptap-codeblock-mermaid-preview')).not.toBeNull();
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(mockRenderMermaid).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('language 从 mermaid 切到 python:移除预览区', async () => {
|
||||
mockRenderMermaid.mockResolvedValue({ svg: '<svg>m</svg>' });
|
||||
const view = makeView({
|
||||
node: mockNode('mermaid', 'graph TD\n A-->B'),
|
||||
editor: mockEditor(),
|
||||
} as any);
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
|
||||
view.update(mockNode('python', 'x') as any);
|
||||
expect(view.dom.querySelector('.tiptap-codeblock-mermaid-preview')).toBeNull();
|
||||
});
|
||||
|
||||
it('渲染失败:预览区显示错误 + mermaid-error class', async () => {
|
||||
mockRenderMermaid.mockResolvedValue({ error: 'syntax error' });
|
||||
const view = makeView({
|
||||
node: mockNode('mermaid', 'bad syntax'),
|
||||
editor: mockEditor(),
|
||||
} as any);
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
|
||||
const preview = view.dom.querySelector('.tiptap-codeblock-mermaid-preview');
|
||||
expect(preview?.classList.contains('mermaid-error')).toBe(true);
|
||||
expect(preview?.textContent).toContain('syntax error');
|
||||
});
|
||||
|
||||
it('竞态:连续两次源码改动,只保留最后一次渲染结果', async () => {
|
||||
// 第一次渲染 pending(慢),第二次立即 resolve——验证慢的过期结果不覆盖快的。
|
||||
let resolveFirst!: (v: { svg: string }) => void;
|
||||
mockRenderMermaid
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((r) => {
|
||||
resolveFirst = () => r({ svg: '<svg>first</svg>' });
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce({ svg: '<svg>second</svg>' });
|
||||
|
||||
const view = makeView({
|
||||
node: mockNode('mermaid', 'v1'),
|
||||
editor: mockEditor(),
|
||||
} as any);
|
||||
await vi.advanceTimersByTimeAsync(500); // 触发首次(v1),pending
|
||||
|
||||
// 立即改源码(v2),token 推进,首次结果应被丢弃。
|
||||
view.update(mockNode('mermaid', 'v2') as any);
|
||||
await vi.advanceTimersByTimeAsync(500); // 触发第二次(v2),resolve
|
||||
resolveFirst({ svg: '<svg>first</svg>' }); // 第一次才 resolve(过期)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const preview = view.dom.querySelector('.tiptap-codeblock-mermaid-preview');
|
||||
// 第二次结果胜出,first 被丢弃。
|
||||
expect(preview?.innerHTML).toBe('<svg>second</svg>');
|
||||
});
|
||||
|
||||
it('主题切换事件触发重渲染', async () => {
|
||||
mockRenderMermaid.mockResolvedValue({ svg: '<svg>m</svg>' });
|
||||
makeView({
|
||||
node: mockNode('mermaid', 'graph TD\n A-->B'),
|
||||
editor: mockEditor(),
|
||||
} as any);
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
|
||||
mockGetCurrentTheme.mockReturnValue('dark');
|
||||
mockRenderMermaid.mockClear();
|
||||
window.dispatchEvent(new Event('yggdrasil:theme-change'));
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
|
||||
expect(mockRenderMermaid).toHaveBeenCalledWith('graph TD\n A-->B', 'dark');
|
||||
});
|
||||
|
||||
it('destroy 清理预览区与主题监听', async () => {
|
||||
mockRenderMermaid.mockResolvedValue({ svg: '<svg>m</svg>' });
|
||||
const removeSpy = vi.spyOn(window, 'removeEventListener');
|
||||
const view = makeView({
|
||||
node: mockNode('mermaid', 'graph TD\n A-->B'),
|
||||
editor: mockEditor(),
|
||||
} as any);
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
|
||||
view.destroy();
|
||||
expect(view.dom.querySelector('.tiptap-codeblock-mermaid-preview')).toBeNull();
|
||||
expect(removeSpy).toHaveBeenCalledWith('yggdrasil:theme-change', expect.any(Function));
|
||||
|
||||
// destroy 后主题事件不再触发渲染。
|
||||
mockRenderMermaid.mockClear();
|
||||
window.dispatchEvent(new Event('yggdrasil:theme-change'));
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(mockRenderMermaid).not.toHaveBeenCalled();
|
||||
removeSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@ -1,7 +1,9 @@
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import type { Node as PMNode } from '@tiptap/pm/model';
|
||||
import type { ViewMutationRecord } from '@tiptap/pm/view';
|
||||
import { THEME_CHANGE_EVENT } from '@yggdrasil/shared';
|
||||
import { extractLang, extractOverridesJson } from './highlight';
|
||||
import { getCurrentTheme, renderMermaid } from './mermaid';
|
||||
import { openRunnableModal } from './slash-command';
|
||||
|
||||
/** editor.storage 的 key,宿主(index.ts)在此注入 onRunCode 回调。 */
|
||||
@ -47,6 +49,17 @@ export class CodeBlockNodeView {
|
||||
private code: HTMLElement;
|
||||
private resultArea: HTMLDivElement | null = null;
|
||||
|
||||
// ---- mermaid 预览相关(仅 mermaid 代码块启用) ----
|
||||
private mermaidPreview: HTMLDivElement | null = null;
|
||||
/** 源码变化后 debounce 重渲染的 timer id。 */
|
||||
private mermaidDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** 自增 token,渲染回调比较它丢弃过期结果(防快速改动的竞态覆盖)。 */
|
||||
private mermaidRenderToken = 0;
|
||||
/** 主题切换监听器引用(destroy 时 removeEventListener)。 */
|
||||
private themeHandler: (() => void) | null = null;
|
||||
/** 上次渲染的源码快照,用于 update() 判断源码是否变化。 */
|
||||
private lastRenderedSource: string | null = null;
|
||||
|
||||
private getPos: (() => number | undefined) | undefined;
|
||||
|
||||
constructor(opts: { node: PMNode; editor: Editor; getPos?: () => number | undefined }) {
|
||||
@ -66,10 +79,10 @@ export class CodeBlockNodeView {
|
||||
this.langBadge.classList.add('tiptap-codeblock-lang');
|
||||
this.langBadge.textContent = extractLang((this.node.attrs.language as string) ?? '');
|
||||
// runnable 块的语言标签可点击,触发编辑模态框(改语言/overrides)
|
||||
this.langBadge.addEventListener('click', () => this.openEditModal());
|
||||
if (isRunnable(this.node)) {
|
||||
this.langBadge.classList.add('tiptap-codeblock-lang-editable');
|
||||
this.langBadge.title = '点击修改语言与运行配置';
|
||||
this.langBadge.addEventListener('click', () => this.openEditModal());
|
||||
}
|
||||
this.toolbar.appendChild(this.langBadge);
|
||||
|
||||
@ -93,6 +106,14 @@ export class CodeBlockNodeView {
|
||||
}
|
||||
this.pre.appendChild(this.code);
|
||||
this.container.appendChild(this.pre);
|
||||
|
||||
// 校验并设置工具栏显隐(无语言且非 runnable 时隐藏)
|
||||
this.updateToolbarVisibility();
|
||||
|
||||
// mermaid 代码块:创建预览区并触发首次渲染 + 订阅主题切换。
|
||||
if (this.isMermaid()) {
|
||||
this.setupMermaidPreview();
|
||||
}
|
||||
}
|
||||
|
||||
get dom(): HTMLElement {
|
||||
@ -111,15 +132,41 @@ export class CodeBlockNodeView {
|
||||
if (node.type !== this.node.type) return false;
|
||||
const oldLang = (this.node.attrs.language as string) ?? '';
|
||||
const newLang = (node.attrs.language as string) ?? '';
|
||||
const langChanged = oldLang !== newLang;
|
||||
const oldSource = this.node.textContent;
|
||||
this.node = node;
|
||||
if (oldLang !== newLang) {
|
||||
const newSource = node.textContent;
|
||||
|
||||
if (langChanged) {
|
||||
this.langBadge.textContent = extractLang(newLang);
|
||||
const runnable = isRunnable(node);
|
||||
this.langBadge.classList.toggle('tiptap-codeblock-lang-editable', runnable);
|
||||
if (runnable) {
|
||||
this.langBadge.title = '点击修改语言与运行配置';
|
||||
} else {
|
||||
this.langBadge.removeAttribute('title');
|
||||
}
|
||||
|
||||
// 更新 <code> 的 language class(低亮按新语言重算)
|
||||
this.code.className = '';
|
||||
const langClass = extractLang(newLang);
|
||||
if (langClass) this.code.classList.add(`language-${langClass}`);
|
||||
// runnable 状态变化时重建按钮(简化:不细粒度增删,整体重建工具栏按钮区)
|
||||
this.refreshRunButton();
|
||||
// 刷新工具栏显隐状态(无语言且非 runnable 时隐藏)
|
||||
this.updateToolbarVisibility();
|
||||
|
||||
// language 切入/切出 mermaid:相应创建/移除预览区。
|
||||
if (this.isMermaid() && !this.mermaidPreview) {
|
||||
this.setupMermaidPreview();
|
||||
} else if (!this.isMermaid() && this.mermaidPreview) {
|
||||
this.teardownMermaidPreview();
|
||||
}
|
||||
}
|
||||
|
||||
// mermaid 块源码变化:debounce 重渲染(language 变化后也重渲一次)。
|
||||
if (this.isMermaid() && this.mermaidPreview && (langChanged || oldSource !== newSource)) {
|
||||
this.scheduleMermaidRender();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@ -137,6 +184,104 @@ export class CodeBlockNodeView {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新工具栏显隐与容器类名:无语言且非 runnable 时隐藏工具栏。
|
||||
*/
|
||||
private updateToolbarVisibility(): void {
|
||||
const lang = extractLang((this.node.attrs.language as string) ?? '');
|
||||
const runnable = isRunnable(this.node);
|
||||
const show = lang !== '' || runnable;
|
||||
|
||||
this.toolbar.style.display = show ? '' : 'none';
|
||||
this.container.classList.toggle('has-toolbar', show);
|
||||
}
|
||||
|
||||
/** 当前节点是否为 mermaid 代码块。 */
|
||||
private isMermaid(): boolean {
|
||||
return extractLang((this.node.attrs.language as string) ?? '') === 'mermaid';
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 mermaid 预览区(挂到 container,pre 之后),触发首次渲染 + 订阅主题切换。
|
||||
* 幂等:已存在则不重复创建。
|
||||
*/
|
||||
private setupMermaidPreview(): void {
|
||||
if (this.mermaidPreview) return;
|
||||
const preview = document.createElement('div');
|
||||
preview.classList.add('tiptap-codeblock-mermaid-preview');
|
||||
preview.classList.add('mermaid-loading');
|
||||
preview.setAttribute('contenteditable', 'false');
|
||||
preview.textContent = '渲染中…';
|
||||
// 插到 pre 之后(运行结果区 resultArea 之前,若存在)。
|
||||
this.pre.after(preview);
|
||||
this.mermaidPreview = preview;
|
||||
|
||||
// 订阅主题切换:用新主题重渲染当前块。
|
||||
this.themeHandler = () => {
|
||||
// 主题变了,强制重渲(忽略 lastRenderedSource 的去重)。
|
||||
this.lastRenderedSource = null;
|
||||
this.scheduleMermaidRender();
|
||||
};
|
||||
window.addEventListener(THEME_CHANGE_EVENT, this.themeHandler);
|
||||
|
||||
this.scheduleMermaidRender();
|
||||
}
|
||||
|
||||
/** 移除 mermaid 预览区与主题监听,清理 pending timer。 */
|
||||
private teardownMermaidPreview(): void {
|
||||
if (this.mermaidDebounceTimer !== null) {
|
||||
clearTimeout(this.mermaidDebounceTimer);
|
||||
this.mermaidDebounceTimer = null;
|
||||
}
|
||||
if (this.themeHandler) {
|
||||
window.removeEventListener(THEME_CHANGE_EVENT, this.themeHandler);
|
||||
this.themeHandler = null;
|
||||
}
|
||||
this.mermaidRenderToken += 1; // 使任何 in-flight 渲染失效
|
||||
this.mermaidPreview?.remove();
|
||||
this.mermaidPreview = null;
|
||||
this.lastRenderedSource = null;
|
||||
}
|
||||
|
||||
/** 源码未变则跳过;否则 debounce 500ms 后渲染(避免输入时频繁渲染)。 */
|
||||
private scheduleMermaidRender(): void {
|
||||
if (!this.mermaidPreview) return;
|
||||
const source = this.node.textContent;
|
||||
if (source === this.lastRenderedSource) return;
|
||||
|
||||
if (this.mermaidDebounceTimer !== null) {
|
||||
clearTimeout(this.mermaidDebounceTimer);
|
||||
}
|
||||
this.mermaidDebounceTimer = setTimeout(() => {
|
||||
this.mermaidDebounceTimer = null;
|
||||
void this.renderMermaidPreview();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* 实际渲染:调 renderMermaid,用 renderToken 防竞态(快速改动时只保留最新结果)。
|
||||
* 成功注入 SVG,失败显示错误(源码仍可编辑修正)。
|
||||
*/
|
||||
private async renderMermaidPreview(): Promise<void> {
|
||||
if (!this.mermaidPreview) return;
|
||||
const source = this.node.textContent;
|
||||
this.mermaidRenderToken += 1;
|
||||
const token = this.mermaidRenderToken;
|
||||
this.lastRenderedSource = source;
|
||||
|
||||
const result = await renderMermaid(source, getCurrentTheme());
|
||||
// 渲染期间预览区可能被 teardown 或发起新渲染:过期则丢弃。
|
||||
if (!this.mermaidPreview || token !== this.mermaidRenderToken) return;
|
||||
|
||||
this.mermaidPreview.classList.remove('mermaid-loading', 'mermaid-error');
|
||||
if ('svg' in result) {
|
||||
this.mermaidPreview.innerHTML = result.svg;
|
||||
} else {
|
||||
this.mermaidPreview.classList.add('mermaid-error');
|
||||
this.mermaidPreview.textContent = `渲染失败:${result.error}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 DOM mutation 是否应被 ProseMirror 忽略。
|
||||
*
|
||||
@ -178,6 +323,7 @@ export class CodeBlockNodeView {
|
||||
/** 点击运行:调 editor.storage.__onRunCode,结果填入结果区。 */
|
||||
/** 点击语言标签:打开编辑模态框,修改当前 runnable 块的语言/overrides。 */
|
||||
private openEditModal(): void {
|
||||
if (!isRunnable(this.node)) return;
|
||||
const pos = this.getPos?.();
|
||||
const currentInfo = (this.node.attrs.language as string) ?? '';
|
||||
if (pos === undefined) return;
|
||||
@ -245,6 +391,8 @@ export class CodeBlockNodeView {
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
// 清理 mermaid 预览资源(timer、主题监听、in-flight token)。
|
||||
this.teardownMermaidPreview();
|
||||
this.resultArea = null;
|
||||
this.runBtn = null;
|
||||
}
|
||||
|
||||
534
libs/tiptap-editor/src/footnote.ts
Normal file
534
libs/tiptap-editor/src/footnote.ts
Normal file
@ -0,0 +1,534 @@
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import { Extension, mergeAttributes, Node, nodeInputRule } from '@tiptap/core';
|
||||
import type { Node as PMNode } from '@tiptap/pm/model';
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||
|
||||
/**
|
||||
* 脚注节点(footnoteRef 引用 + footnoteDef 定义)与实时编号扩展。
|
||||
*
|
||||
* 设计目标:让脚注像数学公式、代码块一样在富文本编辑器内所见即所得——
|
||||
* 引用 `[^1]` 渲染成上标编号节点,定义 `[^1]: 内容` 渲染成可编辑卡片块,
|
||||
* 编号按引用首次出现顺序分配,与线上文章页(服务端 render_markdown_enhanced
|
||||
* 的 GFM 模式)一致。
|
||||
*
|
||||
* 技术范式照搬 `./math.ts`:inline/block atom Node + markdown spec 三件套
|
||||
* (markdownTokenizer/parseMarkdown/renderMarkdown)。atom 节点的 renderMarkdown
|
||||
* 直接拼字面量,绕过 @tiptap/markdown 的 escapeMarkdownSyntax(它会把
|
||||
* `[`→`\[`、`]`→`\]`,这正是旧版 `unescapeFootnoteSyntax` 要修复的问题——
|
||||
* 走节点路径后该转义不再发生,unescape 后处理可移除)。
|
||||
*
|
||||
* 编号是"派生视图",不写回 attrs(否则每次重算触发 transaction 循环):
|
||||
* FootnoteNumbering 扩展在文档变化后重算 Map<label, number> 并写入
|
||||
* editor.storage.footnoteNumbering,NodeView 读取它绘制编号。
|
||||
*
|
||||
* 编号顺序与服务端 src/api/markdown.rs:74-116 的 fn_order 对齐:按
|
||||
* footnoteRef 在文档中首次出现顺序给唯一 label 分配 1, 2, 3…
|
||||
*/
|
||||
|
||||
// ---- tokenizer 正则 ----
|
||||
// inline `[^label]` 引用:label 不含 ] 与换行。负向断言 (?!:) 排除 `[label]:` 定义形式
|
||||
// (定义是 block 级,理论上不会进 inline tokenize,但加断言更稳妥,也防 [^1]: 被段落内联误吞)。
|
||||
const FOOTNOTE_REF_REGEX = /^\[\^([^\]\n]+)\](?!:)/;
|
||||
// input rule:行内输入 `[^label]` 后空格/标点触发(仿 InlineMath 的行尾锚定)。
|
||||
// 要求 `[^` 前是空白或行首,避免误吞普通文本里的 `[`。
|
||||
const FOOTNOTE_REF_INPUT_REGEX = /(?:^|\s)\[\^([^\]\n\\]+)\](?:[\s。,、;:!?.,;:!?]|$)$/;
|
||||
// block `[^label]: 内容` 定义首行 + 后续缩进续行(4 空格或 tab)。
|
||||
// 续行收集规则与服务端 pulldown-cmark GFM 模式一致。
|
||||
const FOOTNOTE_DEF_REGEX = /^\[\^([^\]\n]+)\]:[ \t]*(.*)\n?((?:[ \t]+[^\n]*(?:\n|$))*)/;
|
||||
|
||||
/** storage 里存放的编号表,NodeView 读取它绘制编号。 */
|
||||
export interface FootnoteNumberingStorage {
|
||||
/** label → 显示编号(1 起)。 */
|
||||
numbering: Map<string, number>;
|
||||
/** 每次重算自增,NodeView 用它判断是否需要重绘。 */
|
||||
version: number;
|
||||
/** label → 定义内容(供引用节点双击预览)。 */
|
||||
definitions: Map<string, string>;
|
||||
}
|
||||
|
||||
/** 把多行 content 重新格式化为 GFM 续行:首行跟在 `[^label]: ` 后,续行缩进 4 空格。 */
|
||||
function formatDefinitionBody(content: string): string {
|
||||
const lines = content.replace(/\r\n/g, '\n').split('\n');
|
||||
if (lines.length <= 1) return content;
|
||||
// 续行每行加 4 空格缩进(空行不缩进,保持可读)。
|
||||
return lines.map((line, i) => (i === 0 || line === '' ? line : ` ${line}`)).join('\n');
|
||||
}
|
||||
|
||||
/** 从 marked 捕获的续行 raw 还原出纯内容(去掉每行前导缩进)。 */
|
||||
function unindentContinuation(raw: string): string {
|
||||
return raw
|
||||
.split('\n')
|
||||
.map((line) => line.replace(/^[ \t]{1,4}/, ''))
|
||||
.join('\n')
|
||||
.replace(/\n+$/, '');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// NodeView:脚注引用(inline 上标)
|
||||
// ============================================================
|
||||
class FootnoteRefNodeView {
|
||||
private node: PMNode;
|
||||
private editor: Editor;
|
||||
private container: HTMLSpanElement;
|
||||
private linkEl: HTMLAnchorElement;
|
||||
|
||||
constructor(opts: { node: PMNode; editor: Editor }) {
|
||||
this.node = opts.node;
|
||||
this.editor = opts.editor;
|
||||
|
||||
this.container = document.createElement('span');
|
||||
this.container.setAttribute('contenteditable', 'false');
|
||||
this.container.classList.add('fn-ref-node');
|
||||
|
||||
this.linkEl = document.createElement('a');
|
||||
this.linkEl.classList.add('fn-ref-link');
|
||||
this.linkEl.setAttribute('role', 'doc-noteref');
|
||||
this.container.appendChild(this.linkEl);
|
||||
|
||||
this.applyAttrs();
|
||||
// 初始编号文本先占位,plugin 的 view.update 会立即覆盖为真实编号。
|
||||
this.linkEl.textContent = `^${(this.node.attrs.label as string) ?? ''}`;
|
||||
this.attachPreview();
|
||||
}
|
||||
|
||||
get dom(): HTMLElement {
|
||||
return this.container;
|
||||
}
|
||||
|
||||
get contentDOM(): HTMLElement | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
update(node: PMNode): boolean {
|
||||
if (node.type !== this.node.type) return false;
|
||||
const labelChanged = (node.attrs.label as string) !== (this.node.attrs.label as string);
|
||||
this.node = node;
|
||||
// label 变了才需更新 data-label/title;编号文本由 plugin 维护,这里不动。
|
||||
if (labelChanged) {
|
||||
this.applyAttrs();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
ignoreMutation(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
stopEvent(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.container.innerHTML = '';
|
||||
}
|
||||
|
||||
private getStorage(): FootnoteNumberingStorage | null {
|
||||
const s = (this.editor.storage as unknown as Record<string, unknown>).footnoteNumbering;
|
||||
return (s as FootnoteNumberingStorage) ?? null;
|
||||
}
|
||||
|
||||
private applyAttrs(): void {
|
||||
const label = (this.node.attrs.label as string) ?? '';
|
||||
this.container.setAttribute('data-label', label);
|
||||
this.linkEl.title = `脚注:${label}`;
|
||||
}
|
||||
|
||||
/** 双击弹出定义内容的只读预览气泡(定义节点里编辑)。 */
|
||||
private attachPreview(): void {
|
||||
this.container.addEventListener('dblclick', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const label = (this.node.attrs.label as string) ?? '';
|
||||
const def = this.getStorage()?.definitions.get(label);
|
||||
// 简易浮层:聚焦后失焦消失。避免引入额外 UI 依赖。
|
||||
const tip = document.createElement('span');
|
||||
tip.className = 'fn-ref-preview';
|
||||
tip.textContent = def ?? '(未定义的脚注)';
|
||||
tip.setAttribute('contenteditable', 'false');
|
||||
this.container.appendChild(tip);
|
||||
const dismiss = () => {
|
||||
tip.remove();
|
||||
document.removeEventListener('click', onOutside, true);
|
||||
};
|
||||
const onOutside = (ev: MouseEvent) => {
|
||||
if (!tip.contains(ev.target as globalThis.Node)) dismiss();
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', onOutside, true), 0);
|
||||
tip.addEventListener('click', (ev) => ev.stopPropagation());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// NodeView:脚注定义(block 卡片,可双击编辑)
|
||||
// ============================================================
|
||||
class FootnoteDefNodeView {
|
||||
private node: PMNode;
|
||||
private editor: Editor;
|
||||
private getPos?: () => number | undefined;
|
||||
|
||||
private container: HTMLElement;
|
||||
private readonly labelEl: HTMLSpanElement;
|
||||
private readonly contentEl: HTMLSpanElement;
|
||||
private editEl: HTMLTextAreaElement | null = null;
|
||||
|
||||
constructor(opts: { node: PMNode; editor: Editor; getPos?: () => number | undefined }) {
|
||||
this.node = opts.node;
|
||||
this.editor = opts.editor;
|
||||
this.getPos = opts.getPos;
|
||||
|
||||
this.container = document.createElement('aside');
|
||||
this.container.classList.add('footnote-definition');
|
||||
this.container.setAttribute('contenteditable', 'false');
|
||||
this.container.setAttribute('role', 'doc-footnote');
|
||||
this.container.title = '双击编辑脚注内容';
|
||||
|
||||
this.labelEl = document.createElement('sup');
|
||||
this.labelEl.classList.add('footnote-definition-label');
|
||||
this.container.appendChild(this.labelEl);
|
||||
|
||||
this.contentEl = document.createElement('span');
|
||||
this.contentEl.classList.add('footnote-definition-content');
|
||||
this.container.appendChild(this.contentEl);
|
||||
|
||||
this.applyAttrs();
|
||||
// 编号文本由 plugin 维护;此处先占位。
|
||||
this.labelEl.textContent = `^${(this.node.attrs.label as string) ?? ''}`;
|
||||
this.attachEdit();
|
||||
}
|
||||
|
||||
get dom(): HTMLElement {
|
||||
return this.container;
|
||||
}
|
||||
|
||||
get contentDOM(): HTMLElement | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
update(node: PMNode): boolean {
|
||||
if (node.type !== this.node.type) return false;
|
||||
const oldLabel = this.node.attrs.label as string;
|
||||
const oldContent = this.node.attrs.content as string;
|
||||
this.node = node;
|
||||
// label/content 变了才重绘 data 属性与内容文本;编号由 plugin 维护。
|
||||
if (
|
||||
oldLabel !== (node.attrs.label as string) ||
|
||||
oldContent !== (node.attrs.content as string)
|
||||
) {
|
||||
this.applyAttrs();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
ignoreMutation(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
stopEvent(): boolean {
|
||||
// 编辑态下 textarea 事件交给浏览器;非编辑态双击由 attachEdit 处理。
|
||||
return this.editEl !== null;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.container.innerHTML = '';
|
||||
this.editEl = null;
|
||||
}
|
||||
|
||||
private applyAttrs(): void {
|
||||
const label = (this.node.attrs.label as string) ?? '';
|
||||
const content = (this.node.attrs.content as string) ?? '';
|
||||
this.container.setAttribute('data-label', label);
|
||||
// 编辑态不覆盖 textarea,避免打断输入。
|
||||
if (!this.editEl) {
|
||||
this.contentEl.textContent = content;
|
||||
}
|
||||
}
|
||||
|
||||
/** 仿 MathNodeView.enterEdit:textarea + Ctrl/Cmd+Enter 或失焦提交、Esc 放弃。 */
|
||||
private attachEdit(): void {
|
||||
this.container.addEventListener('dblclick', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.enterEdit();
|
||||
});
|
||||
}
|
||||
|
||||
private enterEdit(): void {
|
||||
if (this.editEl) return;
|
||||
const ta = document.createElement('textarea');
|
||||
ta.className = 'footnote-definition-edit';
|
||||
ta.value = (this.node.attrs.content as string) ?? '';
|
||||
ta.rows = 3;
|
||||
ta.spellcheck = false;
|
||||
ta.placeholder = '脚注内容(Markdown)';
|
||||
|
||||
this.contentEl.classList.add('footnote-definition-content-hidden');
|
||||
this.container.appendChild(ta);
|
||||
this.editEl = ta;
|
||||
ta.focus();
|
||||
ta.setSelectionRange(ta.value.length, ta.value.length);
|
||||
|
||||
const commit = () => {
|
||||
if (!this.editEl) return;
|
||||
const next = this.editEl.value;
|
||||
this.editEl = null;
|
||||
ta.remove();
|
||||
this.contentEl.classList.remove('footnote-definition-content-hidden');
|
||||
const pos = this.getPos?.();
|
||||
if (pos !== undefined) {
|
||||
this.editor
|
||||
.chain()
|
||||
.focus()
|
||||
.command(({ tr, state }) => {
|
||||
const target = state.doc.nodeAt(pos);
|
||||
if (!target || target.type !== this.node.type) return false;
|
||||
tr.setNodeMarkup(pos, undefined, { ...target.attrs, content: next });
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
} else {
|
||||
(this.node.attrs as { content: string }).content = next;
|
||||
this.applyAttrs();
|
||||
}
|
||||
};
|
||||
|
||||
ta.addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Enter' && (ev.metaKey || ev.ctrlKey)) {
|
||||
ev.preventDefault();
|
||||
commit();
|
||||
} else if (ev.key === 'Escape') {
|
||||
ev.preventDefault();
|
||||
this.editEl = null;
|
||||
ta.remove();
|
||||
this.contentEl.classList.remove('footnote-definition-content-hidden');
|
||||
}
|
||||
});
|
||||
ta.addEventListener('blur', commit);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// footnoteRef:inline atom 引用节点
|
||||
// ============================================================
|
||||
export const FootnoteRef = Node.create({
|
||||
name: 'footnoteRef',
|
||||
inline: true,
|
||||
group: 'inline',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
label: {
|
||||
default: '',
|
||||
parseHTML: (el) => (el as HTMLElement).getAttribute('data-label') ?? '',
|
||||
renderHTML: (attrs) => {
|
||||
const v = attrs.label;
|
||||
return v == null || v === '' ? {} : { 'data-label': v };
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'sup[data-footnote-ref]' }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['sup', mergeAttributes({ 'data-footnote-ref': 'true' }, HTMLAttributes)];
|
||||
},
|
||||
|
||||
markdownTokenName: 'footnoteRef',
|
||||
markdownTokenizer: {
|
||||
name: 'footnoteRef',
|
||||
level: 'inline',
|
||||
start: (src: string) => src.indexOf('[^'),
|
||||
tokenize: (src: string) => {
|
||||
const m = FOOTNOTE_REF_REGEX.exec(src);
|
||||
if (!m) return undefined;
|
||||
return { type: 'footnoteRef', raw: m[0], text: m[1] };
|
||||
},
|
||||
},
|
||||
parseMarkdown: (token, helpers) => helpers.createNode('footnoteRef', { label: token.text || '' }),
|
||||
renderMarkdown: (node) => `[^${node.attrs?.label ?? ''}]`,
|
||||
|
||||
addNodeView() {
|
||||
return ({ node, editor }) => new FootnoteRefNodeView({ node, editor });
|
||||
},
|
||||
|
||||
addInputRules() {
|
||||
return [
|
||||
nodeInputRule({
|
||||
find: FOOTNOTE_REF_INPUT_REGEX,
|
||||
type: this.type,
|
||||
getAttributes: (match) => ({ label: match[1] ?? '' }),
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// footnoteDef:block atom 定义节点
|
||||
// ============================================================
|
||||
export const FootnoteDef = Node.create({
|
||||
name: 'footnoteDef',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
defining: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
label: {
|
||||
default: '',
|
||||
parseHTML: (el) => (el as HTMLElement).getAttribute('data-label') ?? '',
|
||||
renderHTML: (attrs) => {
|
||||
const v = attrs.label;
|
||||
return v == null || v === '' ? {} : { 'data-label': v };
|
||||
},
|
||||
},
|
||||
content: {
|
||||
default: '',
|
||||
parseHTML: (el) => (el as HTMLElement).getAttribute('data-content') ?? '',
|
||||
renderHTML: (attrs) => {
|
||||
const v = attrs.content;
|
||||
return v == null || v === '' ? {} : { 'data-content': v };
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'aside[data-footnote-def]' }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['aside', mergeAttributes({ 'data-footnote-def': 'true' }, HTMLAttributes)];
|
||||
},
|
||||
|
||||
markdownTokenName: 'footnoteDef',
|
||||
markdownTokenizer: {
|
||||
name: 'footnoteDef',
|
||||
level: 'block',
|
||||
start: (src: string) => src.indexOf('[^'),
|
||||
tokenize: (src: string) => {
|
||||
const m = FOOTNOTE_DEF_REGEX.exec(src);
|
||||
if (!m) return undefined;
|
||||
const label = m[1];
|
||||
const firstLine = m[2] ?? '';
|
||||
const continuation = unindentContinuation(m[3] ?? '');
|
||||
const content = continuation ? `${firstLine}\n${continuation}` : firstLine;
|
||||
return { type: 'footnoteDef', raw: m[0], label, text: content };
|
||||
},
|
||||
},
|
||||
parseMarkdown: (token, helpers) =>
|
||||
helpers.createNode('footnoteDef', { label: token.label || '', content: token.text || '' }),
|
||||
renderMarkdown: (node) => {
|
||||
const label = node.attrs?.label ?? '';
|
||||
const content = node.attrs?.content ?? '';
|
||||
return `[^${label}]: ${formatDefinitionBody(content)}`;
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ({ node, editor, getPos }) => new FootnoteDefNodeView({ node, editor, getPos });
|
||||
},
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// FootnoteNumbering:实时编号扩展
|
||||
// ============================================================
|
||||
const FOOTNOTE_NUMBERING_KEY = new PluginKey('footnoteNumbering');
|
||||
|
||||
export const FootnoteNumbering = Extension.create({
|
||||
name: 'footnoteNumbering',
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
numbering: new Map<string, number>(),
|
||||
definitions: new Map<string, string>(),
|
||||
version: 0,
|
||||
} satisfies FootnoteNumberingStorage;
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const editor = this.editor;
|
||||
return [
|
||||
new Plugin({
|
||||
key: FOOTNOTE_NUMBERING_KEY,
|
||||
// view.update 在每次 transaction 应用后触发;在此重算编号表、写 storage,
|
||||
// 并直接遍历 DOM 重绘脚注节点的编号文本。
|
||||
//
|
||||
// 为什么不 dispatch transaction 强制 NodeView.update:那会形成
|
||||
// update→dispatch→update 链(虽 changed 守卫能止住,但空 transaction
|
||||
// 会让光标/选区异常)。直接改 DOM 文本是 NodeView 外部副作用,但脚注
|
||||
// 编号是纯派生视图(不进文档模型),ProseMirror 的 ignoreMutation 已让
|
||||
// 它对编辑无感,符合 atom + ignoreMutation 范式。
|
||||
view() {
|
||||
return {
|
||||
update(view) {
|
||||
const numbering = new Map<string, number>();
|
||||
const definitions = new Map<string, string>();
|
||||
let order = 0;
|
||||
// 先扫定义(label → content),供引用节点双击预览。
|
||||
view.state.doc.descendants((node) => {
|
||||
if (node.type.name === 'footnoteDef') {
|
||||
definitions.set(node.attrs.label as string, node.attrs.content as string);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
// 再按文档顺序扫引用,首次出现的 label 分配递增编号。
|
||||
view.state.doc.descendants((node) => {
|
||||
if (node.type.name === 'footnoteRef') {
|
||||
const label = node.attrs.label as string;
|
||||
if (!numbering.has(label)) {
|
||||
order += 1;
|
||||
numbering.set(label, order);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const fnStorage = (
|
||||
editor.storage as unknown as { footnoteNumbering: FootnoteNumberingStorage }
|
||||
).footnoteNumbering;
|
||||
const numberingChanged = !mapsEqual(
|
||||
fnStorage.numbering as Map<string, unknown>,
|
||||
numbering as Map<string, unknown>,
|
||||
);
|
||||
const defsChanged = !mapsEqual(
|
||||
fnStorage.definitions as Map<string, unknown>,
|
||||
definitions as Map<string, unknown>,
|
||||
);
|
||||
fnStorage.numbering = numbering;
|
||||
fnStorage.definitions = definitions;
|
||||
if (numberingChanged || defsChanged || fnStorage.version === 0) {
|
||||
fnStorage.version += 1;
|
||||
redrawFootnoteLabels(view.dom, numbering);
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
/** 遍历 DOM,把所有脚注节点的编号文本刷成最新值。 */
|
||||
function redrawFootnoteLabels(dom: HTMLElement, numbering: Map<string, number>): void {
|
||||
const nodes = dom.querySelectorAll('.fn-ref-node, .footnote-definition');
|
||||
for (const el of nodes) {
|
||||
const label = el.getAttribute('data-label') ?? '';
|
||||
const num = numbering.get(label);
|
||||
const display = num !== undefined ? String(num) : `^${label}`;
|
||||
const target = el.classList.contains('fn-ref-node')
|
||||
? el.querySelector('.fn-ref-link')
|
||||
: el.querySelector('.footnote-definition-label');
|
||||
if (target) target.textContent = display;
|
||||
}
|
||||
}
|
||||
|
||||
function mapsEqual(a: Map<string, unknown>, b: Map<string, unknown>): boolean {
|
||||
if (a.size !== b.size) return false;
|
||||
for (const [k, v] of a) {
|
||||
if (!b.has(k) || b.get(k) !== v) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@ -7,6 +7,7 @@ import { Markdown } from '@tiptap/markdown';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import { CodeBlockBackspaceFix } from './code-block-backspace-fix';
|
||||
import { CodeBlockNodeView, ON_RUN_CODE_STORAGE_KEY } from './code-block-view';
|
||||
import { FootnoteDef, FootnoteNumbering, FootnoteRef } from './footnote';
|
||||
import { lowlight } from './highlight';
|
||||
import { DisplayMath, InlineMath } from './math';
|
||||
import { SlashCommand } from './slash-command';
|
||||
@ -119,6 +120,12 @@ class TiptapEditorInstance {
|
||||
// renderMarkdown 路径,绕过 encodeTextForMarkdown 对 LaTeX 的双重转义。
|
||||
InlineMath,
|
||||
DisplayMath,
|
||||
// 脚注节点(引用/定义)与编号扩展:同样依赖 markdown spec 收集时机,
|
||||
// 必须在 Markdown 之后注册(见 math.ts 注释)。FootnoteNumbering 通过
|
||||
// ProseMirror plugin 在文档变化后重算编号表并直接刷 DOM,不进文档模型。
|
||||
FootnoteRef,
|
||||
FootnoteDef,
|
||||
FootnoteNumbering,
|
||||
CodeBlockLowlight.configure({ lowlight }).extend({
|
||||
addNodeView() {
|
||||
return ({ node, editor, getPos }) => new CodeBlockNodeView({ node, editor, getPos });
|
||||
@ -162,7 +169,9 @@ class TiptapEditorInstance {
|
||||
autofocus: false,
|
||||
onUpdate: ({ editor }) => {
|
||||
if (this.options.onUpdate) {
|
||||
this.options.onUpdate(TiptapEditorInstance.unescapeFootnoteSyntax(editor.getMarkdown()));
|
||||
// 脚注 [^id] 现由 atom 节点(footnoteRef/footnoteDef)承载,序列化走
|
||||
// renderMarkdown 直接拼字面量,不再经 escapeMarkdownSyntax,无需 unescape。
|
||||
this.options.onUpdate(editor.getMarkdown());
|
||||
}
|
||||
},
|
||||
onFocus: () => {
|
||||
@ -205,22 +214,7 @@ class TiptapEditorInstance {
|
||||
if (this.isSourceMode && this.sourceTextarea) {
|
||||
return this.sourceTextarea.value;
|
||||
}
|
||||
return TiptapEditorInstance.unescapeFootnoteSyntax(this.editor?.getMarkdown() || '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 修复 @tiptap/markdown 的 escapeMarkdownSyntax 对脚注语法的破坏。
|
||||
*
|
||||
* escapeMarkdownSyntax(MarkdownManager.ts:1112)将 `[` → `\[`、`]` → `\]`,
|
||||
* 导致 `[^id]` 被转义为 `\[^id\]`,pulldown-cmark 将其视为字面文本而非脚注引用/定义。
|
||||
* 这是与数学公式节点同类的问题:LaTeX 通过 atom Node + renderMarkdown 绕过转义,
|
||||
* 但脚注是普通文本节点,无法走相同路径。
|
||||
*
|
||||
* 此函数在序列化后对完整的 Markdown 做后处理:把 `\[^label\]` 还原为 `[^label]`。
|
||||
* 仅匹配 `\[` + `^` 开头的模式(脚注专属),不会影响普通 `[text](url)` 链接语法。
|
||||
*/
|
||||
private static unescapeFootnoteSyntax(md: string): string {
|
||||
return md.replace(/\\\[\^([^\n\\]*?)\\\]/g, '[^$1]');
|
||||
return this.editor?.getMarkdown() || '';
|
||||
}
|
||||
|
||||
/**
|
||||
@ -234,9 +228,7 @@ class TiptapEditorInstance {
|
||||
const proseMirrorDom = this.editor.view.dom;
|
||||
if (!this.isSourceMode) {
|
||||
// 富文本 → 源码:导出当前 Markdown 到 textarea
|
||||
this.sourceTextarea.value = TiptapEditorInstance.unescapeFootnoteSyntax(
|
||||
this.editor.getMarkdown(),
|
||||
);
|
||||
this.sourceTextarea.value = this.editor.getMarkdown();
|
||||
// 必须在 display:'none' 之前读取滚动比例——隐藏后 scrollTop 会被浏览器归零
|
||||
const pmRatio = this.getScrollRatio(proseMirrorDom);
|
||||
proseMirrorDom.style.display = 'none';
|
||||
|
||||
112
libs/tiptap-editor/src/mermaid.ts
Normal file
112
libs/tiptap-editor/src/mermaid.ts
Normal file
@ -0,0 +1,112 @@
|
||||
import { mermaidThemeVarsFor, type ThemeName } from '@yggdrasil/shared';
|
||||
|
||||
/**
|
||||
* 编辑器侧 mermaid 加载与渲染封装。
|
||||
*
|
||||
* 与前台 yggdrasil-core/src/mermaid.ts 共用同一份运行时(`/mermaid/mermaid.js`,
|
||||
* mermaid 11.16.0,挂 window.MermaidRenderer)与同一套 Catppuccin 主题变量
|
||||
* (经 @yggdrasil/shared 单一真相源),保证「编辑器预览 = 线上文章页」。
|
||||
*
|
||||
* 区别于前台:编辑器是 NodeView 内的同步生命周期里发起异步渲染,需由调用方
|
||||
* (CodeBlockNodeView)管理 debounce、竞态取消与主题重渲染。本模块只提供
|
||||
* 纯粹的「加载 bundle + 把源码渲染成 SVG/error」能力。
|
||||
*/
|
||||
|
||||
/** mermaid 11 API 子集(项目只用 initialize + render)。 */
|
||||
type MermaidApi = {
|
||||
initialize: (config: Record<string, unknown>) => void;
|
||||
render: (id: string, text: string) => Promise<{ svg: string }>;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
MermaidRenderer?: MermaidApi;
|
||||
}
|
||||
}
|
||||
|
||||
let mermaidPromise: Promise<MermaidApi> | null = null;
|
||||
let renderCounter = 0;
|
||||
|
||||
/**
|
||||
* 动态加载 mermaid 独立 IIFE bundle(`/mermaid/mermaid.js`)。
|
||||
*
|
||||
* bundle 挂 window.MermaidRenderer(IIFE 无 ES export),故用动态注入 <script>
|
||||
* 标签的方式加载,onload 后从 window 取。单例缓存,失败清空允许重试。
|
||||
* 与前台 mermaid.ts:201-209 同逻辑。
|
||||
*/
|
||||
export function loadMermaidRenderer(): Promise<MermaidApi> {
|
||||
if (!mermaidPromise) {
|
||||
mermaidPromise = new Promise<MermaidApi>((resolve, reject) => {
|
||||
if (window.MermaidRenderer) {
|
||||
resolve(window.MermaidRenderer);
|
||||
return;
|
||||
}
|
||||
const script = document.createElement('script');
|
||||
script.src = '/mermaid/mermaid.js';
|
||||
script.onload = () => {
|
||||
if (window.MermaidRenderer) {
|
||||
resolve(window.MermaidRenderer);
|
||||
} else {
|
||||
reject(new Error('mermaid bundle loaded but window.MermaidRenderer undefined'));
|
||||
}
|
||||
};
|
||||
script.onerror = () => reject(new Error('failed to load /mermaid/mermaid.js'));
|
||||
document.head.appendChild(script);
|
||||
}).catch((err) => {
|
||||
// 失败清空缓存,允许下次重试。
|
||||
mermaidPromise = null;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
return mermaidPromise;
|
||||
}
|
||||
|
||||
/** 测试用:重置加载缓存(注入 mock 后必须调)。 */
|
||||
export function _resetMermaidRendererLoader(): void {
|
||||
mermaidPromise = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 mermaid 源码渲染成 SVG。
|
||||
*
|
||||
* 渲染配置与前台 mermaid.ts:224-238 完全一致:theme:'base' 让 themeVariables
|
||||
* 完全控制调色板、securityLevel:'strict'、flowchart 平滑曲线 + useMaxWidth。
|
||||
* initialize 每次渲染前调(前台同款),保证主题变量即时生效。
|
||||
*
|
||||
* @returns 成功返回 { svg },失败返回 { error }(不抛异常,调用方据此显示错误态)。
|
||||
*/
|
||||
export async function renderMermaid(
|
||||
source: string,
|
||||
theme: ThemeName,
|
||||
): Promise<{ svg: string } | { error: string }> {
|
||||
try {
|
||||
const mermaid = await loadMermaidRenderer();
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: 'base',
|
||||
darkMode: theme === 'dark',
|
||||
securityLevel: 'strict',
|
||||
flowchart: {
|
||||
curve: 'basis',
|
||||
diagramPadding: 16,
|
||||
useMaxWidth: true,
|
||||
htmlLabels: true,
|
||||
},
|
||||
themeVariables: mermaidThemeVarsFor(theme),
|
||||
});
|
||||
const id = `tiptap-mermaid-${++renderCounter}`;
|
||||
const { svg } = await mermaid.render(id, source);
|
||||
return { svg };
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return { error: msg };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取当前站点主题(前台用 documentElement 的 .dark class 标记暗色)。
|
||||
* NodeView 首次渲染与主题切换重渲染时调用。
|
||||
*/
|
||||
export function getCurrentTheme(): ThemeName {
|
||||
return document.documentElement.classList.contains('dark') ? 'dark' : 'light';
|
||||
}
|
||||
@ -960,9 +960,13 @@
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* pre 紧贴 toolbar 下方,合并圆角 */
|
||||
/* pre 默认全圆角;有 toolbar 时与 toolbar 合并顶部圆角 */
|
||||
.tiptap-codeblock > pre {
|
||||
margin: 0;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.tiptap-codeblock.has-toolbar > pre {
|
||||
border-radius: 0 0 6px 6px;
|
||||
}
|
||||
|
||||
@ -1164,3 +1168,224 @@
|
||||
background-color: rgba(137, 180, 250, 0.15);
|
||||
box-shadow: 0 0 0 1px rgba(137, 180, 250, 0.5);
|
||||
}
|
||||
|
||||
/* ---- 脚注引用(inline 上标) ----
|
||||
* 视觉与文章页 input.css 的 .fn-ref 对齐:上标、accent 色、无下划线。
|
||||
* 编号文本由 FootnoteNumbering plugin 直接写入 .fn-ref-link。 */
|
||||
.tiptap-editor .fn-ref-node {
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tiptap-editor .fn-ref-link {
|
||||
font-size: 0.75em;
|
||||
line-height: 0;
|
||||
vertical-align: super;
|
||||
/* Latte blue */
|
||||
color: #1e66f5;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
padding: 0 0.05em;
|
||||
}
|
||||
|
||||
.tiptap-editor .fn-ref-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror-selectednode .fn-ref-node {
|
||||
background-color: rgba(30, 102, 245, 0.12);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* 双击预览浮层 */
|
||||
.tiptap-editor .fn-ref-preview {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
/* Latte base / text */
|
||||
background: #eff1f5;
|
||||
color: #4c4f69;
|
||||
border: 1px solid #ccd0da;
|
||||
border-radius: 6px;
|
||||
padding: 0.4em 0.6em;
|
||||
font-size: 0.85em;
|
||||
line-height: 1.4;
|
||||
max-width: 280px;
|
||||
white-space: normal;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
||||
/* ---- 脚注定义(block 卡片) ----
|
||||
* 视觉与文章页 .footnote-definition 对齐:左边框、0.875em、可双击编辑。 */
|
||||
.tiptap-editor .footnote-definition {
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
align-items: flex-start;
|
||||
margin: 0.75em 0;
|
||||
padding: 0.5em 0.75em;
|
||||
/* Latte surface0 透明底 */
|
||||
background-color: rgba(204, 208, 218, 0.25);
|
||||
border-left: 0.2rem solid #1e66f5;
|
||||
border-radius: 0 4px 4px 0;
|
||||
font-size: 0.875em;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.tiptap-editor .footnote-definition:hover {
|
||||
background-color: rgba(204, 208, 218, 0.45);
|
||||
}
|
||||
|
||||
.tiptap-editor .footnote-definition-label {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.75em;
|
||||
line-height: 1.6;
|
||||
vertical-align: super;
|
||||
/* Latte blue */
|
||||
color: #1e66f5;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tiptap-editor .footnote-definition-content {
|
||||
flex: 1;
|
||||
/* Latte text */
|
||||
color: #4c4f69;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tiptap-editor .footnote-definition-content-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tiptap-editor .footnote-definition-edit {
|
||||
flex: 1;
|
||||
font-family: 'SF Mono', 'Fira Code', 'JetBrains Mono', Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.9em;
|
||||
line-height: 1.5;
|
||||
padding: 0.4em 0.6em;
|
||||
border: 1px solid #ccd0da;
|
||||
border-radius: 6px;
|
||||
background: #eff1f5;
|
||||
color: #4c4f69;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.tiptap-editor .footnote-definition-edit:focus {
|
||||
border-color: #1e66f5;
|
||||
box-shadow: 0 0 0 2px rgba(30, 102, 245, 0.2);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror-selectednode .footnote-definition {
|
||||
background-color: rgba(30, 102, 245, 0.12);
|
||||
box-shadow: 0 0 0 1px rgba(30, 102, 245, 0.4);
|
||||
}
|
||||
|
||||
/* Dark theme (Mocha) */
|
||||
.dark .tiptap-editor .fn-ref-link {
|
||||
/* Mocha blue */
|
||||
color: #89b4fa;
|
||||
}
|
||||
|
||||
.dark .tiptap-editor .ProseMirror-selectednode .fn-ref-node {
|
||||
background-color: rgba(137, 180, 250, 0.15);
|
||||
}
|
||||
|
||||
.dark .tiptap-editor .fn-ref-preview {
|
||||
background: #181825;
|
||||
color: #cdd6f4;
|
||||
border-color: #313244;
|
||||
}
|
||||
|
||||
.dark .tiptap-editor .footnote-definition {
|
||||
background-color: rgba(49, 50, 68, 0.3);
|
||||
border-left-color: #89b4fa;
|
||||
}
|
||||
|
||||
.dark .tiptap-editor .footnote-definition:hover {
|
||||
background-color: rgba(49, 50, 68, 0.5);
|
||||
}
|
||||
|
||||
.dark .tiptap-editor .footnote-definition-label {
|
||||
color: #89b4fa;
|
||||
}
|
||||
|
||||
.dark .tiptap-editor .footnote-definition-content {
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
.dark .tiptap-editor .footnote-definition-edit {
|
||||
border-color: #313244;
|
||||
background: #181825;
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
.dark .tiptap-editor .footnote-definition-edit:focus {
|
||||
border-color: #89b4fa;
|
||||
box-shadow: 0 0 0 2px rgba(137, 180, 250, 0.25);
|
||||
}
|
||||
|
||||
.dark .tiptap-editor .ProseMirror-selectednode .footnote-definition {
|
||||
background-color: rgba(137, 180, 250, 0.15);
|
||||
box-shadow: 0 0 0 1px rgba(137, 180, 250, 0.5);
|
||||
}
|
||||
|
||||
/* ---- mermaid 代码块预览区 ----
|
||||
* 视觉与前台 input.css 的 pre[data-mermaid-rendered] 对齐:flex 居中、
|
||||
* 横向滚动、SVG 等比缩放。预览区挂在源码 <pre> 之后,源码仍可编辑。 */
|
||||
.tiptap-editor .tiptap-codeblock-mermaid-preview {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
overflow-x: auto;
|
||||
padding: 24px 16px;
|
||||
/* Latte base 边框/底 */
|
||||
border-top: 1px solid #ccd0da;
|
||||
background: #eff1f5;
|
||||
border-radius: 0 0 6px 6px;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.tiptap-editor .tiptap-codeblock-mermaid-preview > svg {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* 加载中:弱色文字 */
|
||||
.tiptap-editor .tiptap-codeblock-mermaid-preview.mermaid-loading {
|
||||
/* Latte subtext0 */
|
||||
color: #6c6f85;
|
||||
font-size: 0.875em;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 渲染失败:accent 色左边框(与前台 .mermaid-error 一致)+ 错误文字 */
|
||||
.tiptap-editor .tiptap-codeblock-mermaid-preview.mermaid-error {
|
||||
justify-content: flex-start;
|
||||
/* Latte red */
|
||||
color: #d20f2f;
|
||||
border-left: 0.2rem solid #d20f2f;
|
||||
font-size: 0.85em;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Dark theme (Mocha) */
|
||||
.dark .tiptap-editor .tiptap-codeblock-mermaid-preview {
|
||||
border-top-color: #313244;
|
||||
background: #181825;
|
||||
}
|
||||
|
||||
.dark .tiptap-editor .tiptap-codeblock-mermaid-preview.mermaid-loading {
|
||||
/* Mocha subtext0 */
|
||||
color: #a6adc8;
|
||||
}
|
||||
|
||||
.dark .tiptap-editor .tiptap-codeblock-mermaid-preview.mermaid-error {
|
||||
/* Mocha red */
|
||||
color: #f38ba8;
|
||||
border-left-color: #f38ba8;
|
||||
}
|
||||
|
||||
@ -20,6 +20,7 @@
|
||||
*/
|
||||
|
||||
import type { ThemeName } from '@yggdrasil/shared';
|
||||
import { mermaidThemeVarsFor } from '@yggdrasil/shared';
|
||||
import { onThemeChange } from './theme-transition';
|
||||
|
||||
/** 文章正文容器选择器(与 post_content.rs 的 __initMermaid 调用一致)。 */
|
||||
@ -31,126 +32,11 @@ type MermaidApi = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Catppuccin themeVariables(与站点 --color-paper-* / .tmTheme 调色板对齐)。
|
||||
*
|
||||
* mermaid 把颜色烤进 SVG 内联 style,无法靠 CSS 原地改主题,故须在 initialize 时注入。
|
||||
* 用 `theme: 'base'`(非 'default'/'dark'):base 主题不硬编码颜色,themeVariables 能完全
|
||||
* 控制调色板;'default' 主题硬编码 mainBkg=#ECECFF 等会阻断覆盖。
|
||||
*
|
||||
* 设计哲学:极简卡片化——节点用 surface 色阶(卡片感)、边框/连线用 subtext 色阶(克制)、
|
||||
* 文字用 primary text。不滥用强调色,绿/紫等 accent 留给作者用 classDef 手动强调。
|
||||
* hex 值取自 themes/Catppuccin Latte.tmTheme 与 Catppuccin Mocha.tmTheme。
|
||||
*
|
||||
* 覆盖的字段涵盖 flowchart / sequence / class 三类图(测试文章用到的全部类型)。
|
||||
* Catppuccin themeVariables 已下沉到 @yggdrasil/shared(前台与后台 tiptap 编辑器
|
||||
* 共用单一真相源,保证渲染一致)。用 `theme: 'base'` 让 themeVariables 完全控制
|
||||
* 调色板;详见 shared/src/index.ts 的 MERMAID_LATTE_VARS / MERMAID_MOCHA_VARS。
|
||||
*/
|
||||
|
||||
/** Latte(亮)主题:节点 surface 色阶、文字 #4c4f69、连线 subtext1 #5c5f77。 */
|
||||
const LATTE_VARS = {
|
||||
background: '#dce0e8', // = --color-paper-code-block,图背景与 pre 无缝衔接
|
||||
// 节点填充:surface 色阶递进(主/次/三级),卡片质感
|
||||
primaryColor: '#e6e9ef',
|
||||
secondaryColor: '#ccd0da',
|
||||
tertiaryColor: '#bcc0cc',
|
||||
mainBkg: '#e6e9ef',
|
||||
nodeBkg: '#e6e9ef',
|
||||
secondBkg: '#ccd0da',
|
||||
// 节点边框:surface1 偏冷灰
|
||||
primaryBorderColor: '#bcc0cc',
|
||||
secondaryBorderColor: '#acb0be',
|
||||
tertiaryBorderColor: '#9ca0b0',
|
||||
nodeBorder: '#bcc0cc',
|
||||
clusterBorder: '#bcc0cc',
|
||||
labelBoxBorderColor: '#bcc0cc',
|
||||
// 文字:primary text #4c4f69
|
||||
primaryTextColor: '#4c4f69',
|
||||
secondaryTextColor: '#5c5f77',
|
||||
tertiaryTextColor: '#6c6f85',
|
||||
textColor: '#4c4f69',
|
||||
nodeTextColor: '#4c4f69',
|
||||
titleColor: '#4c4f69',
|
||||
classText: '#4c4f69',
|
||||
labelTextColor: '#4c4f69',
|
||||
// 连线/箭头:subtext1 #5c5f77
|
||||
lineColor: '#5c5f77',
|
||||
defaultLinkColor: '#5c5f77',
|
||||
arrowheadColor: '#5c5f77',
|
||||
// 时序图
|
||||
actorBkg: '#e6e9ef',
|
||||
actorBorder: '#bcc0cc',
|
||||
actorTextColor: '#4c4f69',
|
||||
actorLineColor: '#5c5f77',
|
||||
signalColor: '#5c5f77',
|
||||
signalTextColor: '#4c4f69',
|
||||
loopTextColor: '#4c4f69',
|
||||
sequenceNumberColor: '#eff1f5',
|
||||
activationBkgColor: '#ccd0da',
|
||||
activationBorderColor: '#bcc0cc',
|
||||
// 边标签 / 子图背景
|
||||
edgeLabelBackground: '#eff1f5',
|
||||
labelBoxBkgColor: '#eff1f5',
|
||||
clusterBkg: 'rgba(239, 241, 245, 0.5)',
|
||||
// 注释:低饱和黄(Latte yellow #df8e1d)
|
||||
noteBkgColor: 'rgba(223, 142, 29, 0.15)',
|
||||
noteBorderColor: '#df8e1d',
|
||||
noteTextColor: '#4c4f69',
|
||||
// 字体:与正文 sans 对齐,中文友好
|
||||
fontFamily:
|
||||
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif",
|
||||
fontSize: '16px',
|
||||
} as const;
|
||||
|
||||
/** Mocha(暗)主题:节点 surface 色阶、文字 #cdd6f4、连线 subtext0 #a6adc8。 */
|
||||
const MOCHA_VARS = {
|
||||
background: '#313244', // = --color-paper-code-block(暗)
|
||||
primaryColor: '#45475a',
|
||||
secondaryColor: '#585b70',
|
||||
tertiaryColor: '#1e1e2e',
|
||||
mainBkg: '#45475a',
|
||||
nodeBkg: '#45475a',
|
||||
secondBkg: '#585b70',
|
||||
primaryBorderColor: '#585b70',
|
||||
secondaryBorderColor: '#45475a',
|
||||
tertiaryBorderColor: '#313244',
|
||||
nodeBorder: '#585b70',
|
||||
clusterBorder: '#585b70',
|
||||
labelBoxBorderColor: '#585b70',
|
||||
primaryTextColor: '#cdd6f4',
|
||||
secondaryTextColor: '#bac2de',
|
||||
tertiaryTextColor: '#a6adc8',
|
||||
textColor: '#cdd6f4',
|
||||
nodeTextColor: '#cdd6f4',
|
||||
titleColor: '#cdd6f4',
|
||||
classText: '#cdd6f4',
|
||||
labelTextColor: '#cdd6f4',
|
||||
lineColor: '#a6adc8',
|
||||
defaultLinkColor: '#a6adc8',
|
||||
arrowheadColor: '#a6adc8',
|
||||
actorBkg: '#45475a',
|
||||
actorBorder: '#585b70',
|
||||
actorTextColor: '#cdd6f4',
|
||||
actorLineColor: '#a6adc8',
|
||||
signalColor: '#a6adc8',
|
||||
signalTextColor: '#cdd6f4',
|
||||
loopTextColor: '#cdd6f4',
|
||||
sequenceNumberColor: '#1e1e2e',
|
||||
activationBkgColor: '#585b70',
|
||||
activationBorderColor: '#6c7086',
|
||||
edgeLabelBackground: '#1e1e2e',
|
||||
labelBoxBkgColor: '#1e1e2e',
|
||||
clusterBkg: 'rgba(30, 30, 46, 0.5)',
|
||||
noteBkgColor: 'rgba(249, 226, 175, 0.12)',
|
||||
noteBorderColor: '#f9e2af',
|
||||
noteTextColor: '#cdd6f4',
|
||||
fontFamily:
|
||||
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif",
|
||||
fontSize: '16px',
|
||||
} as const;
|
||||
|
||||
/** 按主题返回对应 Catppuccin themeVariables。 */
|
||||
function themeVarsFor(theme: ThemeName): Record<string, unknown> {
|
||||
return theme === 'dark' ? { ...MOCHA_VARS } : { ...LATTE_VARS };
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
MermaidRenderer?: MermaidApi;
|
||||
@ -234,7 +120,7 @@ async function renderBlock(pre: HTMLPreElement, source: string, theme: ThemeName
|
||||
useMaxWidth: true,
|
||||
htmlLabels: true,
|
||||
},
|
||||
themeVariables: themeVarsFor(theme),
|
||||
themeVariables: mermaidThemeVarsFor(theme),
|
||||
});
|
||||
const id = `mermaid-svg-${++renderCounter}`;
|
||||
const { svg } = await mermaid.render(id, source);
|
||||
|
||||
@ -387,6 +387,9 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
|
||||
}
|
||||
|
||||
let html = wrap_images_with_blur(&html);
|
||||
// 表格外层套可滚动容器,移动端窄屏可横向滚动而不被外层 overflow-hidden 裁切。
|
||||
// sanitizer 不放行 table 的 class/style,但 div 在白名单、class 属全局属性,故包裹 div。
|
||||
let html = wrap_tables(&html);
|
||||
RenderedContent {
|
||||
html: clean_html(&html),
|
||||
toc_html,
|
||||
@ -459,6 +462,30 @@ where
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
/// 把每个 `<table>...</table>` 包进可横向滚动的 `<div class="table-wrap">`。
|
||||
///
|
||||
/// 移动端窄屏下宽表格无法横向滚动:pulldown-cmark 产出裸 table,外层 `<main>` 又是
|
||||
/// `overflow-hidden` 会把超宽内容直接裁掉。这里给 table 套一层 `table-wrap`(CSS 配
|
||||
/// `overflow-x: auto`),让表格在容器内滚动而非撑破页面。
|
||||
///
|
||||
/// 正则匹配 pulldown-cmark 输出的 `<table ...>...</table>`(非贪婪、跨行),包裹整个
|
||||
/// table 标签。对已包裹的 HTML 幂等(不会二次嵌套)——`table-wrap` div 内的 table
|
||||
/// 仍会被正则命中并再次包裹,故调用方仅在渲染管线调用一次。
|
||||
fn wrap_tables(html: &str) -> String {
|
||||
use regex::Regex;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
static TABLE_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"(?s)<table(\s[^>]*)?>.*?</table>").unwrap());
|
||||
|
||||
TABLE_RE
|
||||
.replace_all(html, |caps: ®ex::Captures| {
|
||||
format!("<div class=\"table-wrap\">{}</div>", &caps[0])
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
/// 根据标题层级生成嵌套目录 HTML。
|
||||
fn generate_toc_html(headings: &[(u8, String, String)]) -> String {
|
||||
@ -707,6 +734,96 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ---- wrap_tables 单元测试 ----
|
||||
|
||||
#[test]
|
||||
fn wrap_tables_wraps_bare_table() {
|
||||
let html =
|
||||
"<table><thead><tr><th>A</th></tr></thead><tbody><tr><td>1</td></tr></tbody></table>";
|
||||
let result = wrap_tables(html);
|
||||
assert!(
|
||||
result.starts_with("<div class=\"table-wrap\"><table>")
|
||||
&& result.ends_with("</table></div>"),
|
||||
"应整体包裹一层 table-wrap, got: {}",
|
||||
result
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_tables_wraps_table_with_attributes() {
|
||||
// pulldown-cmark 不会给 table 加属性,但正则需兼容带属性/自闭合起始的 table。
|
||||
let html = r#"<table class="x"><tr><td>1</td></tr></table>"#;
|
||||
let result = wrap_tables(html);
|
||||
assert!(
|
||||
result.contains("<div class=\"table-wrap\"><table"),
|
||||
"应从 table 起始标签整体包裹, got: {}",
|
||||
result
|
||||
);
|
||||
assert!(result.ends_with("</table></div>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_tables_wraps_multiple_tables() {
|
||||
let html =
|
||||
"<table><tr><td>1</td></tr></table>\n<p>间隔</p>\n<table><tr><td>2</td></tr></table>";
|
||||
let result = wrap_tables(html);
|
||||
let wrap_count = result.matches("<div class=\"table-wrap\">").count();
|
||||
assert_eq!(wrap_count, 2, "两个 table 应各自包裹, got: {}", result);
|
||||
// 中间段落不被误包
|
||||
assert!(result.contains("\n<p>间隔</p>\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_tables_handles_multiline_table() {
|
||||
// pulldown-cmark 产出的 table HTML 是单行无换行的,但正则用 (?s) 跨行兼容手写 HTML。
|
||||
let html = "<table>\n <tr>\n <td>1</td>\n </tr>\n</table>";
|
||||
let result = wrap_tables(html);
|
||||
assert!(
|
||||
result.starts_with("<div class=\"table-wrap\"><table>")
|
||||
&& result.ends_with("</table></div>"),
|
||||
"跨行 table 应整体包裹, got: {}",
|
||||
result
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_tables_does_not_touch_non_table_html() {
|
||||
let html = "<p>段落</p><ul><li>项</li></ul>";
|
||||
let result = wrap_tables(html);
|
||||
assert_eq!(result, html, "无 table 时应原样返回");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_tables_then_clean_preserves_div_and_table() {
|
||||
// 端到端:wrap → clean_html,确认 sanitizer 放行 div.table-wrap 与内部 table 结构。
|
||||
let html =
|
||||
"<table><thead><tr><th>H</th></tr></thead><tbody><tr><td>v</td></tr></tbody></table>";
|
||||
let wrapped = wrap_tables(html);
|
||||
let cleaned = clean_html(&wrapped);
|
||||
assert!(
|
||||
cleaned.contains(r#"<div class="table-wrap">"#),
|
||||
"clean_html 应保留 table-wrap div, got: {}",
|
||||
cleaned
|
||||
);
|
||||
assert!(
|
||||
cleaned.contains("<table>") && cleaned.contains("</table>"),
|
||||
"clean_html 应保留 table 标签, got: {}",
|
||||
cleaned
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_markdown_table_wrapped_in_scroll_container() {
|
||||
// 端到端:markdown table 经渲染管线后应被 table-wrap div 包裹。
|
||||
let result = render_markdown_enhanced("| A | B |\n|---|---|\n| 1 | 2 |\n");
|
||||
assert!(
|
||||
result.html.contains(r#"<div class="table-wrap">"#),
|
||||
"markdown table 应被 table-wrap 包裹, got: {}",
|
||||
result.html
|
||||
);
|
||||
assert!(result.html.contains("<table>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugify_heading_simple() {
|
||||
assert_eq!(slugify_heading("Hello World"), "hello-world");
|
||||
|
||||
@ -60,7 +60,7 @@ pub fn FrontendLayout() -> Element {
|
||||
ThemeToggle {}
|
||||
},
|
||||
}
|
||||
main { class: "flex-1 w-full max-w-4xl mx-auto px-6 py-6 md:py-12 overflow-hidden",
|
||||
main { class: "flex-1 w-full max-w-4xl mx-auto px-6 py-6 md:py-12 overflow-x-clip",
|
||||
SuspenseBoundary { fallback: move |_| route_skeleton(&route), Outlet::<Route> {} }
|
||||
}
|
||||
Footer {}
|
||||
|
||||
66
src/main.rs
66
src/main.rs
@ -238,64 +238,6 @@ fn main() {
|
||||
"版本响应头开关(Server / X-Yggdrasil-Version / X-Yggdrasil-Git)"
|
||||
);
|
||||
|
||||
// SSR 世代号中间件:把当前全局世代号注入请求扩展,并对 GET 请求的
|
||||
// 响应附加 `X-SSR-Generation` 头。这是为未来 Dioxus 支持自定义 SSR 缓存键
|
||||
// 预留的钩子;目前主要提供可观测性,不会实际失效 SSR 缓存。
|
||||
async fn ssr_generation_middleware(
|
||||
req: axum::http::Request<axum::body::Body>,
|
||||
next: axum::middleware::Next,
|
||||
) -> axum::response::Response {
|
||||
let generation = crate::ssr_cache::current_global_generation();
|
||||
let is_get = req.method() == axum::http::Method::GET;
|
||||
let (mut parts, body) = req.into_parts();
|
||||
parts
|
||||
.extensions
|
||||
.insert(crate::ssr_cache::SsrGeneration(generation));
|
||||
let mut response = next.run(axum::http::Request::from_parts(parts, body)).await;
|
||||
if is_get {
|
||||
response.headers_mut().insert(
|
||||
axum::http::header::HeaderName::from_static("x-ssr-generation"),
|
||||
axum::http::HeaderValue::from_str(&generation.to_string())
|
||||
.unwrap_or_else(|_| axum::http::HeaderValue::from_static("0")),
|
||||
);
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
// 版本头中间件:为所有响应附加 Server / X-Yggdrasil-Version / X-Yggdrasil-Git,
|
||||
// 数据源与启动日志 log_build_info() 同源(crate::build_info::BUILD_INFO)。
|
||||
// 挂在最终合并 router 的最外层(见下方 Ok(router) 前),因此连 /healthz、
|
||||
// /uploads/*、被 CSRF 拒(403)/超时/admin_guard 重定向的响应都会带头,
|
||||
// 探测价值最大。受 EXPOSE_VERSION_HEADERS 控制(默认 true)。
|
||||
async fn version_headers_middleware(
|
||||
req: axum::http::Request<axum::body::Body>,
|
||||
next: axum::middleware::Next,
|
||||
) -> axum::response::Response {
|
||||
let mut response = next.run(req).await;
|
||||
let h = response.headers_mut();
|
||||
// Server 头:产品名/版本,遵循 "Server: product/version" 习惯。
|
||||
h.insert(
|
||||
axum::http::header::SERVER,
|
||||
axum::http::HeaderValue::from_str(&format!(
|
||||
"yggdrasil/{}",
|
||||
crate::build_info::BUILD_INFO.version
|
||||
))
|
||||
.unwrap_or_else(|_| axum::http::HeaderValue::from_static("yggdrasil")),
|
||||
);
|
||||
// X-Yggdrasil-Version:Cargo.toml 版本号。
|
||||
h.insert(
|
||||
axum::http::header::HeaderName::from_static("x-yggdrasil-version"),
|
||||
axum::http::HeaderValue::from_static(crate::build_info::BUILD_INFO.version),
|
||||
);
|
||||
// X-Yggdrasil-Git:git describe(版本+提交数+短hash+脏标记)。
|
||||
h.insert(
|
||||
axum::http::header::HeaderName::from_static("x-yggdrasil-git"),
|
||||
axum::http::HeaderValue::from_str(crate::build_info::BUILD_INFO.git_describe)
|
||||
.unwrap_or_else(|_| axum::http::HeaderValue::from_static("unknown")),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
// 自定义 API 路由:图片上传(大文件,需要更长超时)
|
||||
// CSRF 校验置于最外层,先拦截非法来源再做超时/限体。
|
||||
let upload_route = axum::Router::new()
|
||||
@ -348,7 +290,9 @@ fn main() {
|
||||
// 合并 Dioxus + CSRF/世代号/缓存头/可选压缩/30s 超时中间件
|
||||
// layer 顺序:后加的最外层先执行。CSRF 最外层先拦截非法来源。
|
||||
let mut app_routes = dioxus_app
|
||||
.layer(axum::middleware::from_fn(ssr_generation_middleware))
|
||||
.layer(axum::middleware::from_fn(
|
||||
crate::middleware::ssr_generation_middleware,
|
||||
))
|
||||
.layer(axum::middleware::from_fn(
|
||||
crate::middleware::add_cache_control,
|
||||
))
|
||||
@ -394,7 +338,9 @@ fn main() {
|
||||
// 版本头中间件置于最终合并 router 的最外层:所有端点(含 /healthz、/uploads/*、
|
||||
// 被 CSRF 拒/超时/admin_guard 重定向的响应)都会带上版本头。受 EXPOSE_VERSION_HEADERS 控制。
|
||||
let router = if expose_version_headers {
|
||||
router.layer(axum::middleware::from_fn(version_headers_middleware))
|
||||
router.layer(axum::middleware::from_fn(
|
||||
crate::middleware::version_headers_middleware,
|
||||
))
|
||||
} else {
|
||||
router
|
||||
};
|
||||
|
||||
@ -226,6 +226,59 @@ pub(crate) async fn admin_guard(
|
||||
}
|
||||
}
|
||||
|
||||
/// Axum 中间件:把当前 SSR 全局世代号注入请求扩展,并对 GET 请求的响应附加
|
||||
/// `X-SSR-Generation` 头。这是为未来 Dioxus 支持自定义 SSR 缓存键预留的钩子;
|
||||
/// 目前主要提供可观测性,不会实际失效 SSR 缓存。
|
||||
pub(crate) async fn ssr_generation_middleware(
|
||||
req: axum::extract::Request,
|
||||
next: axum::middleware::Next,
|
||||
) -> axum::response::Response {
|
||||
let generation = crate::ssr_cache::current_global_generation();
|
||||
let is_get = req.method() == axum::http::Method::GET;
|
||||
let (mut parts, body) = req.into_parts();
|
||||
parts
|
||||
.extensions
|
||||
.insert(crate::ssr_cache::SsrGeneration(generation));
|
||||
let mut response = next.run(axum::http::Request::from_parts(parts, body)).await;
|
||||
if is_get {
|
||||
response.headers_mut().insert(
|
||||
axum::http::header::HeaderName::from_static("x-ssr-generation"),
|
||||
axum::http::HeaderValue::from_str(&generation.to_string())
|
||||
.unwrap_or_else(|_| axum::http::HeaderValue::from_static("0")),
|
||||
);
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
/// Axum 中间件:为所有响应附加 Server / X-Yggdrasil-Version / X-Yggdrasil-Git 头。
|
||||
/// 数据源与启动日志 `log_build_info()` 同源(`crate::build_info::BUILD_INFO`)。
|
||||
/// 受 `EXPOSE_VERSION_HEADERS` 控制——该开关在 `main.rs` 决定是否挂载本层。
|
||||
pub(crate) async fn version_headers_middleware(
|
||||
req: axum::extract::Request,
|
||||
next: axum::middleware::Next,
|
||||
) -> axum::response::Response {
|
||||
let mut response = next.run(req).await;
|
||||
let h = response.headers_mut();
|
||||
h.insert(
|
||||
axum::http::header::SERVER,
|
||||
axum::http::HeaderValue::from_str(&format!(
|
||||
"yggdrasil/{}",
|
||||
crate::build_info::BUILD_INFO.version
|
||||
))
|
||||
.unwrap_or_else(|_| axum::http::HeaderValue::from_static("yggdrasil")),
|
||||
);
|
||||
h.insert(
|
||||
axum::http::header::HeaderName::from_static("x-yggdrasil-version"),
|
||||
axum::http::HeaderValue::from_static(crate::build_info::BUILD_INFO.version),
|
||||
);
|
||||
h.insert(
|
||||
axum::http::header::HeaderName::from_static("x-yggdrasil-git"),
|
||||
axum::http::HeaderValue::from_str(crate::build_info::BUILD_INFO.git_describe)
|
||||
.unwrap_or_else(|_| axum::http::HeaderValue::from_static("unknown")),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{cache_control_for_path, parse_compression_algorithms, CompressionAlgorithms};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user