From d7721e00abfa310bb33b078a7ef44326d0a8069c Mon Sep 17 00:00:00 2001 From: Choo Date: Thu, 25 Jun 2026 18:21:07 +0900 Subject: [PATCH] feat: add heading breadcrumb bar and TOC panel to editor Shows current heading hierarchy (H1 > H2 > H3) in a bar above the editor in bold orange. Clicking opens a tree-structured TOC panel where each node can be independently folded/unfolded; clicking a heading navigates the editor to that line. Co-Authored-By: Claude Sonnet 4.6 --- src/app.css | 1 + src/app.tsx | 6 +- src/components/editor/editor-pane.tsx | 30 +++ src/components/editor/editor.tsx | 10 +- src/components/editor/heading-breadcrumb.tsx | 235 +++++++++++++++++++ src/components/editor/index.ts | 1 + src/styles/editor/heading-bar.css | 208 ++++++++++++++++ 7 files changed, 487 insertions(+), 4 deletions(-) create mode 100644 src/components/editor/editor-pane.tsx create mode 100644 src/components/editor/heading-breadcrumb.tsx create mode 100644 src/styles/editor/heading-bar.css diff --git a/src/app.css b/src/app.css index c68df99..414f39b 100644 --- a/src/app.css +++ b/src/app.css @@ -6,6 +6,7 @@ @import "./styles/chrome/breadcrumb.css"; @import "./styles/chrome/statusbar.css"; @import "./styles/editor/panes.css"; +@import "./styles/editor/heading-bar.css"; @import "./styles/editor/prose.css"; @import "./styles/editor/reading-find.css"; @import "./styles/files/sidebar.css"; diff --git a/src/app.tsx b/src/app.tsx index 4508166..1e560fc 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; import type { EditorView } from "@codemirror/view"; import { Breadcrumb, StatusBar, TitleBar, type VimMode } from "@/components/chrome"; -import { Editor, OpenTabs, Preview, ReadingFind, Splitter } from "@/components/editor"; +import { EditorPane, OpenTabs, Preview, ReadingFind, Splitter } from "@/components/editor"; import { ContextMenu, Sidebar, type ContextMenuItem } from "@/components/files"; import { AboutOverlay, CommandPalette, DropOverlay, HelpOverlay, Toast, WelcomeOverlay } from "@/components/overlays"; import { TooltipRoot } from "@/components/primitives"; @@ -985,11 +985,11 @@ export function App() { /> {editorOnly ? (
- +
) : ( } + left={} right={} /> )} diff --git a/src/components/editor/editor-pane.tsx b/src/components/editor/editor-pane.tsx new file mode 100644 index 0000000..deae9f4 --- /dev/null +++ b/src/components/editor/editor-pane.tsx @@ -0,0 +1,30 @@ +import { useState, type RefObject } from "react"; +import { EditorView } from "@codemirror/view"; +import { Editor } from "./editor"; +import { HeadingBreadcrumb } from "./heading-breadcrumb"; + +type EditorPaneProps = { + value: string; + onChange: (next: string) => void; + vimOn?: boolean; + onVimMode?: (mode: "normal" | "insert" | "visual" | "replace" | null) => void; + viewRef: RefObject; +}; + +export function EditorPane({ value, onChange, vimOn, onVimMode, viewRef }: EditorPaneProps) { + const [cursorLine, setCursorLine] = useState(1); + + return ( +
+ + +
+ ); +} diff --git a/src/components/editor/editor.tsx b/src/components/editor/editor.tsx index 91fcc79..b9add56 100644 --- a/src/components/editor/editor.tsx +++ b/src/components/editor/editor.tsx @@ -34,6 +34,8 @@ type EditorProps = { onVimMode?: (mode: "normal" | "insert" | "visual" | "replace" | null) => void; /** shared ref populated with the EditorView once it mounts */ viewRef?: RefObject; + /** fired with the 1-based line number whenever the cursor moves */ + onCursorLine?: (line: number) => void; }; function buildTheme() { @@ -87,11 +89,13 @@ function buildTheme() { ); } -export function Editor({ value, onChange, vimOn = false, onVimMode, viewRef: externalViewRef }: EditorProps) { +export function Editor({ value, onChange, vimOn = false, onVimMode, viewRef: externalViewRef, onCursorLine }: EditorProps) { const hostRef = useRef(null); const viewRef = useRef(null); const onChangeRef = useRef(onChange); onChangeRef.current = onChange; + const onCursorLineRef = useRef(onCursorLine); + onCursorLineRef.current = onCursorLine; // Compartment lets us swap the vim extension at runtime without rebuilding // the EditorState (preserves doc, history, selection, undo stack). const vimCompartment = useRef(new Compartment()); @@ -119,6 +123,10 @@ export function Editor({ value, onChange, vimOn = false, onVimMode, viewRef: ext if (update.docChanged) { onChangeRef.current(update.state.doc.toString()); } + if (update.selectionSet || update.docChanged) { + const line = update.state.doc.lineAt(update.state.selection.main.head).number; + onCursorLineRef.current?.(line); + } }), ], }); diff --git a/src/components/editor/heading-breadcrumb.tsx b/src/components/editor/heading-breadcrumb.tsx new file mode 100644 index 0000000..a9c4f53 --- /dev/null +++ b/src/components/editor/heading-breadcrumb.tsx @@ -0,0 +1,235 @@ +import { useState, useMemo, useRef, useEffect, type RefObject } from "react"; +import { ChevronRight, ChevronDown } from "lucide-react"; +import { EditorView } from "@codemirror/view"; + +interface Heading { + level: number; + text: string; + line: number; +} + +interface HeadingNode { + heading: Heading; + children: HeadingNode[]; +} + +function parseHeadings(source: string): Heading[] { + const lines = source.split("\n"); + const result: Heading[] = []; + let inFence = false; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (/^```/.test(line)) { inFence = !inFence; continue; } + if (inFence) continue; + const m = line.match(/^(#{1,6})\s+(.+)/); + if (m) result.push({ level: m[1].length, text: m[2].trim(), line: i + 1 }); + } + return result; +} + +function buildTree(headings: Heading[]): HeadingNode[] { + const roots: HeadingNode[] = []; + const stack: HeadingNode[] = []; + for (const h of headings) { + const node: HeadingNode = { heading: h, children: [] }; + while (stack.length > 0 && stack[stack.length - 1].heading.level >= h.level) { + stack.pop(); + } + if (stack.length === 0) { + roots.push(node); + } else { + stack[stack.length - 1].children.push(node); + } + stack.push(node); + } + return roots; +} + +function getCurrentPath(headings: Heading[], cursorLine: number): Heading[] { + const path: Heading[] = []; + for (const h of headings) { + if (h.line > cursorLine) break; + while (path.length > 0 && path[path.length - 1].level >= h.level) path.pop(); + path.push(h); + } + return path; +} + +function trunc(text: string, max = 15): string { + return text.length > max ? text.slice(0, max) + "…" : text; +} + +type TocNodeProps = { + node: HeadingNode; + activeLine: number; + ancestorLines: Set; + collapsed: Set; + onToggleCollapse: (line: number) => void; + onJump: (line: number) => void; + activeRef: RefObject; +}; + +function TocNode({ node, activeLine, ancestorLines, collapsed, onToggleCollapse, onJump, activeRef }: TocNodeProps) { + const { heading, children } = node; + const isActive = heading.line === activeLine; + const isAncestor = ancestorLines.has(heading.line); + const hasChildren = children.length > 0; + const isCollapsed = collapsed.has(heading.line); + + return ( +
  • +
    + + +
    + {hasChildren && !isCollapsed && ( +
      + {children.map((child) => ( + + ))} +
    + )} +
  • + ); +} + +type Props = { + source: string; + cursorLine: number; + viewRef: RefObject; +}; + +export function HeadingBreadcrumb({ source, cursorLine, viewRef }: Props) { + const [tocOpen, setTocOpen] = useState(false); + const [collapsed, setCollapsed] = useState>(new Set()); + const rootRef = useRef(null); + const activeRef = useRef(null); + + const headings = useMemo(() => parseHeadings(source), [source]); + const tree = useMemo(() => buildTree(headings), [headings]); + const path = useMemo(() => getCurrentPath(headings, cursorLine), [headings, cursorLine]); + const ancestorLines = useMemo(() => new Set(path.map((h) => h.line)), [path]); + + // scroll active item into view when TOC opens + useEffect(() => { + if (tocOpen && activeRef.current) { + activeRef.current.scrollIntoView({ block: "nearest" }); + } + }, [tocOpen]); + + // close on outside click + useEffect(() => { + if (!tocOpen) return; + const onDown = (e: MouseEvent) => { + if (rootRef.current && !rootRef.current.contains(e.target as Node)) { + setTocOpen(false); + } + }; + document.addEventListener("mousedown", onDown); + return () => document.removeEventListener("mousedown", onDown); + }, [tocOpen]); + + if (headings.length === 0) return null; + + const jumpTo = (line: number) => { + const view = viewRef.current; + if (!view) return; + const lineObj = view.state.doc.line(Math.min(line, view.state.doc.lines)); + view.dispatch({ + selection: { anchor: lineObj.from }, + effects: EditorView.scrollIntoView(lineObj.from, { y: "start", yMargin: 40 }), + }); + view.focus(); + setTocOpen(false); + }; + + const toggleCollapse = (line: number) => { + setCollapsed((prev) => { + const next = new Set(prev); + if (next.has(line)) next.delete(line); + else next.add(line); + return next; + }); + }; + + const activeLine = path.length > 0 ? path[path.length - 1].line : -1; + + return ( +
    + + + {tocOpen && ( +
    +
      + {tree.map((node) => ( + + ))} +
    +
    + )} +
    + ); +} diff --git a/src/components/editor/index.ts b/src/components/editor/index.ts index 85a772d..345bdfa 100644 --- a/src/components/editor/index.ts +++ b/src/components/editor/index.ts @@ -1,4 +1,5 @@ export { Editor } from "./editor"; +export { EditorPane } from "./editor-pane"; export { CsvPreview } from "./csv-preview"; export { OpenTabs } from "./open-tabs"; export { Preview } from "./preview"; diff --git a/src/styles/editor/heading-bar.css b/src/styles/editor/heading-bar.css new file mode 100644 index 0000000..7779838 --- /dev/null +++ b/src/styles/editor/heading-bar.css @@ -0,0 +1,208 @@ +/* ── EditorPane layout ── */ + +.mdv-editor-pane { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +.mdv-editor-pane .mdv-editor { + flex: 1; + min-height: 0; + height: auto; +} + +/* ── Heading breadcrumb bar ── */ + +.mdv-hbar { + position: relative; + flex-shrink: 0; + border-bottom: 1px solid var(--border); + background: var(--surface); + z-index: 10; +} + +.mdv-hbar__trigger { + display: flex; + align-items: center; + flex-wrap: nowrap; + gap: 2px; + width: 100%; + padding: 0 12px; + height: 26px; + font-family: var(--font-ui); + font-size: 11px; + font-weight: 600; + color: #d97706; + text-align: left; + overflow: hidden; + cursor: pointer; + transition: background-color var(--dur-fast) var(--easing); +} + +.mdv-hbar__trigger:hover { + background: color-mix(in srgb, var(--fg) 4%, transparent); +} + +.mdv-hbar__trigger.is-open { + background: color-mix(in srgb, var(--fg) 6%, transparent); +} + +.mdv-hbar__crumb-row { + display: inline-flex; + align-items: center; + gap: 2px; + flex-shrink: 0; +} + +.mdv-hbar__crumb { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 160px; +} + +.mdv-hbar__chevron { + display: inline-flex; + align-items: center; + color: color-mix(in srgb, #d97706 55%, transparent); + flex-shrink: 0; +} + +.mdv-hbar__empty { + color: var(--muted); + font-weight: 400; +} + +/* ── TOC dropdown panel ── */ + +.mdv-hbar__toc { + position: absolute; + top: 100%; + left: 0; + right: 0; + max-height: 340px; + overflow-y: auto; + background: var(--surface); + border: 1px solid var(--border); + border-top: none; + box-shadow: 0 8px 24px -6px color-mix(in srgb, var(--fg) 20%, transparent); + z-index: 100; + animation: mdv-hbar-slide 110ms var(--easing); +} + +@keyframes mdv-hbar-slide { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} + +.mdv-hbar__toc-list, +.mdv-hbar__toc-children { + list-style: none; + margin: 0; + padding: 0; +} + +.mdv-hbar__toc-list { + padding: 4px 0; +} + +.mdv-hbar__toc-node { + /* tree node container */ +} + +.mdv-hbar__toc-row { + display: flex; + align-items: center; + padding-right: 12px; + transition: background-color var(--dur-fast) var(--easing); +} + +.mdv-hbar__toc-row:hover { + background: color-mix(in srgb, var(--fg) 5%, transparent); +} + +.mdv-hbar__toc-row.is-ancestor { + color: color-mix(in srgb, #d97706 80%, var(--fg)); +} + +.mdv-hbar__toc-row.is-active { + background: color-mix(in srgb, #d97706 8%, transparent); +} + +.mdv-hbar__toc-row.is-active:hover { + background: color-mix(in srgb, #d97706 13%, transparent); +} + +/* fold toggle button */ +.mdv-hbar__toc-fold { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 18px; + height: 26px; + color: var(--muted); + cursor: pointer; + border-radius: 3px; + transition: color var(--dur-fast) var(--easing); +} + +.mdv-hbar__toc-fold:hover { + color: var(--fg); +} + +.mdv-hbar__toc-row.is-active .mdv-hbar__toc-fold, +.mdv-hbar__toc-row.is-ancestor .mdv-hbar__toc-fold { + color: color-mix(in srgb, #d97706 60%, transparent); +} + +.mdv-hbar__toc-leaf-dot { + display: block; + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--border); + margin: auto; +} + +/* heading label button */ +.mdv-hbar__toc-label { + display: flex; + align-items: baseline; + gap: 5px; + flex: 1; + min-width: 0; + padding: 5px 0; + font-family: var(--font-ui); + font-size: 12px; + color: inherit; + text-align: left; + cursor: pointer; + overflow: hidden; +} + +.mdv-hbar__toc-row.is-active .mdv-hbar__toc-label { + color: #d97706; + font-weight: 600; +} + +.mdv-hbar__toc-marker { + font-family: var(--font-mono); + font-size: 10px; + color: var(--muted); + flex-shrink: 0; + letter-spacing: -0.05em; +} + +.mdv-hbar__toc-row.is-active .mdv-hbar__toc-marker, +.mdv-hbar__toc-row.is-ancestor .mdv-hbar__toc-marker { + color: color-mix(in srgb, #d97706 65%, transparent); +} + +.mdv-hbar__toc-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +}