diff --git a/e2e/editor-selection-themes.spec.ts-snapshots/selection-default-chromium-linux.png b/e2e/editor-selection-themes.spec.ts-snapshots/selection-default-chromium-linux.png index 204cffbb..341aabda 100644 Binary files a/e2e/editor-selection-themes.spec.ts-snapshots/selection-default-chromium-linux.png and b/e2e/editor-selection-themes.spec.ts-snapshots/selection-default-chromium-linux.png differ diff --git a/e2e/editor-selection-themes.spec.ts-snapshots/selection-light-chromium-linux.png b/e2e/editor-selection-themes.spec.ts-snapshots/selection-light-chromium-linux.png index 43beb01c..1931a399 100644 Binary files a/e2e/editor-selection-themes.spec.ts-snapshots/selection-light-chromium-linux.png and b/e2e/editor-selection-themes.spec.ts-snapshots/selection-light-chromium-linux.png differ diff --git a/e2e/editor-selection-themes.spec.ts-snapshots/selection-sepia-chromium-linux.png b/e2e/editor-selection-themes.spec.ts-snapshots/selection-sepia-chromium-linux.png index 349862dc..2a8e1fdf 100644 Binary files a/e2e/editor-selection-themes.spec.ts-snapshots/selection-sepia-chromium-linux.png and b/e2e/editor-selection-themes.spec.ts-snapshots/selection-sepia-chromium-linux.png differ diff --git a/e2e/editor-selection-themes.spec.ts-snapshots/selection-solarized-dark-chromium-linux.png b/e2e/editor-selection-themes.spec.ts-snapshots/selection-solarized-dark-chromium-linux.png index f3d7e8aa..89753882 100644 Binary files a/e2e/editor-selection-themes.spec.ts-snapshots/selection-solarized-dark-chromium-linux.png and b/e2e/editor-selection-themes.spec.ts-snapshots/selection-solarized-dark-chromium-linux.png differ diff --git a/src/__tests__/foldableHeadings.test.ts b/src/__tests__/foldableHeadings.test.ts new file mode 100644 index 00000000..ad98482d --- /dev/null +++ b/src/__tests__/foldableHeadings.test.ts @@ -0,0 +1,190 @@ +/** + * foldableHeadings.test.ts + * + * Unit + integration coverage for collapsible heading sections. + * + * Unit — `headingFoldRange` (the pure range computation off the lezer tree): + * - a `##` folds until the next `##` or `#` + * - a nested `###` folds independently (stops at the next `###`/`##`/`#`) + * - the last heading folds to EOF + * - a heading with no body folds nothing (null) + * - a non-heading line folds nothing (null) + * + * Integration — mount an EditorView with the folding extension: + * - folding a heading hides the body lines (foldedRanges covers them) and + * leaves the document text byte-for-byte unchanged + * - unfolding restores (no folded ranges, text still unchanged) + * - folding coexists with the markdown live-preview decorations (no crash, + * content intact) + * + * idb-keyval is mocked because pulling in CodeMirrorEditor (for the live + * preview integration check) transitively imports persist-backed stores. + */ + +jest.mock('idb-keyval', () => ({ + get: jest.fn().mockResolvedValue(undefined), + set: jest.fn().mockResolvedValue(undefined), + del: jest.fn().mockResolvedValue(undefined), +})) + +import { EditorState } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { markdown, markdownLanguage } from '@codemirror/lang-markdown' +import { foldCode, unfoldCode, foldedRanges } from '@codemirror/language' +import { headingFoldRange, foldableHeadings } from '../components/editor/foldableHeadings' +import { markdownLivePreview } from '../components/editor/markdownLivePreview' + +const md = markdown({ base: markdownLanguage }) + +function makeState(doc: string, extensions: unknown[] = [md]) { + return EditorState.create({ doc, extensions: extensions as never }) +} + +/** Resolve the fold range for the heading on the 1-indexed `lineNo`. */ +function foldRangeForLine(state: EditorState, lineNo: number) { + const line = state.doc.line(lineNo) + return headingFoldRange(state, line.from, line.to) +} + +describe('headingFoldRange — section range off the lezer tree', () => { + test('a `##` folds until the next `##`', () => { + const doc = ['# Title', '## A', 'body a1', 'body a2', '## B', 'body b'].join('\n') + const state = makeState(doc) + const range = foldRangeForLine(state, 2) // "## A" + expect(range).not.toBeNull() + // from = end of "## A" line + expect(range!.from).toBe(state.doc.line(2).to) + // to = just before the "## B" line + expect(range!.to).toBe(state.doc.line(5).from - 1) + // The folded text is exactly the two body lines. + expect(state.doc.sliceString(range!.from, range!.to)).toBe('\nbody a1\nbody a2') + }) + + test('a `##` also stops at a higher-level `#`', () => { + const doc = ['# One', '## Sub', 'body', '# Two', 'more'].join('\n') + const state = makeState(doc) + const range = foldRangeForLine(state, 2) // "## Sub" + expect(range).not.toBeNull() + // Stops before "# Two" (level 1 <= 2). + expect(range!.to).toBe(state.doc.line(4).from - 1) + }) + + test('a nested `###` folds independently of the enclosing `##`', () => { + const doc = [ + '## Parent', // 1 + 'p body', // 2 + '### Child', // 3 + 'c body 1', // 4 + 'c body 2', // 5 + '## Next', // 6 + ].join('\n') + const state = makeState(doc) + const child = foldRangeForLine(state, 3) // "### Child" + expect(child).not.toBeNull() + // The ### section stops at the next heading of level <= 3, i.e. "## Next". + expect(child!.to).toBe(state.doc.line(6).from - 1) + expect(state.doc.sliceString(child!.from, child!.to)).toBe('\nc body 1\nc body 2') + + // The enclosing ## folds the whole thing (its body + the child section). + const parent = foldRangeForLine(state, 1) // "## Parent" + expect(parent!.to).toBe(state.doc.line(6).from - 1) + }) + + test('the last heading folds to end-of-document', () => { + const doc = ['# A', 'a body', '## Last', 'tail 1', 'tail 2'].join('\n') + const state = makeState(doc) + const range = foldRangeForLine(state, 3) // "## Last" + expect(range).not.toBeNull() + expect(range!.to).toBe(state.doc.length) + expect(state.doc.sliceString(range!.from, range!.to)).toBe('\ntail 1\ntail 2') + }) + + test('a heading with no body folds nothing', () => { + const doc = ['## Empty', '## Next', 'body'].join('\n') + const state = makeState(doc) + expect(foldRangeForLine(state, 1)).toBeNull() // "## Empty" → "## Next" immediately + }) + + test('a heading at EOF with no body folds nothing', () => { + const doc = ['# A', 'body', '## End'].join('\n') + const state = makeState(doc) + expect(foldRangeForLine(state, 3)).toBeNull() + }) + + test('a non-heading line folds nothing', () => { + const doc = ['# A', 'just a paragraph'].join('\n') + const state = makeState(doc) + expect(foldRangeForLine(state, 2)).toBeNull() + }) +}) + +describe('heading folding — EditorView integration (view-only, no corruption)', () => { + function mount(doc: string, extensions: unknown[]) { + const state = makeState(doc, extensions) + const view = new EditorView({ state }) + return view + } + + test('folding hides the body and leaves the text unchanged; unfold restores', () => { + const doc = ['# Title', '## A', 'body a1', 'body a2', '## B', 'body b'].join('\n') + const view = mount(doc, [md, foldableHeadings]) + + // Put the cursor on "## A" and fold via the command (exercises foldService). + const aLine = view.state.doc.line(2) + view.dispatch({ selection: { anchor: aLine.from } }) + expect(foldCode(view)).toBe(true) + + // The body lines are now inside a folded range; text is unchanged. + const folded = foldedRanges(view.state) + let foldedFrom = -1 + let foldedTo = -1 + folded.between(0, view.state.doc.length, (f, t) => { + foldedFrom = f + foldedTo = t + }) + expect(foldedFrom).toBe(view.state.doc.line(2).to) + expect(foldedTo).toBe(view.state.doc.line(5).from - 1) + expect(view.state.doc.toString()).toBe(doc) + + // Unfold restores: no folded ranges, text still pristine. + view.dispatch({ selection: { anchor: view.state.doc.line(2).from } }) + expect(unfoldCode(view)).toBe(true) + let stillFolded = false + foldedRanges(view.state).between(0, view.state.doc.length, () => { + stillFolded = true + }) + expect(stillFolded).toBe(false) + expect(view.state.doc.toString()).toBe(doc) + + view.destroy() + }) + + test('folding coexists with the markdown live-preview decorations', () => { + const doc = [ + '# Heading **bold**', + '## Section', + '- a list item', + '- [ ] a task', + 'a #tag here', + '## Other', + 'more', + ].join('\n') + // Mount with BOTH the live-preview StateField and the folding bundle. + const view = mount(doc, [md, markdownLivePreview, foldableHeadings]) + + const sectionLine = view.state.doc.line(2) // "## Section" + view.dispatch({ selection: { anchor: sectionLine.from } }) + expect(() => foldCode(view)).not.toThrow() + + // The section body is folded, decorations didn't corrupt anything, and the + // document text is byte-for-byte identical. + let folded = false + foldedRanges(view.state).between(0, view.state.doc.length, () => { + folded = true + }) + expect(folded).toBe(true) + expect(view.state.doc.toString()).toBe(doc) + + view.destroy() + }) +}) diff --git a/src/components/editor/CodeMirrorEditor.tsx b/src/components/editor/CodeMirrorEditor.tsx index af5bf600..1ceea703 100644 --- a/src/components/editor/CodeMirrorEditor.tsx +++ b/src/components/editor/CodeMirrorEditor.tsx @@ -6,8 +6,10 @@ import { markdown, markdownLanguage } from '@codemirror/lang-markdown' import { EditorView, keymap, drawSelection, type Command } from '@codemirror/view' import { Prec, Compartment } from '@codemirror/state' import { moveLineUp, moveLineDown, deleteLine } from '@codemirror/commands' +import { toggleFold, foldAll, unfoldAll } from '@codemirror/language' import { search, searchKeymap, openSearchPanel } from '@codemirror/search' import { diffGutterExtension, setDiffBaseline } from './diffGutter' +import { foldableHeadings } from './foldableHeadings' import { getLastPushedContent } from '@/utils/lastPushedContent' import { useDebouncedCallback } from '@/hooks/useDebounce' import { useUIStore, useGitHubStore } from '@/stores' @@ -784,6 +786,13 @@ export function CodeMirrorEditor({ onWikilinkNavigate: (note) => navigateRef.current(note), }), diffGutterExtension, + // Collapsible heading sections (Obsidian parity). Adds a fold gutter with + // a chevron next to every ATX heading; clicking collapses everything under + // it until the next heading of the same-or-higher level. View-only — the + // markdown text is untouched, so collab + live-preview decorations + save + // all keep working. See foldableHeadings.ts for the range logic. Keyboard + // toggles (Mod-., Mod-Alt-[ / Mod-Alt-]) are wired in the keymap below. + foldableHeadings, // Built-in find / replace panel. `top: true` opens it above the // editor — matches VS Code / Obsidian placement. Keymap includes // Ctrl+F (find), Ctrl+H (replace), F3/Shift+F3 (next/prev), Esc @@ -828,6 +837,16 @@ export function CodeMirrorEditor({ // "bookmark this page" dialog (Ctrl+D), which is interceptable (unlike // Ctrl+W). Replaces the old selectNextOccurrence binding we removed. { key: 'Mod-d', preventDefault: true, run: deleteLine }, + // ── Heading folding (Obsidian parity) ────────────────────────────────── + // Mod+. toggles the fold on the heading section at the cursor. Mod+Alt+[ + // / Mod+Alt+] fold / unfold every section (CodeMirror's default fold-all + // chords; the app's own foldKeymap is disabled in basicSetup so these + // don't double-fire). None of these collide with the editor's existing + // bindings or the app-level shortcuts (Ctrl+. and the Alt-bracket chords + // are unused — see src/utils/shortcuts.ts). + { key: 'Mod-.', preventDefault: true, run: toggleFold }, + { key: 'Mod-Alt-[', preventDefault: true, run: foldAll }, + { key: 'Mod-Alt-]', preventDefault: true, run: unfoldAll }, // Enter on an EMPTY checkbox exits the list. No preventDefault: when it // returns false (any other line) the event falls through to the markdown // keymap's normal Enter continuation. diff --git a/src/components/editor/foldableHeadings.ts b/src/components/editor/foldableHeadings.ts new file mode 100644 index 00000000..d09342bb --- /dev/null +++ b/src/components/editor/foldableHeadings.ts @@ -0,0 +1,199 @@ +import { EditorView } from '@codemirror/view' +import { + foldService, + foldGutter, + codeFolding, + syntaxTree, + ensureSyntaxTree, +} from '@codemirror/language' +import type { EditorState } from '@codemirror/state' +import type { Tree } from '@lezer/common' + +/** + * Collapsible / foldable heading sections (Obsidian parity). + * + * Markdown's lezer grammar tags every heading line as `ATXHeading1`… + * `ATXHeading6` but gives NO folding information — a heading node spans only + * its own line, not the section beneath it. We supply a `foldService` that, for + * a heading line, computes the section range to collapse: from the END of the + * heading line down to JUST BEFORE the next heading whose level is `<=` this + * one (or end-of-document). This mirrors Obsidian's "fold heading" behaviour + * where `##` collapses everything until the next `##`/`#`, and a nested `###` + * folds independently inside it. + * + * The fold is VIEW-ONLY: CodeMirror hides the lines with a replace decoration + * layer (`codeFolding()`); the underlying markdown text is never touched, so + * typing, saving, collab (yCollab) and the live-preview decorations are all + * unaffected. Decorations from `markdownLivePreview` simply don't render on + * hidden lines, and no mark decoration spans the fold boundary (the boundary + * sits at a line end), so the two layers coexist cleanly. + */ + +interface HeadingInfo { + /** Heading level 1–6. */ + level: number + /** Document offset of the heading's `#` marker. */ + from: number + /** Document offset of the end of the heading line. */ + lineEnd: number +} + +// Cache the heading scan per parsed Tree. `syntaxTree(state)` returns a stable +// Tree object for a given state (replaced on every edit), so a WeakMap keyed by +// the Tree makes the fold-gutter's repeated per-line queries O(1) after the +// first scan, and lets the entry be GC'd once the Tree is superseded. +const headingCache = new WeakMap() + +function resolveTree(state: EditorState): Tree { + // Force a full parse so folding works for the whole document (and in headless + // test states with no view driving incremental parsing). Falls back to the + // partial tree if the parse times out on a very large doc. + return ensureSyntaxTree(state, state.doc.length, 5000) ?? syntaxTree(state) +} + +function collectHeadings(state: EditorState): HeadingInfo[] { + const tree = resolveTree(state) + const cached = headingCache.get(tree) + if (cached) return cached + + const out: HeadingInfo[] = [] + tree.iterate({ + enter(node) { + const m = /^ATXHeading([1-6])$/.exec(node.name) + if (m) { + out.push({ + level: parseInt(m[1], 10), + from: node.from, + lineEnd: state.doc.lineAt(node.from).to, + }) + // Headings have no nested foldable children we care about — skip. + return false + } + return undefined + }, + }) + headingCache.set(tree, out) + return out +} + +/** + * Fold range for the heading occupying the line `[lineStart, lineEnd]`, or + * `null` when the line is not a heading or the section is empty. + * + * The range runs from the end of the heading line to just before the next + * heading of the same-or-higher level (smaller-or-equal level number), or to + * end-of-document for the last heading. A heading immediately followed by a + * sibling/parent heading (no body) yields `null` so the fold arrow doesn't + * offer a no-op fold. + */ +export function headingFoldRange( + state: EditorState, + lineStart: number, + lineEnd: number, +): { from: number; to: number } | null { + const headings = collectHeadings(state) + const idx = headings.findIndex((h) => h.from >= lineStart && h.from <= lineEnd) + if (idx === -1) return null + + const current = headings[idx] + let to = state.doc.length + for (let i = idx + 1; i < headings.length; i++) { + if (headings[i].level <= current.level) { + // End at the newline before the next heading line: this swallows any + // blank lines between the section body and the next heading, matching + // Obsidian, and leaves the next heading line fully visible. + to = state.doc.lineAt(headings[i].from).from - 1 + break + } + } + + const from = current.lineEnd + // Empty section (next heading is the very next line, or heading is at EOF): + // nothing to collapse. + if (to <= from) return null + return { from, to } +} + +const headingFoldService = foldService.of(headingFoldRange) + +// Chevron in the fold gutter. `open` = the section can be folded (pointing +// down); folded sections show a right-pointing chevron. Sized + padded so it +// stays a comfortable tap target on touch screens, where the thin default +// gutter is hard to hit. +function foldMarkerDOM(open: boolean): HTMLElement { + const span = document.createElement('span') + span.className = 'cm-heading-fold-marker' + span.textContent = open ? '▾' : '▸' // ▾ / ▸ + span.title = open ? 'Fold heading' : 'Unfold heading' + span.setAttribute('aria-hidden', 'true') + return span +} + +// Placeholder shown inline at the end of a folded heading line. +function foldPlaceholderDOM(_view: EditorView, onclick: (e: Event) => void): HTMLElement { + const el = document.createElement('span') + el.className = 'cm-heading-fold-placeholder' + el.textContent = '⋯' // ⋯ + el.title = 'Click to unfold' + el.setAttribute('role', 'button') + el.setAttribute('aria-label', 'Unfold heading section') + el.onclick = onclick + return el +} + +const foldTheme = EditorView.baseTheme({ + // Fold gutter column. Kept narrow on the desktop; the marker padding below + // widens the touch target without widening the visible rail. + '.cm-foldGutter': { + minWidth: '14px', + }, + '.cm-foldGutter .cm-gutterElement': { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + '.cm-heading-fold-marker': { + color: '#6b7280', + cursor: 'pointer', + fontSize: '10px', + lineHeight: '1', + padding: '0 3px', + transition: 'color 120ms ease', + userSelect: 'none', + }, + '.cm-heading-fold-marker:hover': { + color: '#dadada', + }, + // The collapsed-section indicator at the end of the heading line. + '.cm-heading-fold-placeholder': { + color: '#8a8a8a', + background: 'rgba(255,255,255,0.06)', + border: '1px solid #3a3a3a', + borderRadius: '4px', + margin: '0 4px', + padding: '0 6px', + cursor: 'pointer', + fontSize: '0.85em', + }, + '.cm-heading-fold-placeholder:hover': { + background: 'rgba(255,255,255,0.12)', + color: '#dadada', + }, + // Touch: fatten the gutter + marker so the chevron is tappable. + '@media (pointer: coarse)': { + '.cm-foldGutter': { minWidth: '22px' }, + '.cm-heading-fold-marker': { fontSize: '13px', padding: '0 5px' }, + }, +}) + +/** + * The full folding bundle: the heading fold range provider, the fold-state + * machinery + collapsed placeholder, the gutter chevrons, and the dark-theme + * styling. Drop this into the editor's extension list. + */ +export const foldableHeadings = [ + headingFoldService, + codeFolding({ placeholderDOM: foldPlaceholderDOM }), + foldGutter({ markerDOM: foldMarkerDOM }), + foldTheme, +]