Files
apes-sidebar/src/client/md-toc.tsx
T
Menghuan1918 7f634866bf 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
2026-08-24 12:05:14 +08:00

128 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The markdown preview's table of contents: a sticky zero-height bar keeping
* a small "outline" button pinned at the top-right of the preview's scroll
* container, visible once the document has at least {@link TOC_MIN_HEADINGS}
* headings. The button opens a popover outline (h1–h6 collected from the
* rendered DOM — so headings inside HTML runs count too); clicking an entry
* expands any collapsed `<details>` ancestor, smooth-scrolls the heading into
* view and flashes it. The scan re-runs through a MutationObserver so late
* content (lazy mermaid chunk, shiki highlighting) is picked up.
*
* Mount contract: render as a DIRECT child of the scroll container it
* outlines. The container is discovered through the bar's own DOM position
* (`parentElement`), not a passed ref — React attaches a parent host
* element's ref only after its children's layout effects have run, so a
* ref-based container read here would see null at mount and never re-run.
* The bar itself renders unconditionally (zero height, pointer-events
* none), so it never affects the preview layout.
*/
import { useLayoutEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { IconListPenOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { t } from './locales.ts'
import css from './sidebar.module.css'
/** Fewer headings than this and the button stays hidden (no outline value). */
export const TOC_MIN_HEADINGS = 3
/** One collected outline entry; `el` is the live rendered heading element. */
interface TocEntry {
level: number
text: string
el: HTMLElement
}
/** The signature used to skip no-op rescans (identity-safe setState guard). */
function signatureOf(entries: readonly TocEntry[]): string {
return entries.map((entry) => `${entry.level}:${entry.text}`).join('\n')
}
export function MdToc(): ReactNode {
const barRef = useRef<HTMLDivElement>(null)
const [entries, setEntries] = useState<TocEntry[]>([])
const [open, setOpen] = useState(false)
const signatureRef = useRef('')
useLayoutEffect(() => {
// The bar's own host ref is attached before this effect runs; the scroll
// container is its parent by the mount contract above.
const container = barRef.current?.parentElement ?? null
if (container === null) return
const scan = (): void => {
const found: TocEntry[] = []
for (const el of container.querySelectorAll<HTMLElement>('h1, h2, h3, h4, h5, h6')) {
const text = (el.textContent ?? '').trim()
if (text === '') continue
found.push({ level: Number(el.tagName.charAt(1)), text, el })
}
const signature = signatureOf(found)
if (signature === signatureRef.current) return
signatureRef.current = signature
setEntries(found)
}
scan()
const observer = new MutationObserver(() => { scan() })
observer.observe(container, { childList: true, subtree: true })
return () => { observer.disconnect() }
}, [])
// Esc closes the popover (the panel is a sibling of the button, so focus
// stays wherever the user came from — a document listener is the simplest).
useLayoutEffect(() => {
if (!open) return
const onKey = (event: KeyboardEvent): void => {
if (event.key === 'Escape') setOpen(false)
}
document.addEventListener('keydown', onKey)
return () => { document.removeEventListener('keydown', onKey) }
}, [open])
const jump = (entry: TocEntry): void => {
entry.el.closest('details:not([open])')?.setAttribute('open', '')
entry.el.scrollIntoView({ behavior: 'smooth', block: 'start' })
const el = entry.el
const flash = css.tocFlash
if (flash !== undefined) {
el.classList.add(flash)
window.setTimeout(() => { el.classList.remove(flash) }, 1200)
}
setOpen(false)
}
const showOutline = entries.length >= TOC_MIN_HEADINGS
return (
<div className={css.tocBar} ref={barRef}>
{open && showOutline && (
<div className={css.tocPanel} data-dsh-md-toc-panel>
{entries.map((entry, index) => (
<button
key={index}
type="button"
className={css.tocItem}
data-level={entry.level}
title={entry.text}
onClick={() => { jump(entry) }}
>
<span className={css.tocItemLevel}>{entry.level}</span>
<span className={css.tocItemText}>{entry.text}</span>
</button>
))}
</div>
)}
{showOutline && (
<button
type="button"
className={css.tocButton}
data-dsh-md-toc=""
aria-label={t('toc')}
title={t('toc')}
aria-expanded={open}
onClick={() => { setOpen(!open) }}
>
<IconListPenOutline16 />
</button>
)}
</div>
)
}