Add document outline support for Markdown/Quarto files
Build and Deploy Verso / deploy (push) Successful in 10m47s

outline.ts: export NestingLevel so it can be used outside the file.

markdown/document-outline.ts: new enterMarkdownNode function that walks
  the Lezer Markdown syntax tree and extracts ATXHeading1-6 and
  SetextHeading1-2 nodes, mapping them to the same NestingLevel enum
  used by the LaTeX outline (Section→SubSection→SubSubSection…).
  Wrapped in makeProjectionStateField for incremental updates.

markdown/index.ts: register markdownDocumentOutline as a CodeMirror
  extension in the Markdown LanguageSupport so the StateField is active
  whenever a .qmd file is open.

codemirror-outline.tsx: fall back to markdownDocumentOutline when the
  LaTeX documentOutline StateField is not present in the editor state
  (i.e. when the active language is Markdown, not LaTeX).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
claude
2026-05-31 14:35:01 +00:00
parent ce0572e01e
commit 1e5ce6c068
4 changed files with 66 additions and 2 deletions
@@ -1,6 +1,7 @@
import { useCodeMirrorStateContext } from './codemirror-context'
import React, { useEffect } from 'react'
import { documentOutline } from '../languages/latex/document-outline'
import { markdownDocumentOutline } from '../languages/markdown/document-outline'
import { ProjectionStatus } from '../utils/tree-operations/projection'
import useDebounce from '../../../shared/hooks/use-debounce'
import { useOutlineContext } from '@/features/ide-react/context/outline-context'
@@ -10,7 +11,10 @@ export const CodemirrorOutline = React.memo(function CodemirrorOutline() {
const state = useCodeMirrorStateContext()
const debouncedState = useDebounce(state, 100)
const outlineResult = debouncedState.field(documentOutline, false)
// Use whichever outline StateField is active for the current language
const outlineResult =
debouncedState.field(documentOutline, false) ??
debouncedState.field(markdownDocumentOutline, false)
// when the outline projection changes, calculate the flat outline
useEffect(() => {
@@ -0,0 +1,58 @@
import { EditorState } from '@codemirror/state'
import { SyntaxNodeRef } from '@lezer/common'
import {
FlatOutlineItem,
NestingLevel,
} from '../../utils/tree-operations/outline'
import { NodeIntersectsChangeFn } from '../../utils/tree-operations/projection'
import { makeProjectionStateField } from '../../utils/projection-state-field'
// Map Lezer Markdown node names to outline nesting levels
const HEADING_LEVELS: Record<string, NestingLevel> = {
ATXHeading1: NestingLevel.Section,
ATXHeading2: NestingLevel.SubSection,
ATXHeading3: NestingLevel.SubSubSection,
ATXHeading4: NestingLevel.Paragraph,
ATXHeading5: NestingLevel.SubParagraph,
ATXHeading6: NestingLevel.SubParagraph,
SetextHeading1: NestingLevel.Section,
SetextHeading2: NestingLevel.SubSection,
}
const enterMarkdownNode = (
state: EditorState,
node: SyntaxNodeRef,
items: FlatOutlineItem[],
nodeIntersectsChange: NodeIntersectsChangeFn
): void => {
const level = HEADING_LEVELS[node.name]
if (level === undefined) return
if (!nodeIntersectsChange(node)) {
// Node unchanged — already present in items from the previous projection
return
}
// Collect heading text, skipping the HeaderMark (the leading # characters)
let title = ''
const cursor = node.node.cursor()
if (cursor.firstChild()) {
do {
if (cursor.name !== 'HeaderMark') {
title += state.sliceDoc(cursor.from, cursor.to)
}
} while (cursor.nextSibling())
}
items.push({
line: state.doc.lineAt(node.from).number,
toLine: state.doc.lineAt(node.to).number,
title: title.trim(),
from: node.from,
to: node.to,
level,
} as FlatOutlineItem)
}
export const markdownDocumentOutline =
makeProjectionStateField<FlatOutlineItem>(enterMarkdownNode)
@@ -8,6 +8,7 @@ import {
syntaxHighlighting,
} from '@codemirror/language'
import { tags } from '@lezer/highlight'
import { markdownDocumentOutline } from './document-outline'
export const markdown = () => {
const { language, support } = markdownLanguage({
@@ -19,6 +20,7 @@ export const markdown = () => {
support,
shortcuts(),
syntaxHighlighting(markdownHighlightStyle),
markdownDocumentOutline,
])
}
@@ -24,7 +24,7 @@ export class FlatOutlineItem extends ProjectionItem {
export type FlatOutline = FlatOutlineItem[]
/* eslint-disable no-unused-vars */
enum NestingLevel {
export enum NestingLevel {
Book = 1,
Part = 2,
Chapter = 3,