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
+295
View File
@@ -0,0 +1,295 @@
/**
* The markdown preview's raw-HTML renderer. `markdown-html.ts` lifts HTML
* runs out of the markdown stream; this module renders them as sanitized DOM
* alongside the markdown runs (which keep flowing through the shared
* `MarkdownText`), nests markdown into unclosed block elements the way
* GitHub's linear HTML output does (`<details>` … fence … `</details>`), and
* runs an inline pass that turns literal tag text inside rendered markdown
* (table cells with `<br/>`, `<sub>`, `<img>`) back into elements.
*
* Security posture: every HTML string (block leaves, inline text, wrapper
* open-tag attributes) goes through DOMPurify with an explicit denylist on
* top of its defaults (no script/style/iframe/forms), anchors are forced to
* open in a new tab with noopener, and local media `src` attributes are
* rewritten through the session-scoped `/sidebar/file` media route — the same
* trust fence the markdown image rewriter (`markdown-images.ts`) uses.
*/
import { useLayoutEffect, useMemo, useRef } from 'react'
import { createElement, type ReactNode } from 'react'
import DOMPurify from 'dompurify'
import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ComponentType } from 'react'
import { lazyChunkComponent } from './lazy-chunk.tsx'
import { resolveLocalMediaDest } from './markdown-images.ts'
import {
analyzeHtmlSegment,
type AnalyzedMarkdownHtml,
} from './markdown-html.ts'
import { splitMermaidBlocks, type MermaidMarkdownProps } from './mermaid-blocks.ts'
import type { SessionScope } from './api.ts'
import css from './sidebar.module.css'
/** The chunk-resident markdown renderer (mermaid lazy chunk), shared with the
* legacy no-HTML preview path in TextEditor. */
export const LazyMermaidMarkdown = lazyChunkComponent<MermaidMarkdownProps>(
'mermaid',
(mod) => mod.MermaidMarkdown as ComponentType<MermaidMarkdownProps> | undefined,
)
/** Everything the sanitizers need to resolve local media + scope the route. */
export interface MarkdownHtmlMedia {
scope: SessionScope
path: string
origin: string
}
/** Tag-like text in a rendered text node — the inline pass gate. */
const TAGLIKE_TEXT_RE = /<\/?[a-zA-Z][a-zA-Z0-9-]*[\s/>]/
/** Explicit denylist on top of DOMPurify's defaults: no active content, no
* form chrome, no document-level elements inside a preview. */
const PURIFY_FORBID_TAGS = [
'script', 'style', 'iframe', 'object', 'embed', 'form', 'input', 'button',
'select', 'textarea', 'meta', 'link', 'base', 'frame', 'frameset', 'applet',
]
const PURIFY_FORBID_ATTR = ['srcdoc', 'formaction']
/**
* Post-sanitize hardening on a detached element tree: anchors open in a new
* tab (never navigate the GUI), and local media sources go through the media
* route so they render instead of being dropped by protocol allowlists.
*/
function postProcessSanitized(root: Element, media: MarkdownHtmlMedia): void {
for (const anchor of root.querySelectorAll('a[href]')) {
anchor.setAttribute('target', '_blank')
anchor.setAttribute('rel', 'noopener noreferrer')
}
for (const element of root.querySelectorAll('img, video, audio, source')) {
const src = element.getAttribute('src')
if (src === null) continue
element.setAttribute('src', resolveLocalMediaDest(src, media.scope, media.path, media.origin))
}
}
/** Sanitize one balanced HTML span into markup for dangerouslySetInnerHTML. */
function sanitizeHtmlBlock(source: string, media: MarkdownHtmlMedia): string {
const holder = document.createElement('div')
holder.innerHTML = DOMPurify.sanitize(source, {
FORBID_TAGS: PURIFY_FORBID_TAGS,
FORBID_ATTR: PURIFY_FORBID_ATTR,
})
postProcessSanitized(holder, media)
return holder.innerHTML
}
/**
* Sanitize literal tag text from a rendered markdown text node. Returns null
* when nothing real survived (pure prose like `a < b` — the DOMPurify output
* has no element children), so the caller leaves the text node untouched.
*/
function sanitizeInlineHtml(text: string, media: MarkdownHtmlMedia): string | null {
const holder = document.createElement('span')
holder.innerHTML = DOMPurify.sanitize(text, {
FORBID_TAGS: PURIFY_FORBID_TAGS,
FORBID_ATTR: PURIFY_FORBID_ATTR,
})
if (holder.firstElementChild === null) return null
postProcessSanitized(holder, media)
return holder.innerHTML
}
/**
* Sanitize a wrapper open tag (`<details open>`) into React props. Returns
* null when DOMPurify dropped the whole tag (denied element) — the renderer
* then treats the wrapper as transparent. `class`/`for` map to their React
* names; `style` is dropped (React needs an object; wrappers with inline
* styles are vanishingly rare and not worth a CSS parser).
*/
function sanitizeTagProps(tag: string, attrs: string): Record<string, string> | null {
const probe = DOMPurify.sanitize(`<${tag}${attrs}></${tag}>`, {
FORBID_TAGS: PURIFY_FORBID_TAGS,
FORBID_ATTR: PURIFY_FORBID_ATTR,
})
const holder = document.createElement('div')
holder.innerHTML = probe
const element = holder.firstElementChild
if (element === null || element.tagName.toLowerCase() !== tag) return null
const props: Record<string, string> = {}
for (const attr of element.attributes) {
if (/^on/i.test(attr.name) || !/^[a-zA-Z][a-zA-Z0-9:._-]*$/.test(attr.name)) continue
if (attr.name === 'style') continue
props[attr.name === 'class' ? 'className' : attr.name === 'for' ? 'htmlFor' : attr.name] = attr.value
}
return props
}
/**
* The inline pass: walk the rendered markdown's text nodes and swap any that
* contain tag-like text for a sanitized `<span data-html-inline>`. Rendered
* code (inline `code`, `pre`, the host `.md-code-block`, mermaid mounts, and
* spans this pass already produced) is skipped. The same commit-then-operate
* pattern the mermaid swap uses: React keeps owning the host tree, only leaf
* text nodes are replaced, and a text change re-renders the subtree fresh.
*/
function runInlineHtmlPass(container: HTMLElement, media: MarkdownHtmlMedia): void {
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, {
acceptNode: (node) => {
const parent = (node as Text).parentElement
if (parent === null) return NodeFilter.FILTER_REJECT
if (parent.closest('code, pre, .md-code-block, [data-mermaid-processed], [data-html-inline]')) {
return NodeFilter.FILTER_REJECT
}
return TAGLIKE_TEXT_RE.test((node as Text).data)
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT
},
})
const targets: Text[] = []
for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) targets.push(node as Text)
for (const node of targets) {
const html = sanitizeInlineHtml(node.data, media)
if (html === null) continue
const span = document.createElement('span')
span.setAttribute('data-html-inline', '')
span.innerHTML = html
node.replaceWith(span)
}
}
interface MarkdownSegmentProps {
text: string
hasMermaid: boolean
media: MarkdownHtmlMedia
codeLabels: { copyLabel: string; copiedLabel: string }
}
/**
* One markdown run of a split document: the shared MarkdownText pass (or the
* mermaid chunk renderer when the run contains a mermaid fence), plus the
* inline HTML pass. The pass runs after every text change and is re-armed by
* a MutationObserver so it also catches content that appears late (the lazy
* mermaid chunk mounting, shiki highlighting settling) — it is idempotent and
* skips its own output, so mutation feedback settles after one extra pass.
*/
function MarkdownSegment({ text, hasMermaid, media, codeLabels }: MarkdownSegmentProps): ReactNode {
const containerRef = useRef<HTMLDivElement>(null)
useLayoutEffect(() => {
const container = containerRef.current
if (container === null) return
runInlineHtmlPass(container, media)
let scheduled = false
const observer = new MutationObserver(() => {
if (scheduled) return
scheduled = true
queueMicrotask(() => {
scheduled = false
runInlineHtmlPass(container, media)
})
})
observer.observe(container, { childList: true, subtree: true })
return () => { observer.disconnect() }
}, [text, media])
return (
<div ref={containerRef}>
{hasMermaid
? <LazyMermaidMarkdown text={text} codeLabels={codeLabels} />
: <MarkdownText text={text} codeLabels={codeLabels} />}
</div>
)
}
/** A sanitized, balanced HTML span rendered as its own block. */
function HtmlLeaf({ html }: { html: string }): ReactNode {
return (
<div className={css.editorHtmlBlock} data-dsh-html-segment dangerouslySetInnerHTML={{ __html: html }} />
)
}
/** One fully-prepared HTML run: sanitized leaves + wrapper opens/closes. */
type PreparedHtmlPart =
| { kind: 'html'; html: string }
| { kind: 'open'; tag: string; props: Record<string, string> | null }
| { kind: 'close' }
type PreparedSegment =
| { kind: 'markdown'; text: string; hasMermaid: boolean }
| { kind: 'html'; parts: PreparedHtmlPart[] }
interface MarkdownDocumentProps {
info: AnalyzedMarkdownHtml
media: MarkdownHtmlMedia
codeLabels: { copyLabel: string; copiedLabel: string }
}
/**
* The split-document renderer: markdown runs render through MarkdownSegment,
* HTML runs render as sanitized leaves, and unclosed block elements lower the
* following runs into themselves until their close part pops the frame (the
* renderer's frame stack persists across segments). Stray closes at the top
* level render nothing (the sanitizer/parser would drop them anyway), and
* frames still open at the end of the document are closed like a browser
* parser would. Sanitization runs once per prepared change, in a memo.
*/
export function MarkdownDocument({ info, media, codeLabels }: MarkdownDocumentProps): ReactNode {
const prepared = useMemo<PreparedSegment[]>(() => info.segments.map((segment): PreparedSegment => {
if (segment.kind === 'markdown') {
const defs = info.referenceDefinitions
const text = defs === '' ? segment.text : `${segment.text}\n\n${defs}`
return {
kind: 'markdown',
text,
hasMermaid: splitMermaidBlocks(text).some((block) => block.kind === 'mermaid'),
}
}
return {
kind: 'html',
parts: analyzeHtmlSegment(segment.text).parts.map((part): PreparedHtmlPart => {
if (part.kind === 'html') return { kind: 'html', html: sanitizeHtmlBlock(part.html, media) }
if (part.kind === 'open') return { kind: 'open', tag: part.tag, props: sanitizeTagProps(part.tag, part.attrs) }
return { kind: 'close' }
}),
}
// `media` is a memoized object in the host (TextEditor); identity tracks
// scope/path/origin changes so sanitization re-runs exactly when needed.
}), [info, media])
const nodes: ReactNode[] = []
const frames: { tag: string; props: Record<string, string> | null; children: ReactNode[] }[] = []
const emit = (node: ReactNode): void => {
const frame = frames[frames.length - 1]
if (frame !== undefined) frame.children.push(node)
else nodes.push(node)
}
let key = 0
for (const segment of prepared) {
if (segment.kind === 'markdown') {
emit(<MarkdownSegment key={`md-${key += 1}`} text={segment.text} hasMermaid={segment.hasMermaid} media={media} codeLabels={codeLabels} />)
continue
}
for (const part of segment.parts) {
if (part.kind === 'html') {
if (part.html.trim() === '') continue
emit(<HtmlLeaf key={`html-${key += 1}`} html={part.html} />)
} else if (part.kind === 'open') {
frames.push({ tag: part.tag, props: part.props, children: [] })
} else {
const frame = frames.pop()
if (frame === undefined) continue
if (frame.props === null) {
for (const child of frame.children) emit(child)
} else {
emit(createElement(frame.tag, { ...frame.props, key: `wrap-${key += 1}` }, ...frame.children))
}
}
}
}
while (frames.length > 0) {
const frame = frames.pop()!
if (frame.props === null) {
for (const child of frame.children) emit(child)
} else {
emit(createElement(frame.tag, { ...frame.props, key: `wrap-${key += 1}` }, ...frame.children))
}
}
return <>{nodes}</>
}
+42 -21
View File
@@ -14,7 +14,6 @@
* renders the controls instead.
*/
import { useEffect, useMemo, useRef, useState } from 'react'
import type { ComponentType } from 'react'
import { createPortal } from 'react-dom'
import clsx from 'clsx'
import { EditorState } from '@codemirror/state'
@@ -30,7 +29,10 @@ import { SandboxStatusBar } from './SandboxStatusBar.tsx'
import { appendToDraft } from './conversation-draft.ts'
import { buildSelectionInsert, linesOfSelection } from './selection-payload.ts'
import { lazyChunkComponent } from './lazy-chunk.tsx'
import { splitMermaidBlocks, type MermaidMarkdownProps } from './mermaid-blocks.ts'
import { analyzeMarkdownHtml } from './markdown-html.ts'
import { LazyMermaidMarkdown, MarkdownDocument, type MarkdownHtmlMedia } from './MarkdownHtml.tsx'
import { MdToc } from './md-toc.tsx'
import { splitMermaidBlocks } from './mermaid-blocks.ts'
import { t } from './locales.ts'
import type { EditorToolbarState, FileViewerProps } from './service.ts'
import css from './sidebar.module.css'
@@ -45,16 +47,6 @@ interface SelectionPopup {
top: number
}
/**
* The chunk-resident markdown preview renderer (mermaid lazy chunk): one
* MarkdownText pass over the whole source, with rendered mermaid fences
* swapped for diagrams. Module-level `pick` keeps the load effect stable.
*/
const LazyMermaidMarkdown = lazyChunkComponent<MermaidMarkdownProps>(
'mermaid',
(mod) => mod.MermaidMarkdown as ComponentType<MermaidMarkdownProps> | undefined,
)
/**
* The sandbox tokens of the HTML preview iframe. NO allow-same-origin (the
* preview must stay in an opaque origin — with the route's own origin it
@@ -266,9 +258,28 @@ export function TextEditor(props: FileViewerProps) {
() => (markdown && mode === 'preview' ? splitMermaidBlocks(mdText) : []),
[markdown, mode, mdText],
)
/** Raw-HTML analysis (block runs lifted out + inline gate). Non-null only
* for documents that actually contain HTML — plain markdown keeps the
* legacy single-pass render path below, byte-for-byte. */
const htmlInfo = useMemo(
() => (markdown && mode === 'preview' ? analyzeMarkdownHtml(mdText) : null),
[markdown, mode, mdText],
)
const hasMermaid = useMemo(
() => mdBlocks.some(block => block.kind === 'mermaid'),
[mdBlocks],
() => htmlInfo !== null
? htmlInfo.segments.some((segment) => segment.kind === 'markdown'
&& splitMermaidBlocks(segment.text).some((block) => block.kind === 'mermaid'))
: mdBlocks.some((block) => block.kind === 'mermaid'),
[htmlInfo, mdBlocks],
)
/** The media context for the split renderer (local-src rewriting inside
* sanitized HTML). Memoized on primitives: MarkdownDocument sanitizes per
* `media` identity, so a fresh object per render would re-sanitize every
* keystroke. */
const htmlMedia = useMemo<MarkdownHtmlMedia>(
() => ({ scope, path, origin: window.location.origin }),
// eslint-disable-next-line react-hooks/exhaustive-deps
[scope.sessionId, scope.cwd, path],
)
const codeLabels = { copyLabel: t('copy'), copiedLabel: t('copied') }
@@ -395,13 +406,23 @@ export function TextEditor(props: FileViewerProps) {
dictionary: the DSH MarkdownText/CodeBlock are cordis-free and
fall back to hardcoded Chinese otherwise (same pattern as the
chat's AssistantMarkdown). Render-time t() keeps them following
the active locale on live switches. Mermaid fences hand the
whole document to the mermaid lazy chunk (single markdown
parse; cross-fence references/footnotes stay intact); files
without one render exactly as before. */}
{hasMermaid
? <LazyMermaidMarkdown text={previewText} codeLabels={codeLabels} />
: <MarkdownText text={previewText} codeLabels={codeLabels} />}
the active locale on live switches. Plain markdown (no HTML)
renders exactly as before — one MarkdownText pass for the whole
document, or the mermaid lazy chunk (single markdown parse;
cross-fence references/footnotes stay intact) when a mermaid
fence exists. Documents containing HTML (block runs or inline
tags) render through the split document renderer: markdown runs
keep the MarkdownText/mermaid path while raw-HTML runs render
as sanitized DOM (see markdown-html.tsx). */}
{/* The outline button rides on top of the preview scroll container
(sticky, zero-height — first child so it pins from the very
top) once the document has enough headings. */}
<MdToc />
{htmlInfo !== null
? <MarkdownDocument info={htmlInfo} media={htmlMedia} codeLabels={codeLabels} />
: hasMermaid
? <LazyMermaidMarkdown text={previewText} codeLabels={codeLabels} />
: <MarkdownText text={previewText} codeLabels={codeLabels} />}
</div>
)}
{html && mode === 'preview' && (
+1
View File
@@ -80,6 +80,7 @@ export const ar: Record<string, string> = {
terminalDepsHint: 'شغّل الأمر التالي في طرفية أو cmd على جهاز DSH للإصلاح، ثم أعد المحاولة (يبقى node-pty متزامناً مع إصدار نواة DSH):',
terminalDepsProfile: ' (الملف الشخصي المكتشف: {profile})',
preview: 'معاينة',
toc: 'الفهرس',
edit: 'تحرير',
mermaidError: 'فشل رسم Mermaid',
mermaidZoomIn: 'تكبير',
+1
View File
@@ -65,6 +65,7 @@ export const de: Record<string, string> = {
terminalDepsHint: 'Führen Sie den folgenden Befehl in einem Terminal oder in cmd auf dem DSH-System aus, um dies zu beheben, und klicken Sie dann auf „Erneut versuchen“ (node-pty bleibt mit der DSH-Core-Version synchron):',
terminalDepsProfile: ' (erkanntes Profil: {profile})',
preview: 'Vorschau',
toc: 'Inhaltsverzeichnis',
edit: 'Bearbeiten',
mermaidError: 'Mermaid-Rendering fehlgeschlagen',
mermaidZoomIn: 'Vergrößern',
+1
View File
@@ -72,6 +72,7 @@ export const fr: Record<string, string> = {
terminalDepsHint: 'Exécutez la commande suivante dans un terminal ou cmd de l’environnement DSH pour réparer, puis cliquez sur Réessayer (node-pty reste à la même version que le cœur DSH) :',
terminalDepsProfile: ' (profil détecté : {profile})',
preview: 'Aperçu',
toc: 'Sommaire',
edit: 'Modifier',
mermaidError: 'Échec du rendu Mermaid',
mermaidZoomIn: 'Agrandir',
+1
View File
@@ -79,6 +79,7 @@ export const hi: Record<string, string> = {
terminalDepsHint: 'DSH मशीन पर एक टर्मिनल या cmd में नीचे दिया गया कमांड चलाएँ, फिर पुनः प्रयास करें (node-pty DSH कोर संस्करण के साथ सिंक रहता है):',
terminalDepsProfile: ' (पहचाना गया प्रोफ़ाइल: {profile})',
preview: 'पूर्वावलोकन',
toc: 'विषय-सूची',
edit: 'संपादित करें',
mermaidError: 'Mermaid रेंडर विफल',
mermaidZoomIn: 'ज़ूम इन',
+1
View File
@@ -77,6 +77,7 @@ export const id: Record<string, string> = {
terminalDepsHint: 'Jalankan perintah di bawah ini di terminal atau cmd pada mesin DSH untuk memperbaikinya, lalu coba lagi (node-pty tetap sinkron dengan versi inti DSH):',
terminalDepsProfile: ' (profil terdeteksi: {profile})',
preview: 'Pratinjau',
toc: 'Daftar Isi',
edit: 'Edit',
mermaidError: 'Render Mermaid gagal',
mermaidZoomIn: 'Perbesar',
+1
View File
@@ -70,6 +70,7 @@ export const it: Record<string, string> = {
terminalDepsHint: 'Esegua il comando seguente in un terminale o cmd sul computer DSH per ripristinarlo, poi riprovi (node-pty resta sincronizzato con la versione del core DSH):',
terminalDepsProfile: ' (rilevato profilo: {profile})',
preview: 'Anteprima',
toc: 'Indice',
edit: 'Modifica',
mermaidError: 'Rendering Mermaid non riuscito',
mermaidZoomIn: 'Ingrandisci',
+1
View File
@@ -79,6 +79,7 @@ export const ja: Record<string, string> = {
terminalDepsHint: 'DSH 環境のターミナルまたは cmd で以下のコマンドを実行して修復し、再試行してください(node-pty は DSH コアと同じバージョンを維持):',
terminalDepsProfile: '(検出された profile:{profile})',
preview: 'プレビュー',
toc: '目次',
edit: '編集',
mermaidError: 'Mermaid レンダリング失敗',
mermaidZoomIn: '拡大',
+1
View File
@@ -71,6 +71,7 @@ export const ko: Record<string, string> = {
terminalDepsHint: 'DSH가 설치된 환경의 터미널 또는 cmd에서 아래 명령을 실행하여 복구한 후 다시 시도를 클릭하세요(node-pty는 DSH 코어와 동일한 버전을 유지합니다):',
terminalDepsProfile: ' (profile 감지됨: {profile})',
preview: '미리보기',
toc: '목차',
edit: '편집',
mermaidError: 'Mermaid 렌더링 실패',
mermaidZoomIn: '확대',
+1
View File
@@ -77,6 +77,7 @@ export const nl: Record<string, string> = {
terminalDepsHint: 'Voer het onderstaande commando uit in een terminal of cmd op de DSH-machine om het te herstellen en probeer het opnieuw (node-pty blijft in sync met de DSH-coreversie):',
terminalDepsProfile: ' (gedetecteerde profile: {profile})',
preview: 'Voorbeeld',
toc: 'Inhoudsopgave',
edit: 'Bewerken',
mermaidError: 'Mermaid-renderen mislukt',
mermaidZoomIn: 'Inzoomen',
+1
View File
@@ -81,6 +81,7 @@ export const pl: Record<string, string> = {
terminalDepsHint: 'Uruchom poniższe polecenie w terminalu lub cmd na maszynie DSH, aby to naprawić, a następnie ponów (node-pty pozostaje w synchronizacji z wersją rdzenia DSH):',
terminalDepsProfile: ' (wykryty profil: {profile})',
preview: 'Podgląd',
toc: 'Spis treści',
edit: 'Edytuj',
mermaidError: 'Renderowanie Mermaid nie powiodło się',
mermaidZoomIn: 'Powiększ',
+1
View File
@@ -62,6 +62,7 @@ export const pt: Record<string, string> = {
terminalDepsHint: 'Execute o comando abaixo em um terminal ou cmd na máquina do DSH para reparar e tente novamente (node-pty permanece em sincronia com a versão do núcleo do DSH):',
terminalDepsProfile: ' (perfil detectado: {profile})',
preview: 'Pré-visualizar',
toc: 'Sumário',
edit: 'Editar',
mermaidError: 'Falha ao renderizar o Mermaid',
mermaidZoomIn: 'Ampliar',
+1
View File
@@ -75,6 +75,7 @@ export const ru: Record<string, string> = {
terminalDepsHint: 'Выполните приведённую ниже команду в терминале или cmd на машине DSH, затем нажмите «Повторить» (node-pty остаётся синхронизированным с версией ядра DSH):',
terminalDepsProfile: ' (обнаружен profile: {profile})',
preview: 'Предпросмотр',
toc: 'Оглавление',
edit: 'Изменить',
mermaidError: 'Сбой рендеринга Mermaid',
mermaidZoomIn: 'Приблизить',
+1
View File
@@ -62,6 +62,7 @@ export const sv: Record<string, string> = {
terminalDepsHint: 'Kör kommandot nedan i en terminal eller cmd på DSH-maskinen för att reparera det, försök sedan igen (node-pty hålls synkad med DSH-kärnversionen):',
terminalDepsProfile: ' (upptäckt profil: {profile})',
preview: 'Förhandsgranska',
toc: 'Innehållsförteckning',
edit: 'Redigera',
mermaidError: 'Mermaid-rendering misslyckades',
mermaidZoomIn: 'Zooma in',
+1
View File
@@ -79,6 +79,7 @@ export const th: Record<string, string> = {
terminalDepsHint: 'เรียกใช้คำสั่งด้านล่างในเทอร์มินัลหรือ cmd บนเครื่อง DSH เพื่อซ่อมแซม แล้วกดลองอีกครั้ง (node-pty ต้องซิงค์กับเวอร์ชันหลักของ DSH):',
terminalDepsProfile: ' (ตรวจพบ profile: {profile})',
preview: 'พรีวิว',
toc: 'สารบัญ',
edit: 'แก้ไข',
mermaidError: 'เรนเดอร์ Mermaid ล้มเหลว',
mermaidZoomIn: 'ซูมเข้า',
+1
View File
@@ -79,6 +79,7 @@ export const tr: Record<string, string> = {
terminalDepsHint: 'Onarmak için DSH makinesindeki bir terminalde veya cmd’de aşağıdaki komutu çalıştırın, sonra yeniden deneyin (node-pty, DSH çekirdek sürümüyle senkron kalır):',
terminalDepsProfile: ' (algılanan profil: {profile})',
preview: 'Önizleme',
toc: 'İçindekiler',
edit: 'Düzenle',
mermaidError: 'Mermaid oluşturma başarısız',
mermaidZoomIn: 'Yakınlaştır',
+1
View File
@@ -79,6 +79,7 @@ export const vi: Record<string, string> = {
terminalDepsHint: 'Chạy lệnh sau trong terminal hoặc cmd trên máy DSH để sửa, rồi nhấn thử lại (node-pty đồng bộ phiên bản với lõi DSH):',
terminalDepsProfile: ' (phát hiện profile: {profile})',
preview: 'Xem trước',
toc: 'Mục lục',
edit: 'Chỉnh sửa',
mermaidError: 'Render Mermaid thất bại',
mermaidZoomIn: 'Phóng to',
+1
View File
@@ -94,6 +94,7 @@ export const zhHK: Record<string, string> = {
terminalDepsHint: '在 DSH 所在環境的終端或 cmd 中執行以下命令修復,然後點重試(node-pty 與 DSH 核心保持同一版本):',
terminalDepsProfile: '(偵測到 profile:{profile})',
preview: '預覽',
toc: '目錄',
edit: '編輯',
mermaidError: 'Mermaid 渲染失敗',
mermaidZoomIn: '放大',
+1
View File
@@ -94,6 +94,7 @@ export const zhMO: Record<string, string> = {
terminalDepsHint: '在 DSH 所在環境的終端或 cmd 中執行以下命令修復,然後點重試(node-pty 與 DSH 核心保持同一版本):',
terminalDepsProfile: '(偵測到 profile:{profile})',
preview: '預覽',
toc: '目錄',
edit: '編輯',
mermaidError: 'Mermaid 渲染失敗',
mermaidZoomIn: '放大',
+1
View File
@@ -94,6 +94,7 @@ export const zhTW: Record<string, string> = {
terminalDepsHint: '在 DSH 所在環境的終端或 cmd 中執行以下命令修復,然後點重試(node-pty 與 DSH 核心保持同一版本):',
terminalDepsProfile: '(偵測到 profile:{profile})',
preview: '預覽',
toc: '目錄',
edit: '編輯',
mermaidError: 'Mermaid 渲染失敗',
mermaidZoomIn: '放大',
+2
View File
@@ -86,6 +86,7 @@ export const zh = {
terminalDepsHint: '在 DSH 所在环境的终端或 cmd 中执行以下命令修复,然后点重试(node-pty 与 DSH 核心保持同一版本):',
terminalDepsProfile: '(检测到 profile:{profile})',
preview: '预览',
toc: '目录',
edit: '编辑',
mermaidError: 'Mermaid 渲染失败',
mermaidZoomIn: '放大',
@@ -420,6 +421,7 @@ export const en: Record<keyof typeof zh, string> = {
terminalDepsHint: 'Run the command below in a terminal or cmd on the DSH machine to repair it, then retry (node-pty stays in sync with the DSH core version):',
terminalDepsProfile: ' (detected profile: {profile})',
preview: 'Preview',
toc: 'Table of contents',
edit: 'Edit',
mermaidError: 'Mermaid render failed',
mermaidZoomIn: 'Zoom in',
+331
View File
@@ -0,0 +1,331 @@
/**
* Raw-HTML block detection for the markdown preview. The shared `MarkdownText`
* renders raw HTML as literal text (a chat-security stance), so a GitHub-style
* README (`<div align="center">` badge walls, `<details>` collapsibles with
* markdown inside, table cells full of inline tags) previews as source soup.
* This module's pure splitter lifts those HTML runs OUT of the markdown stream
* before rendering: markdown runs keep flowing through `MarkdownText` (shiki /
* KaTeX / GFM intact, mermaid chunk path unchanged) while HTML runs render as
* sanitized DOM (see markdown-html.tsx).
*
* Splitting follows CommonMark's shape closely enough for real-world READMEs:
* a line outside code fences that starts with a block-level tag (type-6 list
* below) or `<!--` opens an HTML run that extends to the next blank line
* (comments end at the line containing `-->`). Inline-only tags (`<b>`, `<br>`,
* `<a>`…) never open a run — they stay in the markdown stream and are handled
* by the inline pass instead. Unclosed block tags (`<details>` … markdown …
* `</details>`) are surfaced by {@link analyzeHtmlSegment} as ordered parts so
* the renderer can nest the in-between markdown inside the open element, the
* way GitHub's linear HTML output nests.
*/
import { CLOSE_FENCE_RE, OPEN_FENCE_RE, fenceInfo } from './mermaid-blocks.ts'
/** A run of raw HTML lines lifted out of the markdown stream. */
export interface HtmlSegment {
kind: 'html'
text: string
}
/** A run of markdown source (may contain non-mermaid fences, inline HTML…). */
export interface MarkdownHtmlSegment {
kind: 'markdown'
text: string
}
export type MdHtmlSegment = MarkdownHtmlSegment | HtmlSegment
/** The doc-wide analysis the preview consumes (all pure, unit-tested here). */
export interface AnalyzedMarkdownHtml {
/** The document split into markdown / html runs (empty input → []). */
segments: MdHtmlSegment[]
/** True when at least one html run was lifted out. */
hasBlockHtml: boolean
/** True when the source contains any tag-like text (block OR inline). */
hasInlineHtml: boolean
/** Every reference definition found in the markdown runs (`[label]: dest`
* lines), joined — appended to each markdown run so `[text][id]` keeps
* resolving across the lifted HTML runs (first-match wins makes the
* appended copy inert inside runs that already define it). */
referenceDefinitions: string
}
/**
* CommonMark HTML-block type-6 tag names (block-level elements), lowercased.
* A line starting with one of these (open or close) outside a fence begins an
* HTML run. `<summary>` is CommonMark-inline but intentionally included: it is
* the idiomatic first child of a `<details>` run in GitHub-flavored READMEs.
*/
export const HTML_BLOCK_TAGS: ReadonlySet<string> = new Set([
'address', 'article', 'aside', 'base', 'basefont', 'blockquote', 'body',
'caption', 'center', 'col', 'colgroup', 'dd', 'details', 'dialog', 'dir',
'div', 'dl', 'dt', 'fieldset', 'figcaption', 'figure', 'footer', 'form',
'frame', 'frameset', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header',
'hr', 'html', 'legend', 'li', 'link', 'main', 'menu', 'menuitem', 'nav',
'noframes', 'ol', 'optgroup', 'option', 'p', 'param', 'picture', 'pre',
'section', 'source', 'summary', 'table', 'tbody', 'td', 'tfoot', 'th',
'thead', 'title', 'tr', 'track', 'ul', 'video',
])
/** HTML void elements — never pushed on the balance stack. */
const VOID_TAGS: ReadonlySet<string> = new Set([
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta',
'param', 'source', 'track', 'wbr',
])
/** A block-tag line start: indent + `<` or `</` + tag name + space/`/`/`>`. */
const HTML_BLOCK_START_RE = /^ {0,3}<\/?([a-zA-Z][a-zA-Z0-9-]*)[\s/>]/
/** A comment-open line start (CommonMark type 2). */
const COMMENT_START_RE = /^ {0,3}<!--/
/** Tag-like text anywhere — the cheap gate for the inline pass. */
const TAGLIKE_RE = /<\/?[a-zA-Z][a-zA-Z0-9-]*[\s/>]/
/** One HTML tag token (open/close/void) in a lifted HTML run. */
interface HtmlToken {
kind: 'open' | 'close' | 'void'
tag: string
/** Raw attribute source of an open tag (between the name and `>`/`/>`). */
attrs: string
start: number
end: number
}
/** A comment range within a run — tags inside comments are not tokens. */
interface CommentRange {
start: number
end: number
}
/** A full tag match including its `>` (token regex; quotes guard `>` inside attrs). */
const TAG_TOKEN_RE = /<(\/?)([a-zA-Z][a-zA-Z0-9-]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/g
const COMMENT_RE = /<!--[\s\S]*?-->/g
/**
* Tokenize a lifted HTML run into tags (comments are located first so tags
* inside them are skipped). Text between tokens is not tokenized — it stays
* part of the raw `html` spans the part analysis slices out.
*/
function tokenizeHtml(source: string): HtmlToken[] {
const comments: CommentRange[] = []
for (const match of source.matchAll(COMMENT_RE)) {
comments.push({ start: match.index, end: match.index + match[0].length })
}
const inComment = (index: number): boolean =>
comments.some((range) => index >= range.start && index < range.end)
const tokens: HtmlToken[] = []
for (const match of source.matchAll(TAG_TOKEN_RE)) {
if (inComment(match.index)) continue
const closing = match[1] === '/'
const tag = match[2]!.toLowerCase()
let attrs = match[3] ?? ''
// Self-closing syntax (`<div/>`) behaves as void regardless of the name.
const selfClosing = /\/\s*$/.test(attrs)
if (selfClosing) attrs = attrs.replace(/\/\s*$/, '')
tokens.push({
kind: closing ? 'close' : selfClosing || VOID_TAGS.has(tag) ? 'void' : 'open',
tag,
attrs,
start: match.index,
end: match.index + match[0].length,
})
}
return tokens
}
/** One structural piece of a lifted HTML run, in document order. */
export type HtmlPart =
| { kind: 'html'; html: string }
| { kind: 'open'; tag: string; attrs: string }
| { kind: 'close'; tag: string }
/** The run's structure relative to the surrounding document nesting. */
export interface HtmlSegmentShape {
parts: HtmlPart[]
}
/**
* Reduce a lifted HTML run to ordered parts: balanced spans become `html`
* leaves, unclosed open tags become `open` (a wrapper the renderer lowers
* following markdown runs into), unmatched closes become `close` (pops one
* wrapper level). A mismatched close pops through the matching open — the
* HTML parser's implicit-close behavior. Runs with no structural tags reduce
* to a single `html` part.
*/
export function analyzeHtmlSegment(source: string): HtmlSegmentShape {
const tokens = tokenizeHtml(source)
const stack: { token: HtmlToken }[] = []
const unmatchedCloses: HtmlToken[] = []
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index]!
if (token.kind === 'void') continue
if (token.kind === 'open') {
stack.push({ token })
continue
}
// Close: pop through the nearest matching open (implicit closes inside).
let matchAt = -1
for (let depth = stack.length - 1; depth >= 0; depth -= 1) {
if (stack[depth]!.token.tag === token.tag) { matchAt = depth; break }
}
if (matchAt === -1) {
unmatchedCloses.push(token)
continue
}
stack.length = matchAt
}
// Structural tokens in source order: unmatched closes + surviving opens.
const structural: Array<{ token: HtmlToken; kind: 'open' | 'close' }> = []
for (const token of unmatchedCloses) structural.push({ token, kind: 'close' })
for (const entry of stack) structural.push({ token: entry.token, kind: 'open' })
structural.sort((a, b) => a.token.start - b.token.start)
if (structural.length === 0) return { parts: [{ kind: 'html', html: source }] }
const parts: HtmlPart[] = []
let cursor = 0
for (const { token, kind } of structural) {
if (token.start > cursor) parts.push({ kind: 'html', html: source.slice(cursor, token.start) })
parts.push(kind === 'open'
? { kind: 'open', tag: token.tag, attrs: token.attrs }
: { kind: 'close', tag: token.tag })
cursor = token.end
}
if (cursor < source.length) parts.push({ kind: 'html', html: source.slice(cursor) })
return { parts }
}
/**
* Split markdown source into markdown / html runs (fence-aware: an HTML-looking
* line inside any fenced code block is content, not a run start). Blank lines
* terminate HTML runs and are dropped between segments (they carry no markdown
* semantics the preview needs); everything else stays byte-identical.
*/
export function splitHtmlBlocks(text: string): MdHtmlSegment[] {
if (text === '') return []
const lines = text.split('\n')
const segments: MdHtmlSegment[] = []
let markdown: string[] = []
let html: string[] = []
/** Fence state: the char + length of the currently open fence, if any. */
let openFence: { char: string; length: number } | null = null
/** Comment state: an HTML comment run ends at the line containing `-->`. */
let inComment = false
/** True while accumulating a blank-line-terminated HTML run. */
let inHtmlRun = false
const flushMarkdown = (): void => {
// Separator blank lines around HTML runs carry no markdown semantics.
while (markdown.length > 0 && isBlank(markdown[markdown.length - 1] ?? '')) markdown.pop()
if (markdown.length === 0) return
segments.push({ kind: 'markdown', text: markdown.join('\n') })
markdown = []
}
const flushHtml = (): void => {
if (html.length === 0) return
segments.push({ kind: 'html', text: html.join('\n') })
html = []
inHtmlRun = false
inComment = false
}
const isBlank = (line: string): boolean => /^[ \t]*$/.test(line)
for (const line of lines) {
if (openFence !== null) {
const close = CLOSE_FENCE_RE.exec(line)
if (close !== null && close[1]!.charAt(0) === openFence.char && close[1]!.length >= openFence.length) {
openFence = null
}
markdown.push(line)
continue
}
const fenceMatch = OPEN_FENCE_RE.exec(line)
if (fenceMatch !== null) {
const fence = fenceMatch[1]!
const rest = line.slice(fenceMatch.index + fenceMatch[0].length)
const info = fenceInfo(rest, fence)
if (info !== null) {
openFence = { char: fence.charAt(0), length: fence.length }
markdown.push(line)
continue
}
// Not a valid fence (backtick info containing backticks): plain text.
markdown.push(line)
continue
}
if (inComment) {
html.push(line)
if (line.includes('-->')) flushHtml()
continue
}
if (inHtmlRun) {
if (isBlank(line)) {
flushHtml()
continue
}
html.push(line)
continue
}
if (COMMENT_START_RE.test(line)) {
flushMarkdown()
html.push(line)
inComment = true
if (line.includes('-->')) flushHtml()
continue
}
const htmlMatch = HTML_BLOCK_START_RE.exec(line)
const tag = htmlMatch?.[1]?.toLowerCase()
if (htmlMatch !== null && tag !== undefined && HTML_BLOCK_TAGS.has(tag)) {
flushMarkdown()
html.push(line)
inHtmlRun = true
continue
}
// Blank lines only survive inside a markdown run (paragraph separators);
// at a run boundary they are separators and are dropped.
if (isBlank(line) && markdown.length === 0) continue
markdown.push(line)
}
flushMarkdown()
flushHtml()
return segments
}
/** A reference definition line: `[label]: destination` (up to 3 spaces indent). */
const REFERENCE_DEF_RE = /^ {0,3}\[((?:[^\][]|\[[^\]]*\])*)\]:\s*(?:<([^<>]*)>|(\S+))/
/**
* Collect the reference definitions of every markdown run (HTML runs cannot
* define them), in document order, newline-joined for appending.
*/
export function collectReferenceDefinitions(segments: readonly MdHtmlSegment[]): string {
const defs: string[] = []
for (const segment of segments) {
if (segment.kind !== 'markdown') continue
for (const line of segment.text.split('\n')) {
if (REFERENCE_DEF_RE.test(line)) defs.push(line)
}
}
return defs.join('\n')
}
/**
* The whole-document gate + split the preview consumes. `hasInlineHtml` is a
* cheap source-level regex (code-fence content may false-positive; the inline
* pass skips rendered code blocks anyway, so a false positive only costs the
* enhanced render path, never a behavior change).
*/
export function analyzeMarkdownHtml(text: string): AnalyzedMarkdownHtml {
const segments = splitHtmlBlocks(text)
return {
segments,
hasBlockHtml: segments.some((segment) => segment.kind === 'html'),
hasInlineHtml: TAGLIKE_RE.test(text),
referenceDefinitions: collectReferenceDefinitions(segments),
}
}
+29 -16
View File
@@ -65,28 +65,41 @@ function normalizeLocalPath(path: string): string {
* so the core rewrite stays pure and unit-testable.
* @returns The markdown with local image destinations rewritten in place.
*/
/**
* Resolve one media destination against the session's media route: local
* (relative or absolute) paths become absolute `/sidebar/file` URLs (prefixed
* with the GUI's own origin so the shared MarkdownText http(s) allowlist
* accepts them), while remote URLs, `#`-anchors and empty destinations are
* returned untouched. Shared by the markdown image rewriter below and by the
* preview's raw-HTML sanitizer (`markdown-html.tsx`, which meets the same
* allowlist when rendering `<img src="./x.png">` inside HTML blocks).
*/
export function resolveLocalMediaDest(
dest: string,
scope: SessionScope,
filePath: string,
origin: string,
): string {
const trimmed = dest.trim()
if (trimmed === '' || trimmed.startsWith('#')) return dest
if (isRemoteUrl(trimmed)) return dest
const slash = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'))
const directory = slash === -1 ? '/' : filePath.slice(0, slash + 1)
const candidate = isAbsolutePath(trimmed) ? trimmed : directory + trimmed
// Mirrors api.ts fileUrl/mediaUrl for the /sidebar/file media route, made
// absolute so the shared MarkdownText http(s) allowlist accepts it.
const params = new URLSearchParams({ sessionId: scope.sessionId, path: normalizeLocalPath(candidate) })
if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
return `${origin}/sidebar/file?${params.toString()}`
}
export function rewriteLocalImageUrls(
text: string,
scope: SessionScope,
filePath: string,
origin: string,
): string {
const slash = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'))
const directory = slash === -1 ? '/' : filePath.slice(0, slash + 1)
const mediaUrl = (candidate: string): string => {
// Mirrors api.ts fileUrl/mediaUrl for the /sidebar/file media route, made
// absolute so the shared MarkdownText http(s) allowlist accepts it.
const params = new URLSearchParams({ sessionId: scope.sessionId, path: candidate })
if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
return `${origin}/sidebar/file?${params.toString()}`
}
const resolve = (dest: string): string => {
const trimmed = dest.trim()
if (trimmed === '' || trimmed.startsWith('#')) return dest
if (isRemoteUrl(trimmed)) return dest
const candidate = isAbsolutePath(trimmed) ? trimmed : directory + trimmed
return mediaUrl(normalizeLocalPath(candidate))
}
const resolve = (dest: string): string => resolveLocalMediaDest(dest, scope, filePath, origin)
// Mask fenced code blocks and inline code spans so image-looking text
// inside documentation examples is never rewritten. The sentinel uses a
+127
View File
@@ -0,0 +1,127 @@
/**
* 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>
)
}
+3 -3
View File
@@ -32,13 +32,13 @@ export interface MermaidMarkdownProps {
}
/** CommonMark opening fence: 0-3 spaces indent + a run of 3+ backticks or tildes. */
const OPEN_FENCE_RE = /^ {0,3}(`{3,}|~{3,})/
export const OPEN_FENCE_RE = /^ {0,3}(`{3,}|~{3,})/
/** A closing-fence line: 0-3 spaces indent + 3+ backticks/tildes + trailing spaces only. */
const CLOSE_FENCE_RE = /^ {0,3}(`{3,}|~{3,})[ \t]*$/
export const CLOSE_FENCE_RE = /^ {0,3}(`{3,}|~{3,})[ \t]*$/
/** Parse the info string from the line tail after the fence run; null when invalid. */
function fenceInfo(rest: string, fence: string): string | null {
export function fenceInfo(rest: string, fence: string): string | null {
const info = rest.trimStart().split(/\s+/)[0] ?? ''
// CommonMark: a backtick fence's info string may not contain backticks —
// such a line is not an opening fence at all.
+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;
}