feat(editor): Markdown 预览内嵌 HTML 安全渲染 + 目录大纲(TOC)

宿主 MarkdownText 出于聊天安全把 raw HTML 按字面文本渲染,预览 GitHub
风格 README(徽章墙 div、details 折叠块内嵌 markdown、表格单元格内联
标签、video/picture)时全是源码文本。本次在不动渲染器的前提下:

- markdown-html.ts(纯函数):fence 感知切分器把文档切成 markdown/html
  段(CommonMark type-6 块级标签名单 + 注释段 + 空行终止);段内标签
  tokenizer 归约 open/close/html parts;全文档引用定义收集(每段追加
  保持 [text][id] 跨段语义)
- MarkdownHtml.tsx:DOMPurify 白名单消毒(显式禁 script/iframe/form
  等;a 强制 _blank+noopener;本地媒体 src 经 /sidebar/file 重写);
  MarkdownDocument 帧栈状态机让 <details> 跨段嵌套内部 markdown;
  MarkdownSegment 内联替换(MutationObserver 幂等 pass,把表格单元格
  等处标签文本消毒还原为元素,纯散文不误伤)
- md-toc.tsx:零高度 sticky 浮动目录按钮(≥3 标题)+ 弹出大纲,跳转
  平滑滚动 + 自动展开折叠 details + 高亮闪现。关键修复:子组件读父
  ref 在 React layout 阶段恒为 null(子先于父),改为经自身 bar ref
  的 parentElement 定位容器(jsdom 单测传预填 ref 掩盖了该 bug,仅
  真实挂载 e2e 抓到)
- 纯 markdown 文档走原单次解析路径零变化(现有 mermaid e2e 守卫一字
  不动);新增 readme-style.md 种子 + 探针(消毒渲染/嵌套/内联/TOC
  全链路);19 语言词典新增 toc 键;dompurify 提升为直接依赖(与
  mermaid 传递依赖去重单版本)

设计文档 docs/plans/2026-08-24-markdown-html-toc-design.md
This commit is contained in:
Menghuan1918
2026-08-24 12:05:14 +08:00
parent 36a4119253
commit 7f634866bf
36 changed files with 1673 additions and 43 deletions
+132
View File
@@ -2758,3 +2758,135 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
.openWithPinActive {
color: var(--dsw-alias-state-business-primary);
}
/* ── Markdown preview: raw-HTML runs + table of contents ─────────────────
The HTML runs lifted out of the markdown source (MarkdownHtml.tsx) render
as sanitized block leaves; the TOC is a zero-height sticky bar keeping the
outline button pinned at the preview's top-right without consuming layout.
All visuals ride the alias tokens (skin system follows automatically). */
/* A sanitized HTML run leaf (block-level box; content is DOMPurify output). */
.editorHtmlBlock {
margin: 8px 0;
}
.editorHtmlBlock img,
.editorHtmlBlock video {
max-width: 100%;
}
.editorHtmlBlock details {
margin: 4px 0;
padding: 4px 0;
}
.editorHtmlBlock summary {
cursor: pointer;
}
/* The zero-height sticky bar: occupies no layout, keeps the button visible
while the preview scrolls, and lets pointer events pass through the bar
itself (only the interactive children re-enable them). */
.tocBar {
position: sticky;
top: 0;
z-index: 3;
display: flex;
justify-content: flex-end;
height: 0;
pointer-events: none;
}
.tocButton {
pointer-events: auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
margin: 4px 2px 0 0;
padding: 0;
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 6px;
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-secondary);
cursor: pointer;
}
.tocButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
/* The outline popover, anchored under the button (the bar is the containing
block; its height is 0, so the panel drops below the pinned button row). */
.tocPanel {
position: absolute;
top: 32px;
right: 2px;
display: flex;
flex-direction: column;
width: min(300px, 82%);
max-height: 60vh;
overflow-y: auto;
padding: 4px;
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 8px;
background: var(--dsw-alias-bg-layer-2);
box-shadow: var(--dsw-shadow-lv2);
pointer-events: auto;
}
.tocItem {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
padding: 4px 8px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xxs-12);
text-align: left;
cursor: pointer;
}
.tocItem:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
/* Indent by heading level (h2 = one step, h3 = two steps…). */
.tocItem[data-level='2'] { padding-left: 18px; }
.tocItem[data-level='3'] { padding-left: 28px; }
.tocItem[data-level='4'] { padding-left: 38px; }
.tocItem[data-level='5'] { padding-left: 48px; }
.tocItem[data-level='6'] { padding-left: 58px; }
/* The tiny level number keeps deep outlines scannable. */
.tocItemLevel {
flex: none;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-tertiary);
}
.tocItemText {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* The jumped-to heading flashes once (the class is added to the host-rendered
heading element by md-toc.tsx; @keyframes rides the CSS module). */
@keyframes dsh-toc-flash {
0%, 60% { background: var(--dsw-alias-interactive-bg-hover); }
100% { background: transparent; }
}
.tocFlash {
animation: dsh-toc-flash 1.2s ease-out;
border-radius: 4px;
}