Skip to content

feat(editor): collapsible heading sections (Obsidian parity)#187

Merged
thetechjon merged 2 commits into
devfrom
feat/foldable-headings
Jun 12, 2026
Merged

feat(editor): collapsible heading sections (Obsidian parity)#187
thetechjon merged 2 commits into
devfrom
feat/foldable-headings

Conversation

@thetechjon

Copy link
Copy Markdown
Collaborator

What

Adds Obsidian-style collapsible / foldable heading sections to the CodeMirror live-preview editor. A chevron in the fold gutter next to every markdown heading collapses everything under it until the next heading of the same or higher level; click again (or use the keybinding) to expand.

Fold-range logic

Computed from the lezer markdown syntax tree (ATXHeading1..ATXHeading6), not a regex (src/components/editor/foldableHeadings.ts):

  • The fold range runs from the end of the heading line to just before the next heading whose level is <= this heading's level (smaller-or-equal level number), or to end-of-document for the last heading.
  • A ## folds until the next ## or #; a nested ### folds independently inside its parent ##; the last heading folds to EOF.
  • A heading with no body (immediately followed by a sibling/parent heading, or sitting at EOF) yields null, so the gutter does not offer a no-op fold.
  • Heading scans are cached per parsed Tree in a WeakMap, so the fold gutter's repeated per-line queries stay cheap and the cache is GC'd when the tree is superseded.

Wired via @codemirror/language's foldService + codeFolding() + foldGutter(), added to the editor extension list in CodeMirrorEditor.tsx. The collapsed section shows a styled placeholder; gutter chevrons ( / ) and the placeholder are themed to match the obsidian-dark palette.

Keybindings

Picked to avoid collisions with the editor's existing bindings and the app-level shortcuts in src/utils/shortcuts.ts (verified Ctrl+. and the Alt-bracket chords are unused):

  • Mod+. — toggle fold on the heading section at the cursor (toggleFold)
  • Mod+Alt+[ — fold all sections (foldAll)
  • Mod+Alt+] — unfold all sections (unfoldAll)

The fold gutter chevron is also clickable, and the placeholder is clickable to unfold.

View-only — no content corruption

Folding is a pure view-layer collapse: CodeMirror hides lines with a replace-decoration layer and the underlying markdown text is never modified. So collab (yCollab), save, and the live-preview decorations all keep working. No mark decoration spans the fold boundary (the boundary sits at a line end), and line decorations on hidden lines simply don't render — the two layers coexist cleanly. None of the existing editor extensions (hanging indent, live preview, tasks, images, ordered-list renumber, smart-Enter, table Tab) are touched.

Mobile

The fold gutter widens and the chevron grows under @media (pointer: coarse) so the tap target is comfortable on touch.

Tests (src/__tests__/foldableHeadings.test.ts)

  • UnitheadingFoldRange: ## folds to next ##/#; nested ### folds independently; last heading folds to EOF; empty-body heading folds nothing; non-heading line folds nothing.
  • Integration — mount an EditorView with the folding extension, fold a heading, assert the body lines are folded (foldedRanges) and the doc text is byte-for-byte unchanged; unfold restores. A further case mounts folding + the markdown live-preview StateField together and confirms no crash and intact content.

Verification

  • npx tsc --noEmit — zero errors
  • npx eslint (changed files) — clean
  • npx jest --ci — 3017 passed, 0 failed (no flake)

Rendered-preview folding — deferred

Folding is shipped for the CodeMirror editor only. The read-only ReactMarkdown preview (EditorContent.tsx) renders a heading and its following content as flat sibling nodes, not a nested tree, so a <details>-style per-section collapse needs a rehype transform that groups siblings under each heading. That transform would also have to coexist with the preview's existing document-order DOM walks (cursor-block tracking, task-checkbox line matching, tag styling), making it a meaningfully separate effort. Deferred as a follow-up rather than rushed in here.

🤖 Generated with Claude Code

Add fold/unfold for markdown heading sections in the CodeMirror live-preview
editor, matching Obsidian: a chevron in the fold gutter next to every ATX
heading collapses everything under it until the next heading of the same or
higher level.

- foldableHeadings.ts: a foldService computing the section range from the
  lezer markdown tree (ATXHeading1..6), not a regex. Range = end of heading
  line to just before the next heading whose level <= this one (or EOF).
  Per-Tree WeakMap cache keeps the fold-gutter's per-line queries cheap.
- codeFolding() + foldGutter() with dark-theme chevrons + a collapsed-section
  placeholder; touch-friendly gutter sizing under (pointer: coarse).
- Keybindings: Mod-. toggles the heading fold at the cursor; Mod-Alt-[ /
  Mod-Alt-] fold / unfold all. No collisions with existing editor or app
  shortcuts.
- View-only: the markdown text is never modified, so collab (yCollab), save,
  and the live-preview decorations all keep working. Unit + integration tests
  cover the range logic and confirm no content corruption with folding +
  live preview mounted together.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…utter

The new fold gutter shifts the editor content right by the gutter column and
renders a fold chevron next to the heading in the selection-visibility note,
so the committed baselines for editor-selection-themes.spec.ts no longer match.
Regenerated all four theme snapshots (default/light/sepia/solarized-dark) in
the pinned Playwright v1.60.0 container so the pixels match the CI runner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@thetechjon thetechjon merged commit 59a43e7 into dev Jun 12, 2026
3 checks passed
@thetechjon thetechjon deleted the feat/foldable-headings branch June 12, 2026 18:30
thetechjon pushed a commit that referenced this pull request Jun 15, 2026
…beta

Temporary, revertable: comments out the foldableHeadings extension (#187) to
isolate whether the fold gutter / codeFolding / foldService is the cause of
the slow note switch Jon sees on beta (dev-only; prod/main is instant). If
switching becomes instant with this off, re-enable and fix its per-switch
cost; otherwise revert this commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant