Compare commits

...

8 Commits

Author SHA1 Message Date
xfy
f2216119ae chore(markdown): 格式化测试中的长字符串字面量
Some checks failed
CI / check (push) Has been cancelled
CI / build (push) Has been cancelled
2026-07-23 16:30:31 +08:00
xfy
07d737af0f fix(markdown): 修复窄表格在 table-wrap 内边框被裁切
overflow-x:auto 会强制 overflow-y 也建滚动盒,裁切 table 向外 spread 的 1px
box-shadow 描边,导致不需要滚动的窄表格右下角边框断裂。

给 .table-wrap 加 padding:1px 为描边预留空间,负 margin:-1px 抵消外部尺寸
偏移,使 wrap 不撑大父容器、左上仍贴齐正文边缘。
2026-07-23 16:18:12 +08:00
xfy
485d4b1cc4 fix(markdown): 修复移动端表格无法横向滚动
宽表格在移动端被外层 <main> 的 overflow-hidden 直接裁掉,既看不到也滚不到。
根因是三层叠加:裸 table 无滚动容器、外层裁剪、th 强制 nowrap 加剧超宽。

- markdown.rs: 新增 wrap_tables,给每个 table 套 <div class=table-wrap>
  滚动容器(仿 wrap_images_with_blur 模式,sanitizer 放行 div+class)
- input.css: .table-wrap 设 overflow-x:auto;table 加 min-width:100% 让宽表
  撑开触发滚动;768px 下对旧文章裸 table (.md-content > div > table) 兜底
  block 化,新文章因多一层 div 不受影响、圆角视觉完整
- frontend_layout.rs: main 的 overflow-hidden 改 overflow-x-clip,只裁 x
  且不建滚动容器,让内部 .table-wrap 滚动生效

旧文章经 CSS 兜底立即修复;新文章用渲染器包裹视觉完整。可在 /admin/posts
点 rebuild all 让旧文章也升级到 div 包裹。
2026-07-23 16:07:11 +08:00
xfy
77a22bbfb1 feat(editor): tiptap 代码块 mermaid 实时预览
mermaid 代码块( ```mermaid )在富文本编辑器里此前只是普通源码,作者看不到
渲染效果。现在像数学公式、脚注一样在编辑器内直接预览:源码下方加预览区,
debounce 500ms 后渲染成 SVG,跟随站点 light/dark 主题切换,与前台文章页
渲染视觉完全一致。

一致性保证(核心):
- 同一份 mermaid 运行时:编辑器与前台都加载 /mermaid/mermaid.js(11.16.0)
- 同一套主题变量:经 @yggdrasil/shared 单一真相源(上一提交下沉)
- 同一份渲染配置:theme:'base' + securityLevel:'strict' + flowchart.curve:'basis'
- 同一主题切换机制:监听 THEME_CHANGE_EVENT(与 codemirror-editor 同范式)

实现:
- mermaid.ts:加载 bundle + renderMermaid(source, theme) 封装,与前台同逻辑
- code-block-view.ts:mermaid 块构造时建预览区;源码变化 debounce 重渲染;
  renderToken 防竞态(快速改动只保留最新);主题事件重渲染;language 切入/切出
  mermaid 时创建/移除预览区;destroy 清理 timer+监听+token
- 渲染失败显示错误态(accent 色左边框),源码仍可编辑修正
- 样式对齐前台 pre[data-mermaid-rendered]:flex 居中、SVG 等比缩放、Latte/Mocha 双主题
- 测试 mock mermaid 运行时,覆盖预览区生命周期、debounce、竞态、主题、错误、清理

服务端 markdown.rs / Dioxus.toml / Rust-JS 桥零改动。
2026-07-23 15:13:07 +08:00
xfy
800129e245 refactor(shared): 下沉 mermaid Catppuccin 主题变量到 shared 包
前台 yggdrasil-core 的 LATTE_VARS/MOCHA_VARS 是 mermaid 配色唯一真相源。
后台 tiptap 编辑器要实现 mermaid 预览且与前台视觉一致,需复用同一套变量;
若复制一份会立即产生漂移风险,而「主题一致性」正是编辑器预览的核心目标。

按 shared 包「单一真相源」的设计意图,把两套主题变量(~100 行)+ MermaidThemeVariables
类型 + mermaidThemeVarsFor(theme) 工具函数搬到 shared。yggdrasil-core 改 import
自 shared,删除本地定义——行为零变化(同一份变量,只换引用源)。

前台 37 个测试全绿回归,含 10 个 mermaid 测试验证亮/暗主题传入正确调色板。
2026-07-23 15:12:48 +08:00
xfy
86781d8bbc feat(editor): tiptap 富文本编辑器内脚注所见即所得
脚注 [^id] 引用与 [^id]: 内容 定义此前在富文本模式下只是裸文本,
作者无法看到渲染效果。现在像数学公式一样建模为 atom 节点,在编辑器内
直接预览:引用渲染成上标编号、定义渲染成可双击编辑的卡片块,编号按
引用首次出现顺序分配,与线上文章页 render_markdown_enhanced 的 GFM
模式一致。

- footnoteRef(inline): [^label] 解析成上标节点,双击浮层预览定义内容
- footnoteDef(block): [^label]: 内容 解析成卡片块,双击进入 textarea 编辑
- FootnoteNumbering: ProseMirror plugin,文档变化后重算编号表写 storage,
  并直接遍历 DOM 刷编号文本(不 dispatch transaction,避免循环);编号是
  派生视图,不进文档模型
- 多行定义序列化用 4 空格缩进续行,与 pulldown-cmark GFM 续行规则对齐
- 样式复用文章页 .fn-ref/.footnote-definition 视觉语义,限定 .tiptap-editor
  作用域,Latte/Mocha 双主题

脚注改走 atom 节点的 renderMarkdown 后,不再经过 escapeMarkdownSyntax 的
文本转义路径,unescapeFootnoteSyntax 后处理及其三处调用随之移除(转义问题
从根上消失)。测试从旧的"转义+还原"模型重写为验证节点解析、序列化往返、
编号实时分配。服务端 markdown.rs / input.css 零改动。
2026-07-23 14:33:25 +08:00
xfy
f569258633 fix(editor): 隐藏无语言标识代码块的空工具栏顶栏 2026-07-23 14:00:42 +08:00
xfy
67406f661a refactor(middleware): 抽出 ssr_generation/version_headers 中间件到 middleware.rs
main.rs 的 serve() 闭包内有两个内联 async fn 中间件
(ssr_generation_middleware、version_headers_middleware),与已抽出到
middleware.rs 的 add_cache_control/admin_guard 同类却散落在入口,无法独立测试。

将其移入 middleware.rs 作为 pub(crate) 函数,serve() 闭包改用全路径
crate::middleware::xxx 引用(与现有中间件一致),行为不变。

签名归一:原内联用 axum::http::Request<Body>,迁移后改用
axum::extract::Request(与 add_cache_control/admin_guard 一致;二者是同一
类型的别名,from_fn 接受不变)。

挂载点说明(版本头置于最外层合并 router)留在 main.rs 调用处注释,描述的是
挂载决策而非函数本身。
2026-07-23 13:56:51 +08:00
17 changed files with 1758 additions and 240 deletions

View File

@ -583,9 +583,22 @@
border: 0;
}
/* table-wrap渲染器给宽表格套的滚动容器移动端窄屏可横向滚动
src/api/markdown.rs::wrap_tablesmin-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
View File

@ -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

View File

@ -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 };
}

View File

@ -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"
}

View File

@ -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'),

View File

@ -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('定义内容');
});
});

View 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();
});
});

View File

@ -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;
}

View 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;
}

View File

@ -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
*
* escapeMarkdownSyntaxMarkdownManager.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';

View 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';
}

View File

@ -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;
}

View File

@ -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);

View File

@ -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: &regex::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");

View File

@ -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 {}

View File

@ -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-VersionCargo.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-Gitgit 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
};

View File

@ -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};