diff --git a/docs/plans/2026-08-24-markdown-html-toc-design.md b/docs/plans/2026-08-24-markdown-html-toc-design.md
index b74ebef..a1dbfae 100644
--- a/docs/plans/2026-08-24-markdown-html-toc-design.md
+++ b/docs/plans/2026-08-24-markdown-html-toc-design.md
@@ -50,3 +50,9 @@ DSH 宿主的 `MarkdownText` 出于聊天安全把 raw HTML 按字面文本渲
- GFM 脚注跨段不保(宿主本就把脚注渲染为纯文本);`srcset` 不重写(实际只有远程 URL);HTML 实体不解码;标题锚点 permalink、TOC 滚动联动高亮不做;Sidechat 不动;不换渲染器。
- CommonMark HTML 块类型 1(script/pre/style/textarea 按闭标签终止)与类型 7(单行完整标签)不做专门处理——块级名单 + 空行终止已覆盖 README 级真实用法。
+
+## 5. 后续修正记录(bug 反馈)
+
+- **代码块表头在预览中悬浮过长**:DSH `CodeBlock` 的 `bannerWrap` 是 `position: sticky; top: 0; z-index: 6`——在聊天里每个消息块拥有自己的滚动容器,表头粘住是对的;但在文件预览中所有 fence 共享一个 `.editorMd` 滚动容器,表头会钉在视口顶部直到整个块滚过,读作悬浮在文档上。修正:预览容器内对非 mermaid 的 `.md-code-block` 首子元素(即 `bannerWrap`)恢复 `position: static; z-index: auto`(mermaid 块已被 `data-mermaid-processed` 排除,其自身 chrome 取代了表头)。见 `src/client/sidebar.module.css` `.editorMd :global(.md-code-block:not([data-mermaid-processed])) > :global(div):first-child`。
+- **TOC 面板被代码块表头压住**:`.tocBar` 原 `z-index: 3`,低于代码块表头的 `6`,弹出面板(位于 bar 的层叠上下文内)会被 sticky 表头盖住。修正:`.tocBar` 提升到 `7`(仍低于传送门选择弹窗的 `60` 与 mermaid 模态的 `1000`)。
+- **TOC 只能再点按钮收起**:新增文档级 `pointerdown` 监听,点击落在按钮与面板之外(预览空白处)即收起;按钮/面板豁免,保证按钮仍可翻转。见 `src/client/md-toc.tsx`(与 Esc 关闭共用同一 effect)。
diff --git a/src/client/md-toc.tsx b/src/client/md-toc.tsx
index 4f03b42..11c6b47 100644
--- a/src/client/md-toc.tsx
+++ b/src/client/md-toc.tsx
@@ -68,13 +68,30 @@ export function MdToc(): ReactNode {
// 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).
+ // A document pointerdown also closes it when the click lands outside the
+ // button and the panel, so clicking blank preview space dismisses the
+ // outline instead of requiring a second click on the toggle. The check runs
+ // at pointerdown (before the button's own click toggles), and the button and
+ // panel are exempt so a re-click on the toggle keeps working as a toggle
+ // rather than being eaten by the dismiss.
useLayoutEffect(() => {
if (!open) return
const onKey = (event: KeyboardEvent): void => {
if (event.key === 'Escape') setOpen(false)
}
+ const onPointerDown = (event: Event): void => {
+ const target = event.target
+ if (!(target instanceof Node)) return
+ const bar = barRef.current
+ if (bar !== null && bar.contains(target)) return
+ setOpen(false)
+ }
document.addEventListener('keydown', onKey)
- return () => { document.removeEventListener('keydown', onKey) }
+ document.addEventListener('pointerdown', onPointerDown)
+ return () => {
+ document.removeEventListener('keydown', onKey)
+ document.removeEventListener('pointerdown', onPointerDown)
+ }
}, [open])
const jump = (entry: TocEntry): void => {
diff --git a/src/client/sidebar.module.css b/src/client/sidebar.module.css
index 85426dd..e2c9b4d 100644
--- a/src/client/sidebar.module.css
+++ b/src/client/sidebar.module.css
@@ -1813,6 +1813,19 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
font: var(--dsw-font-xs-13);
}
+/* The DSH CodeBlock banner is position:sticky (top:0, z-index:6) so a block
+ keeps its copy action reachable inside a per-message scroll container —
+ the right call in chat, where each block owns its scroll box. In the file
+ preview every fence shares one `.editorMd` scroll container, so that sticky
+ banner pins to the top of the viewport until the *whole block* scrolls
+ past, which reads as a floating header over the document. Neutralize the
+ stickiness here (bannerWrap is the first element child of `.md-code-block`;
+ mermaid mounts are excluded since their own chrome replaces the banner). */
+.editorMd :global(.md-code-block:not([data-mermaid-processed])) > :global(div):first-child {
+ position: static;
+ z-index: auto;
+}
+
/* ── Mermaid diagram block (markdown preview, mermaid lazy chunk) ────────
Mirrors the md-code-block chrome the DSH CodeBlock renders, so a diagram
reads as a peer of the surrounding fences: hairline border, layer-2
@@ -2470,11 +2483,15 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
/* 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). */
+ itself (only the interactive children re-enable them). The bar's stacking
+ context also frames the outline panel, so its z-index must clear the DSH
+ CodeBlock banner (z-index:6) or the popover paints beneath a sticky fence
+ header. 7 sits just above that banner and below the portalled selection
+ popup (60) / mermaid modal (1000). */
.tocBar {
position: sticky;
top: 0;
- z-index: 3;
+ z-index: 7;
display: flex;
justify-content: flex-end;
height: 0;
diff --git a/tests/md-toc.spec.tsx b/tests/md-toc.spec.tsx
index 0216ce8..e0a0bb8 100644
--- a/tests/md-toc.spec.tsx
+++ b/tests/md-toc.spec.tsx
@@ -149,4 +149,44 @@ describe('MdToc', () => {
expect(container.querySelector('[data-dsh-md-toc-panel]')).toBeNull()
await unmount(root)
})
+
+ it('closes the popover when a pointerdown lands outside the button and panel', async () => {
+ const { container, root } = await mountToc([
+ { tag: 'h1', text: 'Title' },
+ { tag: 'h2', text: 'A' },
+ { tag: 'h2', text: 'B' },
+ ])
+ const button = container.querySelector('[data-dsh-md-toc]')!
+ await act(async () => { button.click() })
+ expect(container.querySelector('[data-dsh-md-toc-panel]')).not.toBeNull()
+ // A press on a heading that sits in the scroll container (a sibling of the
+ // TOC bar) is "blank space" to the outline: it must dismiss the popover.
+ const heading = container.querySelector('h2')!
+ await act(async () => {
+ heading.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
+ })
+ expect(container.querySelector('[data-dsh-md-toc-panel]'), 'outside press must close the panel').toBeNull()
+ await unmount(root)
+ })
+
+ it('keeps the toggle working: a press inside the bar does not dismiss, a click re-closes', async () => {
+ const { container, root } = await mountToc([
+ { tag: 'h1', text: 'Title' },
+ { tag: 'h2', text: 'A' },
+ { tag: 'h2', text: 'B' },
+ ])
+ const button = container.querySelector('[data-dsh-md-toc]')!
+ await act(async () => { button.click() })
+ expect(container.querySelector('[data-dsh-md-toc-panel]')).not.toBeNull()
+ // A pointerdown on the toggle itself must not be swallowed by the dismiss
+ // handler (the button re-enables pointer events inside the pointer-events:
+ // none bar), so the subsequent click still toggles it closed.
+ await act(async () => {
+ button.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
+ })
+ expect(container.querySelector('[data-dsh-md-toc-panel]'), 'press on the toggle must not dismiss').not.toBeNull()
+ await act(async () => { button.click() })
+ expect(container.querySelector('[data-dsh-md-toc-panel]'), 're-clicking the toggle must close').toBeNull()
+ await unmount(root)
+ })
})