fix: address review findings (#1895)

This commit is contained in:
Jinjing 2026-05-14 22:05:19 -07:00 committed by GitHub
parent f708e57c50
commit 53150f6022
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 761 additions and 215 deletions

View File

@ -87,12 +87,14 @@
"cmdk": "^1.1.1",
"dompurify": "^3.4.2",
"electron-updater": "^6.8.3",
"entities": "^6.0.1",
"github-slugger": "^2.0.0",
"hosted-git-info": "^9.0.3",
"html-to-image": "^1.11.13",
"katex": "^0.16.45",
"lowlight": "^3.3.0",
"lucide-react": "^0.577.0",
"mdast-util-to-string": "^4.0.0",
"mermaid": "^11.15.0",
"monaco-editor": "^0.55.1",
"node-pty": "^1.1.0",
@ -110,6 +112,7 @@
"remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"remark-parse": "^11.0.0",
"shadcn": "^4.7.0",
"sherpa-onnx": "1.12.37",
"simple-git": "^3.36.0",
@ -118,6 +121,7 @@
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"tweetnacl": "^1.0.3",
"unified": "^11.0.5",
"ws": "^8.20.0",
"zod": "~4.4.3",
"zustand": "^5.0.13"

View File

@ -136,6 +136,9 @@ importers:
electron-updater:
specifier: ^6.8.3
version: 6.8.3
entities:
specifier: ^6.0.1
version: 6.0.1
github-slugger:
specifier: ^2.0.0
version: 2.0.0
@ -154,6 +157,9 @@ importers:
lucide-react:
specifier: ^0.577.0
version: 0.577.0(react@19.2.5)
mdast-util-to-string:
specifier: ^4.0.0
version: 4.0.0
mermaid:
specifier: ^11.15.0
version: 11.15.0
@ -205,6 +211,9 @@ importers:
remark-math:
specifier: ^6.0.0
version: 6.0.0
remark-parse:
specifier: ^11.0.0
version: 11.0.0
shadcn:
specifier: ^4.7.0
version: 4.7.0(@types/node@25.6.0)(typescript@5.9.3)
@ -229,6 +238,9 @@ importers:
tweetnacl:
specifier: ^1.0.3
version: 1.0.3
unified:
specifier: ^11.0.5
version: 11.0.5
ws:
specifier: ^8.20.0
version: 8.20.0

View File

@ -5,6 +5,17 @@
padding: 24px 32px;
font-size: inherit;
line-height: 1.7;
min-width: 0;
flex: 1;
}
.markdown-preview-shell {
position: relative;
display: flex;
min-height: 0;
height: 100%;
min-width: 0;
container-type: inline-size;
}
.markdown-preview:focus-visible {
@ -81,6 +92,113 @@
color: var(--foreground);
}
.markdown-toc-panel {
display: flex;
width: 240px;
min-width: 200px;
max-width: 30%;
flex-shrink: 0;
flex-direction: column;
border-left: 1px solid color-mix(in srgb, var(--border) 72%, transparent);
background: color-mix(in srgb, var(--background) 88%, var(--editor-surface));
}
.markdown-toc-header {
display: flex;
min-height: 40px;
min-width: 0;
align-items: center;
gap: 8px;
border-bottom: 1px solid color-mix(in srgb, var(--border) 72%, transparent);
padding: 0 10px 0 12px;
color: var(--muted-foreground);
font-size: 12px;
font-weight: 600;
}
.markdown-toc-header > svg,
.markdown-toc-header > button {
flex-shrink: 0;
}
.markdown-toc-header > span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.markdown-toc-list {
min-height: 0;
flex: 1;
overflow-y: auto;
padding: 8px;
}
.markdown-toc-row {
display: flex;
width: 100%;
min-width: 0;
align-items: center;
gap: 7px;
border: 0;
border-radius: 6px;
background: transparent;
padding-top: 6px;
padding-right: 8px;
padding-bottom: 6px;
color: var(--foreground);
font-size: 12px;
line-height: 1.25;
text-align: left;
}
.markdown-toc-row:hover {
background: var(--accent);
}
.markdown-toc-level {
flex-shrink: 0;
color: var(--muted-foreground);
font-size: 10px;
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.markdown-toc-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.markdown-toc-empty {
padding: 8px 6px;
color: var(--muted-foreground);
font-size: 12px;
}
@media (max-width: 900px) {
.markdown-toc-panel {
width: 200px;
max-width: 42%;
}
}
@container (max-width: 560px) {
.markdown-toc-panel {
position: absolute;
top: 0;
right: 0;
bottom: 0;
z-index: 30;
width: min(240px, 72cqw);
min-width: 0;
max-width: calc(100cqw - 44px);
box-shadow: -10px 0 24px rgb(0 0 0 / 0.14);
}
}
.markdown-preview-search-match {
padding: 0;
border-radius: 2px;

View File

@ -5,10 +5,22 @@
display: flex;
min-height: 0;
height: 100%;
min-width: 0;
flex: 1;
flex-direction: column;
background: var(--editor-surface);
}
.rich-markdown-editor-layout {
position: relative;
display: flex;
min-height: 0;
height: 100%;
min-width: 0;
background: var(--editor-surface);
container-type: inline-size;
}
.rich-markdown-editor-toolbar {
display: flex;
align-items: center;

View File

@ -83,6 +83,8 @@ export function EditorContent({
mdViewMode,
isChangesMode,
sideBySide,
showMarkdownTableOfContents = false,
onCloseMarkdownTableOfContents = () => {},
pendingEditorReveal,
handleContentChange,
handleDirtyStateHint,
@ -103,6 +105,8 @@ export function EditorContent({
mdViewMode: MarkdownViewMode
isChangesMode: boolean
sideBySide: boolean
showMarkdownTableOfContents?: boolean
onCloseMarkdownTableOfContents?: () => void
pendingEditorReveal: {
filePath?: string
line?: number
@ -239,6 +243,8 @@ export function EditorContent({
onSave={onSaveWithFm}
onOpenDocLink={md.onOpenDocLink}
markdownDocuments={md.markdownDocuments}
showTableOfContents={showMarkdownTableOfContents}
onCloseTableOfContents={onCloseMarkdownTableOfContents}
// Why: render the front-matter banner below the editor toolbar
// (inside the editor shell) so formatting controls remain at
// the top of the pane — the banner is read-only context, not
@ -270,6 +276,8 @@ export function EditorContent({
content={currentContent}
filePath={activeFile.filePath}
scrollCacheKey={`${editorViewStateKey}:preview`}
showTableOfContents={showMarkdownTableOfContents}
onCloseTableOfContents={onCloseMarkdownTableOfContents}
{...md.previewProps}
/>
</div>
@ -357,6 +365,8 @@ export function EditorContent({
filePath={activeFile.filePath}
scrollCacheKey={markdownPreviewViewStateKey}
initialAnchor={activeFile.markdownPreviewAnchor ?? null}
showTableOfContents={showMarkdownTableOfContents}
onCloseTableOfContents={onCloseMarkdownTableOfContents}
{...md.previewProps}
/>
</div>
@ -498,6 +508,8 @@ export function EditorContent({
content={modifiedDiffContent}
filePath={activeFile.filePath}
scrollCacheKey={`${diffViewStateKey}:preview`}
showTableOfContents={showMarkdownTableOfContents}
onCloseTableOfContents={onCloseMarkdownTableOfContents}
{...md.previewProps}
/>
</div>

View File

@ -5,7 +5,16 @@ across multiple components. Autosave now lives in a smaller headless controller
so hidden editor UI no longer participates in shutdown. */
import React, { useCallback, useEffect, useRef, useState, Suspense } from 'react'
import * as monaco from 'monaco-editor'
import { Columns2, Copy, Eye, ExternalLink, FileText, MoreHorizontal, Rows2 } from 'lucide-react'
import {
Columns2,
Copy,
Eye,
ExternalLink,
FileText,
ListTree,
MoreHorizontal,
Rows2
} from 'lucide-react'
import { useAppStore } from '@/store'
import { findWorktreeById } from '@/store/slices/worktree-helpers'
import { getConnectionId } from '@/lib/connection-context'
@ -211,6 +220,7 @@ function EditorPanelInner({
const [copiedPathToast, setCopiedPathToast] = useState<{ fileId: string; token: number } | null>(
null
)
const [showMarkdownTableOfContents, setShowMarkdownTableOfContents] = useState(false)
const [renameDialogFileId, setRenameDialogFileId] = useState<string | null>(null)
const renameDialogFile = renameDialogFileId
? openFiles.find((f) => f.id === renameDialogFileId)
@ -1129,6 +1139,9 @@ function EditorPanelInner({
: hasViewModeToggle
? mdViewMode
: 'edit'
const isMarkdownTableOfContentsDisabled = hasViewModeToggle && mdViewMode === 'source'
const canShowMarkdownTableOfContents =
isMarkdown && (hasViewModeToggle || activeFile.mode === 'markdown-preview')
const canShowMarkdownPreview = canOpenMarkdownPreview({
language: resolvedLanguage,
mode: activeFile.mode,
@ -1312,6 +1325,33 @@ function EditorPanelInner({
}
/>
)}
{canShowMarkdownTableOfContents && (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={`p-1 rounded hover:bg-accent hover:text-foreground transition-colors flex-shrink-0 disabled:opacity-50 disabled:hover:bg-transparent disabled:hover:text-muted-foreground ${
showMarkdownTableOfContents && !isMarkdownTableOfContentsDisabled
? 'bg-accent text-foreground'
: 'text-muted-foreground'
}`}
onClick={() => setShowMarkdownTableOfContents((shown) => !shown)}
disabled={isMarkdownTableOfContentsDisabled}
aria-label="Table of Contents"
aria-pressed={showMarkdownTableOfContents}
>
<ListTree size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{isMarkdownTableOfContentsDisabled
? 'Table of Contents is available in rich or preview mode'
: 'Table of Contents'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{hasViewModeToggle && isMarkdown && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@ -1366,6 +1406,8 @@ function EditorPanelInner({
handleDirtyStateHint={handleDirtyStateHint}
handleSave={handleSave}
reloadFileContent={reloadFileContent}
showMarkdownTableOfContents={showMarkdownTableOfContents}
onCloseMarkdownTableOfContents={() => setShowMarkdownTableOfContents(false)}
/>
</Suspense>
<UntitledFileRenameDialog

View File

@ -10,6 +10,7 @@ import {
type LucideIcon
} from 'lucide-react'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import type { MarkdownViewMode } from '@/store/slices/editor'
// Why: 'changes' is not a MarkdownViewMode in the store — it lives on the
@ -82,39 +83,48 @@ export default function EditorViewToggle({
metadataOverride
}: EditorViewToggleProps): React.JSX.Element {
return (
<ToggleGroup
type="single"
size="sm"
className="h-6 [&_[data-slot=toggle-group-item]]:h-7 [&_[data-slot=toggle-group-item]]:min-w-5 [&_[data-slot=toggle-group-item]]:px-2.5"
variant="outline"
value={value}
onValueChange={(v) => {
if (v) {
onChange(v as EditorToggleValue)
}
}}
>
{modes.map((viewMode) => {
// Why: metadataOverride is keyed by MarkdownViewMode (source/rich/preview)
// because only those slots have language-specific presentation variants
// (e.g. CSV's "Table" label on the 'rich' slot). 'edit'/'changes' are
// orthogonal toggle values and always use the default metadata.
const override = (
metadataOverride as Partial<Record<EditorToggleValue, ViewModeMetadata>> | undefined
)?.[viewMode]
const metadata = override ?? DEFAULT_VIEW_MODE_METADATA[viewMode]
const Icon = metadata.icon
return (
<ToggleGroupItem
key={viewMode}
value={viewMode}
aria-label={metadata.label}
title={metadata.title ?? metadata.label}
>
<Icon className="h-3 w-3" />
</ToggleGroupItem>
)
})}
</ToggleGroup>
<TooltipProvider delayDuration={300}>
<ToggleGroup
type="single"
size="sm"
className="h-6 [&_[data-slot=toggle-group-item]]:h-7 [&_[data-slot=toggle-group-item]]:min-w-5 [&_[data-slot=toggle-group-item]]:px-2.5"
variant="outline"
value={value}
onValueChange={(v) => {
if (v) {
onChange(v as EditorToggleValue)
}
}}
>
{modes.map((viewMode) => {
// Why: metadataOverride is keyed by MarkdownViewMode (source/rich/preview)
// because only those slots have language-specific presentation variants
// (e.g. CSV's "Table" label on the 'rich' slot). 'edit'/'changes' are
// orthogonal toggle values and always use the default metadata.
const override = (
metadataOverride as Partial<Record<EditorToggleValue, ViewModeMetadata>> | undefined
)?.[viewMode]
const metadata = override ?? DEFAULT_VIEW_MODE_METADATA[viewMode]
const Icon = metadata.icon
const tooltipLabel = metadata.title ?? metadata.label
return (
<Tooltip key={viewMode}>
<TooltipTrigger asChild>
<ToggleGroupItem
value={viewMode}
aria-label={metadata.label}
className="data-[state=on]:border-primary data-[state=on]:bg-primary data-[state=on]:text-primary-foreground data-[state=on]:shadow-xs data-[state=on]:hover:bg-primary/90 data-[state=on]:hover:text-primary-foreground"
>
<Icon className="h-3 w-3" />
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{tooltipLabel}
</TooltipContent>
</Tooltip>
)
})}
</ToggleGroup>
</TooltipProvider>
)
}

View File

@ -49,12 +49,16 @@ import {
import { usePreserveSectionDuringExternalEdit } from './usePreserveSectionDuringExternalEdit'
import { openHttpLink } from '@/lib/http-link-routing'
import { markdownPreviewUrlTransform } from './markdown-preview-url-transform'
import { buildMarkdownTableOfContents } from './markdown-table-of-contents'
import { MarkdownTableOfContentsPanel } from './MarkdownTableOfContentsPanel'
type MarkdownPreviewProps = {
content: string
filePath: string
scrollCacheKey: string
initialAnchor?: string | null
showTableOfContents?: boolean
onCloseTableOfContents?: () => void
markdownDocuments?: MarkdownDocument[]
onOpenDocument?: (document: MarkdownDocument) => void | Promise<void>
}
@ -163,6 +167,8 @@ export default function MarkdownPreview({
filePath,
scrollCacheKey,
initialAnchor = null,
showTableOfContents = false,
onCloseTableOfContents,
markdownDocuments = [],
onOpenDocument
}: MarkdownPreviewProps): React.JSX.Element {
@ -194,6 +200,10 @@ export default function MarkdownPreview({
const renderedContent = usePreserveSectionDuringExternalEdit(content, bodyRef)
const frontMatter = useMemo(() => extractFrontMatter(renderedContent), [renderedContent])
const tableOfContentsItems = useMemo(
() => buildMarkdownTableOfContents(renderedContent),
[renderedContent]
)
const markdownDocumentIndex = useMemo(
() => createMarkdownDocumentIndex(markdownDocuments),
[markdownDocuments]
@ -335,6 +345,13 @@ export default function MarkdownPreview({
return true
}, [])
const navigateToTableOfContentsItem = useCallback(
(id: string): void => {
scrollToAnchor(id)
},
[scrollToAnchor]
)
useEffect(() => {
if (isSearchOpen) {
inputRef.current?.focus()
@ -748,125 +765,134 @@ export default function MarkdownPreview({
])
return (
<div
ref={rootRef}
tabIndex={0}
style={{ fontSize: `${editorFontSize}px` }}
className={`markdown-preview h-full min-h-0 overflow-auto scrollbar-editor ${isDark ? 'markdown-dark' : 'markdown-light'}`}
>
{isSearchOpen ? (
<div className="markdown-preview-search" onKeyDown={(event) => event.stopPropagation()}>
<div className="markdown-preview-search-field">
<Input
ref={inputRef}
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && event.shiftKey) {
event.preventDefault()
moveToMatch(-1)
return
}
if (event.key === 'Enter') {
event.preventDefault()
moveToMatch(1)
return
}
if (event.key === 'Escape') {
event.preventDefault()
closeSearch()
rootRef.current?.focus()
}
}}
placeholder="Find in preview"
className="markdown-preview-search-input h-7 !border-0 bg-transparent px-2 shadow-none focus-visible:!border-0 focus-visible:ring-0"
aria-label="Find in markdown preview"
/>
<div className="markdown-preview-shell">
<div
ref={rootRef}
tabIndex={0}
style={{ fontSize: `${editorFontSize}px` }}
className={`markdown-preview h-full min-h-0 overflow-auto scrollbar-editor ${isDark ? 'markdown-dark' : 'markdown-light'}`}
>
{isSearchOpen ? (
<div className="markdown-preview-search" onKeyDown={(event) => event.stopPropagation()}>
<div className="markdown-preview-search-field">
<Input
ref={inputRef}
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && event.shiftKey) {
event.preventDefault()
moveToMatch(-1)
return
}
if (event.key === 'Enter') {
event.preventDefault()
moveToMatch(1)
return
}
if (event.key === 'Escape') {
event.preventDefault()
closeSearch()
rootRef.current?.focus()
}
}}
placeholder="Find in preview"
className="markdown-preview-search-input h-7 !border-0 bg-transparent px-2 shadow-none focus-visible:!border-0 focus-visible:ring-0"
aria-label="Find in markdown preview"
/>
</div>
<div className="markdown-preview-search-status">
{query && matchCount === 0
? 'No results'
: `${matchCount === 0 ? 0 : activeMatchIndex + 1}/${matchCount}`}
</div>
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={() => moveToMatch(-1)}
disabled={matchCount === 0}
title="Previous match"
aria-label="Previous match"
className="markdown-preview-search-button"
>
<ChevronUp size={14} />
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={() => moveToMatch(1)}
disabled={matchCount === 0}
title="Next match"
aria-label="Next match"
className="markdown-preview-search-button"
>
<ChevronDown size={14} />
</Button>
<div className="markdown-preview-search-divider" />
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={closeSearch}
title="Close search"
aria-label="Close search"
className="markdown-preview-search-button"
>
<X size={14} />
</Button>
</div>
<div className="markdown-preview-search-status">
{query && matchCount === 0
? 'No results'
: `${matchCount === 0 ? 0 : activeMatchIndex + 1}/${matchCount}`}
</div>
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={() => moveToMatch(-1)}
disabled={matchCount === 0}
title="Previous match"
aria-label="Previous match"
className="markdown-preview-search-button"
>
<ChevronUp size={14} />
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={() => moveToMatch(1)}
disabled={matchCount === 0}
title="Next match"
aria-label="Next match"
className="markdown-preview-search-button"
>
<ChevronDown size={14} />
</Button>
<div className="markdown-preview-search-divider" />
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={closeSearch}
title="Close search"
aria-label="Close search"
className="markdown-preview-search-button"
>
<X size={14} />
</Button>
</div>
) : null}
<div ref={bodyRef} className="markdown-body">
{/* Why: remarkFrontmatter silently strips front-matter from rendered
) : null}
<div ref={bodyRef} className="markdown-body">
{/* Why: remarkFrontmatter silently strips front-matter from rendered
output. We extract it ourselves and render it as a styled code block so
the user can see the metadata in preview mode. */}
{frontMatter && (
<div className="mb-4 rounded border border-border/60 bg-muted/40 px-3 py-2">
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Front Matter
{frontMatter && (
<div className="mb-4 rounded border border-border/60 bg-muted/40 px-3 py-2">
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Front Matter
</div>
<pre className="max-h-48 overflow-auto whitespace-pre-wrap text-xs text-muted-foreground font-mono scrollbar-editor">
{frontMatterInner}
</pre>
</div>
<pre className="max-h-48 overflow-auto whitespace-pre-wrap text-xs text-muted-foreground font-mono scrollbar-editor">
{frontMatterInner}
</pre>
</div>
)}
<Markdown
components={components}
// Why: react-markdown filters file:// after rehype-sanitize; preview
// click handlers need the target so they can authorize and open it.
urlTransform={markdownPreviewUrlTransform}
remarkPlugins={[
remarkGfm,
remarkBreaks,
remarkFrontmatter,
remarkMath,
remarkMarkdownDocLinks
]}
// Why: raw HTML must be sanitized before any trusted renderer expands
// it into richer DOM. Running KaTeX and syntax highlighting after
// sanitize preserves VS Code-style math/code rendering without having
// to whitelist KaTeX's generated markup in the user-content schema.
rehypePlugins={[
rehypeRaw,
[rehypeSanitize, markdownPreviewSanitizeSchema],
rehypeSlug,
rehypeHighlight,
rehypeKatex
]}
>
{renderedContent}
</Markdown>
)}
<Markdown
components={components}
// Why: react-markdown filters file:// after rehype-sanitize; preview
// click handlers need the target so they can authorize and open it.
urlTransform={markdownPreviewUrlTransform}
remarkPlugins={[
remarkGfm,
remarkBreaks,
remarkFrontmatter,
remarkMath,
remarkMarkdownDocLinks
]}
// Why: raw HTML must be sanitized before any trusted renderer expands
// it into richer DOM. Running KaTeX and syntax highlighting after
// sanitize preserves VS Code-style math/code rendering without having
// to whitelist KaTeX's generated markup in the user-content schema.
rehypePlugins={[
rehypeRaw,
[rehypeSanitize, markdownPreviewSanitizeSchema],
rehypeSlug,
rehypeHighlight,
rehypeKatex
]}
>
{renderedContent}
</Markdown>
</div>
</div>
{showTableOfContents ? (
<MarkdownTableOfContentsPanel
items={tableOfContentsItems}
onClose={onCloseTableOfContents ?? (() => {})}
onNavigate={navigateToTableOfContentsItem}
/>
) : null}
</div>
)
}

View File

@ -0,0 +1,73 @@
import React from 'react'
import { ListTree, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import type { MarkdownTocItem } from './markdown-table-of-contents'
type MarkdownTableOfContentsPanelProps = {
items: MarkdownTocItem[]
onClose: () => void
onNavigate: (id: string) => void
}
function MarkdownTocRow({
depth,
item,
onNavigate
}: {
depth: number
item: MarkdownTocItem
onNavigate: (id: string) => void
}): React.JSX.Element {
return (
<>
<button
type="button"
className="markdown-toc-row"
style={{ paddingLeft: 12 + depth * 14 }}
title={item.title}
onClick={() => onNavigate(item.id)}
>
<span className="markdown-toc-level">H{item.level}</span>
<span className="markdown-toc-title">{item.title}</span>
</button>
{item.children.map((child) => (
<MarkdownTocRow key={child.id} depth={depth + 1} item={child} onNavigate={onNavigate} />
))}
</>
)
}
export function MarkdownTableOfContentsPanel({
items,
onClose,
onNavigate
}: MarkdownTableOfContentsPanelProps): React.JSX.Element {
return (
<aside className="markdown-toc-panel" aria-label="Table of contents">
<div className="markdown-toc-header">
<ListTree className="size-3.5 text-muted-foreground" />
<span>Table of Contents</span>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="ml-auto"
aria-label="Close table of contents"
title="Close table of contents"
onClick={onClose}
>
<X className="size-3.5" />
</Button>
</div>
<div className="markdown-toc-list">
{items.length > 0 ? (
items.map((item) => (
<MarkdownTocRow key={item.id} depth={0} item={item} onNavigate={onNavigate} />
))
) : (
<div className="markdown-toc-empty">No headings</div>
)}
</div>
</aside>
)
}

View File

@ -45,6 +45,8 @@ import type {
RichMarkdownContextMenuCommand,
RichMarkdownContextMenuCommandPayload
} from '../../../../shared/rich-markdown-context-menu'
import { buildMarkdownTableOfContents, type MarkdownTocItem } from './markdown-table-of-contents'
import { MarkdownTableOfContentsPanel } from './MarkdownTableOfContentsPanel'
type RichMarkdownEditorProps = {
fileId: string
@ -57,6 +59,8 @@ type RichMarkdownEditorProps = {
onSave: (content: string) => void
onOpenDocLink?: (target: string) => void
markdownDocuments?: MarkdownDocument[]
showTableOfContents?: boolean
onCloseTableOfContents?: () => void
// Why: front-matter is stripped from the rich editor's content but we still
// want it visible to the user. It renders between the toolbar and the editor
// surface so the formatting toolbar stays at the top of the pane.
@ -154,6 +158,10 @@ function isRichMarkdownContextCommandTarget(
)
}
function flattenMarkdownTocItems(items: MarkdownTocItem[]): MarkdownTocItem[] {
return items.flatMap((item) => [item, ...flattenMarkdownTocItems(item.children)])
}
export default function RichMarkdownEditor({
fileId,
content,
@ -165,6 +173,8 @@ export default function RichMarkdownEditor({
onSave,
onOpenDocLink,
markdownDocuments,
showTableOfContents = false,
onCloseTableOfContents,
headerSlot
}: RichMarkdownEditorProps): React.JSX.Element {
const rootRef = useRef<HTMLDivElement | null>(null)
@ -215,6 +225,11 @@ export default function RichMarkdownEditor({
const [isEditingLink, setIsEditingLink] = useState(false)
const isEditingLinkRef = useRef(false)
const typedEmptyOrderedListMarkerRef = useRef(false)
const tableOfContentsItems = useMemo(() => buildMarkdownTableOfContents(content), [content])
const flatTableOfContentsItems = useMemo(
() => flattenMarkdownTocItems(tableOfContentsItems),
[tableOfContentsItems]
)
// Why: assigning callback refs during render keeps them current before any
// ProseMirror handler reads them, avoiding the one-render stale window that
@ -566,6 +581,25 @@ export default function RichMarkdownEditor({
openSearchRef.current = openSearch
}, [openSearch])
const navigateToTableOfContentsItem = useCallback(
(id: string): void => {
const target = flatTableOfContentsItems.find((item) => item.id === id)
const container = scrollContainerRef.current
if (!target || !container) {
return
}
const sameTitleIndex = flatTableOfContentsItems
.filter((item) => item.title === target.title)
.findIndex((item) => item.id === target.id)
const matchingHeadings = Array.from(
container.querySelectorAll<HTMLElement>('h1, h2, h3')
).filter((candidate) => candidate.textContent?.trim() === target.title)
const heading = matchingHeadings.at(Math.max(0, sameTitleIndex))
heading?.scrollIntoView({ block: 'center' })
},
[flatTableOfContentsItems]
)
const filteredSlashCommands = useMemo(() => {
const query = slashMenu?.query.trim().toLowerCase() ?? ''
if (!query) {
@ -712,75 +746,84 @@ export default function RichMarkdownEditor({
}, [content, editor, fileId])
return (
<div
ref={rootRef}
className="rich-markdown-editor-shell"
style={{ '--editor-font-zoom-level': editorFontZoomLevel } as React.CSSProperties}
>
<RichMarkdownToolbar
editor={editor}
onToggleLink={toggleLinkFromToolbar}
onImagePick={handleLocalImagePick}
/>
{headerSlot}
{/* Why: wrap scroll area + search bar in a relative container so the
search bar overlays the content (Monaco-style) instead of occupying
layout space and shifting the document down when opened. */}
<div className="relative min-h-0 flex-1">
<div
ref={scrollContainerRef}
className="h-full overflow-auto scrollbar-editor"
onMouseDown={(event) => {
if (!shouldFocusEmptyEditorFromSurfaceClick(event, editorRef.current)) {
return
}
// Why: native contenteditable only places the caret on actual line
// boxes; an empty note should still focus when the user clicks any
// blank part of the document surface.
event.preventDefault()
editorRef.current?.commands.focus('start')
}}
>
<EditorContent editor={editor} />
</div>
<RichMarkdownSearchBar
activeMatchIndex={activeMatchIndex}
isOpen={isSearchOpen}
matchCount={matchCount}
onClose={closeSearch}
onMoveToMatch={moveToMatch}
onQueryChange={setSearchQuery}
query={searchQuery}
searchInputRef={searchInputRef}
/>
</div>
{linkBubble ? (
<RichMarkdownLinkBubble
linkBubble={linkBubble}
isEditing={isEditingLink}
onSave={handleLinkSave}
onRemove={handleLinkRemove}
onEditStart={() => setIsEditingLink(true)}
onEditCancel={handleLinkEditCancel}
onOpen={handleLinkOpen}
/>
) : null}
{slashMenu && filteredSlashCommands.length > 0 ? (
<RichMarkdownSlashMenu
<div className="rich-markdown-editor-layout">
<div
ref={rootRef}
className="rich-markdown-editor-shell"
style={{ '--editor-font-zoom-level': editorFontZoomLevel } as React.CSSProperties}
>
<RichMarkdownToolbar
editor={editor}
slashMenu={slashMenu}
filteredCommands={filteredSlashCommands}
selectedIndex={selectedCommandIndex}
onToggleLink={toggleLinkFromToolbar}
onImagePick={handleLocalImagePick}
/>
) : null}
{docLinkMenu ? (
<RichMarkdownDocLinkMenu
editor={editor}
menu={docLinkMenu}
rows={docLinkRows}
totalMatches={docLinkTotalMatches}
selectedIndex={selectedDocLinkIndex}
{headerSlot}
{/* Why: wrap scroll area + search bar in a relative container so the
search bar overlays the content (Monaco-style) instead of occupying
layout space and shifting the document down when opened. */}
<div className="relative min-h-0 flex-1">
<div
ref={scrollContainerRef}
className="h-full overflow-auto scrollbar-editor"
onMouseDown={(event) => {
if (!shouldFocusEmptyEditorFromSurfaceClick(event, editorRef.current)) {
return
}
// Why: native contenteditable only places the caret on actual line
// boxes; an empty note should still focus when the user clicks any
// blank part of the document surface.
event.preventDefault()
editorRef.current?.commands.focus('start')
}}
>
<EditorContent editor={editor} />
</div>
<RichMarkdownSearchBar
activeMatchIndex={activeMatchIndex}
isOpen={isSearchOpen}
matchCount={matchCount}
onClose={closeSearch}
onMoveToMatch={moveToMatch}
onQueryChange={setSearchQuery}
query={searchQuery}
searchInputRef={searchInputRef}
/>
</div>
{linkBubble ? (
<RichMarkdownLinkBubble
linkBubble={linkBubble}
isEditing={isEditingLink}
onSave={handleLinkSave}
onRemove={handleLinkRemove}
onEditStart={() => setIsEditingLink(true)}
onEditCancel={handleLinkEditCancel}
onOpen={handleLinkOpen}
/>
) : null}
{slashMenu && filteredSlashCommands.length > 0 ? (
<RichMarkdownSlashMenu
editor={editor}
slashMenu={slashMenu}
filteredCommands={filteredSlashCommands}
selectedIndex={selectedCommandIndex}
onImagePick={handleLocalImagePick}
/>
) : null}
{docLinkMenu ? (
<RichMarkdownDocLinkMenu
editor={editor}
menu={docLinkMenu}
rows={docLinkRows}
totalMatches={docLinkTotalMatches}
selectedIndex={selectedDocLinkIndex}
/>
) : null}
</div>
{showTableOfContents ? (
<MarkdownTableOfContentsPanel
items={tableOfContentsItems}
onClose={onCloseTableOfContents ?? (() => {})}
onNavigate={navigateToTableOfContentsItem}
/>
) : null}
</div>

View File

@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest'
import {
buildMarkdownTableOfContents,
stripInlineMarkdownForToc
} from './markdown-table-of-contents'
describe('markdown table of contents', () => {
it('builds a nested h1-h3 outline', () => {
const toc = buildMarkdownTableOfContents('# Intro\n\n## Setup\n\n### Install\n\n## Usage')
expect(toc).toEqual([
{
id: 'intro',
level: 1,
title: 'Intro',
children: [
{
id: 'setup',
level: 2,
title: 'Setup',
children: [
{
id: 'install',
level: 3,
title: 'Install',
children: []
}
]
},
{
id: 'usage',
level: 2,
title: 'Usage',
children: []
}
]
}
])
})
it('skips front matter and unsupported heading depths', () => {
const toc = buildMarkdownTableOfContents('---\ntitle: Doc\n---\n# Visible\n#### Hidden')
expect(toc.map((item) => item.title)).toEqual(['Visible'])
})
it('skips headings inside fenced code blocks', () => {
const toc = buildMarkdownTableOfContents('# Install\n\n```sh\n# not a heading\n```\n\n## Real')
expect(toc[0].children.map((item) => item.title)).toEqual(['Real'])
})
it('includes rendered markdown heading forms', () => {
const toc = buildMarkdownTableOfContents(
'# Intro\n\n ## Indented\n\nSetext *Title*\n---\n\n### https://example.com'
)
expect(toc[0].children).toEqual([
{
id: 'indented',
level: 2,
title: 'Indented',
children: []
},
{
id: 'setext-title',
level: 2,
title: 'Setext Title',
children: [
{
id: 'httpsexamplecom',
level: 3,
title: 'https://example.com',
children: []
}
]
}
])
})
it('uses GitHub-compatible duplicate slugs', () => {
const toc = buildMarkdownTableOfContents('# Repeat\n# Repeat')
expect(toc.map((item) => item.id)).toEqual(['repeat', 'repeat-1'])
})
it('decodes HTML entities before slugging headings', () => {
const toc = buildMarkdownTableOfContents('# A &amp; B')
expect(toc[0]).toMatchObject({
id: 'a--b',
title: 'A & B'
})
})
it('strips inline markdown from labels', () => {
expect(stripInlineMarkdownForToc('Use **bold** [links](./x) and [[docs|Docs]]')).toBe(
'Use bold links and Docs'
)
})
})

View File

@ -0,0 +1,93 @@
import GithubSlugger from 'github-slugger'
import { decodeHTML } from 'entities'
import { toString } from 'mdast-util-to-string'
import remarkFrontmatter from 'remark-frontmatter'
import remarkGfm from 'remark-gfm'
import remarkParse from 'remark-parse'
import { unified } from 'unified'
export type MarkdownTocLevel = 1 | 2 | 3
export type MarkdownTocItem = {
children: MarkdownTocItem[]
id: string
level: MarkdownTocLevel
title: string
}
function isMarkdownTocLevel(value: number): value is MarkdownTocLevel {
return value === 1 || value === 2 || value === 3
}
export function stripInlineMarkdownForToc(text: string): string {
return decodeHTML(text)
.replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/\[\[[^|\]]+\|([^\]]+)\]\]/g, '$1')
.replace(/\[\[([^\]]+)\]\]/g, '$1')
.replace(/<[^>]+>/g, '')
.replace(/[*_`~]/g, '')
.replace(/\s+/g, ' ')
.trim()
}
function nearestParent(stack: MarkdownTocItem[], level: MarkdownTocLevel): MarkdownTocItem {
for (let index = stack.length - 1; index >= 0; index -= 1) {
const item = stack.at(index)
if (item && item.level < level) {
return item
}
}
return stack[0]
}
function appendTocItem(stack: MarkdownTocItem[], item: MarkdownTocItem): void {
nearestParent(stack, item.level).children.push(item)
Reflect.set(stack, item.level, item)
stack.length = item.level + 1
}
type MarkdownAstNode = {
children?: MarkdownAstNode[]
depth?: number
type?: string
}
export function buildMarkdownTableOfContents(markdown: string): MarkdownTocItem[] {
const slugger = new GithubSlugger()
const root: MarkdownTocItem = { id: 'toc-root', level: 1, title: '', children: [] }
const stack: MarkdownTocItem[] = [root]
// Why: the TOC must produce the same heading text/ids as react-markdown plus
// rehype-slug; parsing Markdown avoids drift on setext, GFM, and entities.
const tree = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkFrontmatter, ['yaml', 'toml'])
.parse(markdown) as MarkdownAstNode
function visit(node: MarkdownAstNode): void {
if (
node.type === 'heading' &&
typeof node.depth === 'number' &&
isMarkdownTocLevel(node.depth)
) {
const title = toString(node).replace(/\s+/g, ' ').trim()
if (title) {
appendTocItem(stack, {
children: [],
id: slugger.slug(title),
level: node.depth,
title
})
}
}
for (const child of node.children ?? []) {
visit(child)
}
}
visit(tree)
return root.children
}