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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user