fix: address review findings (#1895)
This commit is contained in:
parent
f708e57c50
commit
53150f6022
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 & 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue