feat: foldable headings in the WYSIWYG editor (#1869) by rkristelijn · Pull Request #5276 · marktext/marktext · GitHub
Skip to content

feat: foldable headings in the WYSIWYG editor (#1869) - #5276

Merged
Jocs merged 20 commits into
marktext:developfrom
rkristelijn:feat/heading-fold-muya
Oct 19, 2026
Merged

Jocs merged 20 commits into
marktext:developfrom
rkristelijn:feat/heading-fold-muya

Conversation

@rkristelijn

@rkristelijn rkristelijn commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1869

Summary

Adds section folding for ATX H1–H6 headings in the TypeScript Muya WYSIWYG engine. Folding hides subsequent blocks up to the next heading of equal or higher level in the same container. Setext headings participate in section boundaries; their own fold controls are outside this PR's delivery scope.

The heading controls are ordered copy heading link → block front-menu handle → fold/unfold. A folded heading keeps its right-pointing chevron visible. An expanded heading’s fold button appears on heading hover or button focus. Activate the fold button with a click, Enter, or Space, or use Cmd/Ctrl+Shift+[ on the heading line.

Ordinary arrow navigation skips folded content. Selection and programmatic cursor placement reveal the necessary sections before editing. Enter, Backspace, Delete, clipboard operations, and undo/redo preserve the hidden document content. Find-in-document expands all sections; closing search leaves them expanded. Revealing a TOC target preserves unrelated and nested fold states.

Fold state is transient and stays out of Markdown and serialized document state. Fold visibility is derived from heading state and section boundaries; DOM classes only present the result. The existing previous/next content traversal methods accept an optional skipFolded parameter for caret navigation.

Scope

This PR delivers individual ATX heading folding and its editor interactions. It does not deliver a trailing ellipsis control, Setext heading fold controls, Fold all / Unfold all / Fold to this level menu actions, or persistence across editor recreation. These features were incorrectly described as already implemented in the previous PR description.

Validation

  • Muya: 2,744 unit tests passed.
  • Chromium: 42 selected folding, paragraph-menu, and drag/reorder tests passed.
  • Root ESLint, changed Muya source ESLint, desktop typecheck, Muya lint:types, and circular dependency check passed.
  • Regression coverage includes nested folds, captured section boundaries, class-independent visibility, editing/navigation, search and TOC reveal, and heading-control listener lifetime. Before the lifetime fix, deleting either an ordinary heading or a blockquote containing a heading retained four control listeners in EventCenter; both cases now pass, and moved headings keep working.

By submitting this pull request, I confirm that my contribution is made under the terms of the MIT license.

rkristelijn added a commit to rkristelijn/marktext-fork that referenced this pull request Sep 9, 2026
…tor + lint

CodeRabbit review of marktext#5276/#4 caught a real nested-fold bug and lint failures.

- Fix nested fold state (functional bug): unfolding an ancestor heading no
  longer reveals content owned by a still-folded descendant heading. _applyFold
  now consults a pure hiddenByFoldedDescendant() helper so a block stays hidden
  when it belongs to a nested heading that is itself folded. Regression test
  added (fold inner → fold ancestor → unfold ancestor).
- Replace the three `as unknown as AtxHeading` double casts with a shared
  structural type guard isFoldableHeading() (foldSection.ts) — removes the
  lint-suppressed casts flagged by no-restricted-syntax, and _siblingHeadings /
  both _ownerHeading now narrow safely.
- Lint fixes flagged by the muya CI (which was not covered by the desktop
  lint-staged hook): type-before-value import order, function-declaration
  fixtures, no explicit any in the test tree walkers.

Verified: tsc --noEmit clean; muya lint clean; full muya suite 1508 tests pass
(fold suites 55, incl. the new nested-fold regression + hiddenByFoldedDescendant
unit tests).
@rkristelijn

Copy link
Copy Markdown
Contributor Author

@Jocs

Jocs commented Sep 18, 2026

Copy link
Copy Markdown
Member

Please remove all design docs before I can take a review.

@Jocs

Jocs commented Sep 18, 2026

Copy link
Copy Markdown
Member

You can put design docs in PR comments.

@Jocs

Jocs commented Sep 22, 2026

Copy link
Copy Markdown
Member

Thanks for putting this together, @rkristelijn. I've only driven the UI so far — I haven't read the code yet — so this is first-impressions feedback on the interaction design.

1. The copy-anchor is no longer visible

Moving .mu-copy-header-link from left: -40px to left: -64px pushes it out of the text column. .mu-container has padding: 0 50px, so at -64px the 18px icon spans -14px … +4px relative to the container's border box — 14 of its 18 pixels sit outside the column, and the desktop editor wrapper (.editor-component { overflow: auto }) clips them.

Measured against this PR's CSS (headless Chromium, .mu-container inside the desktop editor wrapper). editorW is the width of the editor area, i.e. window width minus the sidebar:

editorW= 700  container=[0..700]   copyLink=[-14..4]  partly clipped   fold=[10..28]  visible
editorW= 780  container=[0..780]   copyLink=[-14..4]  partly clipped   fold=[10..28]  visible
editorW= 828  container=[14..814]  copyLink=[0..18]   visible          fold=[24..42]  visible
editorW= 920  container=[60..860]  copyLink=[46..64]  visible          fold=[70..88]  visible

So below roughly 828px of editor width — any normal window with the sidebar open — only a 4px sliver of the anchor is left. The chevron took over the one gutter slot that is actually inside the column (-40px) and the anchor was pushed into the clipped zone. The left gutter is 50px wide; it does not hold two affordances.

The right gutter is free and just as wide. I measured right: -40px in the same harness and it is visible at every width (rightGutter=[672..690] at 700px through [922..940] at 1100px), so the chevron can go there without evicting anything.

2. The front-menu fold items feel out of place

The block front menu operates on this block — change its type, duplicate it, delete it. "Fold all" / "Unfold all" / "Fold to this level" are document-wide view commands, so they read as out of place there, and they make the menu longer for something that will be reached for rarely. I'd drop them from this PR. If we want document-wide folding it belongs with the other view commands (app menu / command palette) and deserves its own PR and its own discussion.

3. The trailing "…" marker feels redundant

I understand the argument in the description — a section that just vanishes could be mistaken for an empty one. But the chevron already carries that signal: unlike the copy-anchor it stays visible while the section is folded, and it points right instead of down. A second indicator placed inside the text line means a folded heading no longer looks like the heading the user typed, and it adds a surface that has to keep behaving around wrapping, themes, selection, the TOC and export. I'd drop the marker and the HeadingFoldMarker block with it, and let the chevron own both the state and the click target.

What I'd like to see

Scope this down to the core of #1869: fold/unfold one section via the chevron plus Cmd/Ctrl+Shift+[. Leave .mu-copy-header-link at -40px and move the chevron to the right gutter. That drops the marker block, the front-menu changes and most of the new locale strings, and leaves the part people actually asked for. Document-wide folding can follow separately once this lands.

One merge-forward note for whatever survives: packages/muya/src/locales/ru.ts landed after this branch (#5496), so it has none of the fold strings.

@Jocs Jocs left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See comments.

Implements Option A from the fold design doc (marktext#1869): fold the section
owned by an ATX heading in the new muya engine. A section is every block
following a heading up to the next heading of equal-or-higher level, so
folding an h1 also hides nested h2/h3.

- foldSection.ts: pure, DOM-free section-resolution helpers (unit-tested)
- HeadingFoldToggle: chevron affordance (attachment on AtxHeading),
  operable by click and keyboard (Enter/Space), with aria-expanded and
  accessible labels
- AtxHeading.toggleFold()/_applyFold(): walk siblings and toggle the
  MU_FOLDED_CONTENT class; fold is runtime-only, never serialized
- content.ts: beforeKeydown hook so headings can consume Cmd/Ctrl+Shift+[
  (VS Code fold shortcut) before default key routing
- CSS chevron + folded-content hiding; locales for all 11 languages
- docs/design: design doc, PR marktext#4331 review note, and testing notes

Verified: tsc --noEmit clean; 14 unit tests pass (foldSection + headingFold).

Known open items (not yet resolved):
- Affordance placement is provisional (copy-link -64px, chevron -40px, both
  in the left gutter). 'Copy-link directly after heading text' was reverted
  because width:fit-content on headings froze the renderer (see
  docs/design/fold-heading-testing-notes.md).
- Hang fix reverted in source but not yet verified via a dev-server reload.
- Collapse/expand children + document-wide fold (Option D) not started.
- Extract isFoldShortcut() as a pure, DOM-free helper in foldSection.ts so the
  modifier chord logic is unit-testable without a real KeyboardEvent.
- Extract toFoldSibling() to name the sibling→shape projection used by
  collectSectionIndices, with a comment explaining why non-heading blocks
  resolve to undefined level.
- Extract _ownerHeading() in HeadingFoldToggle to name and document the
  defensive guard against stray clicks during mount/unmount.
- Extract _setFoldedClass() in AtxHeading to make fold CSS toggling read as
  intent rather than DOM bookkeeping.
- Add collapsed-section marker: a CSS ::after pill ("…") on folded headings so
  hidden content is discoverable. Uses a pseudo-element that does NOT set any
  intrinsic width on the heading — avoids the documented width:fit-content
  renderer freeze (see fold-heading-testing-notes.md).
- 8 new tests (7 isFoldShortcut chord tests + 1 marker-class integration test);
  total fold tests: 22 passed, tsc --noEmit clean.
- Updated testing notes with marker re-test checklist and verification status.

Verified: tsc --noEmit clean; 22 unit+integration tests pass.
Shortcut conflict: confirmed Cmd/Ctrl+Shift+[ is not bound elsewhere.
Hang regression: no fit-content/max-content rule remains on headings (static);
runtime freeze absence confirmed via dev-server (editor responsive, marker
renders, main.log clean).
…marker

Builds on the per-heading fold with the actions users need to structure a
document, and makes hidden content both visible and directly recoverable.

- Clickable "…" collapsed-section marker (HeadingFoldMarker attachment): a real
  DOM element (a pseudo-element cannot receive a click) shown only while folded,
  keyboard + pointer operable, that unfolds its section. Positioned without any
  intrinsic heading width to avoid the documented fit-content renderer freeze.
- Document-wide fold on AtxHeading: foldAll (every heading), unfoldAll, and
  foldToLevel(n) (fold headings deeper than n) driven by the pure, unit-tested
  foldPlanForLevel helper. foldAll is deliberately NOT foldToLevel(1).
- Heading-only fold actions in the paragraph front menu (the block handle):
  Fold all / Unfold all / Fold to this level; shown only for atx-heading.
- isFoldShortcut extracted to a pure helper; attachment lookup deduped into
  _attachmentByName; fold class toggle named _setFoldedClass (complexity hiding).
- Locales for all 11 languages; CSS for the marker; 28 new/updated tests.

Verified: tsc --noEmit clean; 50 fold unit+integration tests pass; front menu
shows fold items only on headings; runtime dev-server check (fold/unfold, marker
click, menu) OK, main.log clean.
…tor + lint

CodeRabbit review of marktext#5276/#4 caught a real nested-fold bug and lint failures.

- Fix nested fold state (functional bug): unfolding an ancestor heading no
  longer reveals content owned by a still-folded descendant heading. _applyFold
  now consults a pure hiddenByFoldedDescendant() helper so a block stays hidden
  when it belongs to a nested heading that is itself folded. Regression test
  added (fold inner → fold ancestor → unfold ancestor).
- Replace the three `as unknown as AtxHeading` double casts with a shared
  structural type guard isFoldableHeading() (foldSection.ts) — removes the
  lint-suppressed casts flagged by no-restricted-syntax, and _siblingHeadings /
  both _ownerHeading now narrow safely.
- Lint fixes flagged by the muya CI (which was not covered by the desktop
  lint-staged hook): type-before-value import order, function-declaration
  fixtures, no explicit any in the test tree walkers.

Verified: tsc --noEmit clean; muya lint clean; full muya suite 1508 tests pass
(fold suites 55, incl. the new nested-fold regression + hiddenByFoldedDescendant
unit tests).
SonarCloud quality gate flagged new-code duplicated-lines at 5.4% (threshold
3%), concentrated in the fold test files that each re-declared the same
bootMuya + MUYA_VERSION setup/teardown.

Extract that boilerplate into src/__tests__/muyaHarness.ts (useMuyaHarness +
bootMuya) and consume it from headingFold.spec.ts and foldActions.spec.ts. This
removes the duplicated setup blocks (the real duplication) rather than gaming
the metric, and future block/UI specs can reuse it.

Verified: muya tsc clean; lint clean; full muya suite 1508 tests pass (fold
suites 55).
The desktop E2E TOC tests (toc-scroll, toc-panel-content) read a heading via
`heading.textContent` and match it against the heading text. The collapsed
marker set `domNode.textContent = "…"`, so a folded heading read as
"…Heading" and headingIndexByText() returned -1 — failing the TOC scroll/outline
suites. This was a real regression from the marker, not pre-existing (thanks to
the e2e log that pinpointed it).

- Render the "…" glyph via CSS `.mu-fold-marker::after` instead of element
  text. The element stays a clickable, focusable button, but contributes no
  text to the heading, so `textContent` stays equal to the heading text.
- Add a muya regression test asserting a folded heading textContent (normalized
  like the desktop consumer) is unchanged — the coverage the unit suite lacked.

Verified: muya tsc clean; lint clean; full suite 1509 tests pass.
Address review feedback: drop the document-wide fold actions
(Fold all / Unfold all / Fold to this level) and the clickable
"..." collapsed-section marker. Keep the core per-heading
chevron fold/unfold.

Removes the now-dead production code (foldAll/unfoldAll/foldToLevel,
the HeadingFoldMarker block + its CSS, the foldPlanForLevel helper,
the front-menu fold items), plus the tests and locale strings that
only covered the removed surface.
ru was missing 'Fold this section' / 'Unfold this section' since
the fold feature first landed, which fails localeCompleteness.spec.ts
(every locale must carry the same keys as en). Unrelated to the scope
reduction, but required to keep the suite green.
Comment thread packages/muya/src/block/commonMark/atxHeading/foldSection.ts Outdated
Comment thread packages/muya/src/block/commonMark/atxHeading/foldSection.ts Outdated
Comment thread packages/muya/src/block/commonMark/atxHeading/foldSection.ts Outdated
@Jocs

Jocs commented Oct 5, 2026

Copy link
Copy Markdown
Member

I took a quick look at the code, and there are a few issues that need to be fixed: 1) Keep the code style consistent with the existing code. 2) Don't write too many comments; you need to judge for yourself where comments are needed(AI always write too much comments). 3) If a function has only one line, don't wrap it in a function, as this makes the code harder to maintain.

@Jocs

Jocs commented Oct 18, 2026 •

Copy link
Copy Markdown
Member

Deep review of current head 69a6255b — changes requested before merging.

I reproduced the following against the current implementation (including real Chrome interaction checks):

  1. P1 — Hidden content loses its recovery control when a folded heading is removed or converted. Fold # A, put the caret at offset 0, and press Backspace. A becomes a paragraph, but its former section retains mu-folded-content and has no toggle to reveal it. Newly inserted blocks also do not inherit the section's visibility. Fold visibility needs to participate in structural tree changes, rather than only toggleFold() (atxHeading/index.ts:111–153).
  2. P1 — Keyboard navigation enters hidden content and subsequent typing goes to the wrong location. With # A, secret, # B, fold A with the caret at its end, then press ArrowRight. The active block and native selection enter the hidden secret paragraph. Typing X then produces X# B in Chrome. Cross-block navigation and selection placement need a defined policy for folded sections (atxHeading/index.ts:138–145, existing content navigation).
  3. P2 — Setext headings do not terminate sections. In # A\n\nalpha\n\nB\n===\n\nbravo, folding A hides the same-level Setext B and bravo. collectSectionIndices() only recognizes atx-heading; Setext headings must still act as boundaries even if their own fold controls are out of scope (foldSection.ts:61–68).
  4. P2 — Search results remain invisible inside folded sections. Searching for secret after folding its section returns one match and renders a highlight, but the highlight has no visible layout box and the section remains folded. Active search results should reveal their containing folded sections. TOC navigation to a hidden subheading needs the same treatment (blockSyntax.css:214–218, search/TOC navigation).
  5. P3 — Expanded-toggle hover styling is overridden. The hN:hover > .mu-fold-toggle selectors override .mu-fold-toggle:hover, so expanded-button opacity stays 0.6 instead of the intended 1. This also introduces two new Stylelint no-descending-specificity errors (blockSyntax.css:177–190).

Maintainability/style follow-ups: remove the unused MU_FOLD_MARKER and production-unused isEmptySection, simplify single-use attachment/class wrappers, eliminate the unconditional Parent as AtxHeading projection and duplicate Parent import, and trim comments that restate code or refer to removed call sites. Keep Muya's package-specific four-space/semicolon conventions.

Validation: all 2,362 existing Muya unit tests pass; five added regression scenarios fail (heading conversion, insertion, keyboard navigation, Setext boundaries, search visibility). ESLint has no errors and circular dependency checks pass. Type checking fails identically on the merge base and PR at the pre-existing items.spec.ts Array.at/lib mismatch. Tests reused the workspace's installed dependencies, rather than reinstalling the PR lockfile.

@Jocs

Jocs commented Oct 18, 2026

Copy link
Copy Markdown
Member

Fixed the review findings in e935e2f.

  • Recompute fold visibility when blocks are inserted, removed, or converted, preserve nested folds, and respect Setext heading boundaries. Capture the section tail while folded so a new paragraph created after the section stays visible.
  • Ordinary arrow navigation skips hidden content. Folding moves an existing caret out of the hidden section. Programmatic selection and TOC/anchor navigation reveal their destination before selection or scroll measurement.
  • Match the Feishu Docs editing behavior checked in a temporary document: Enter at the start inserts a paragraph before the folded heading; Enter in the middle/end creates a same-level heading after the whole section; a second Enter creates an editable paragraph outside the fold. Shift+Enter uses an inline <br/> to preserve the single-line ATX Markdown representation. Backspace conversion restores the body; Delete at the title end expands and merges the first paragraph.
  • Find-in-document expands all sections. For Shift+arrow selection across a folded boundary, expose the destination immediately so the selection cannot silently edit invisible content; this is an intentional adaptation of Feishu's delayed reveal.
  • Keep the copy-link control in the left gutter and move the fold control to the right gutter. Fix hover specificity in both states and verify both controls remain within a narrow viewport.
  • Centralize fold visibility/navigation, use a typed toggle attachment, remove unused helpers/constants and redundant comments, and avoid sibling scans in containers without active folds.

Validation passed:

  • Muya: 2,380 unit tests across 275 files.
  • Chromium: 35 new folding interaction tests, plus 8 existing search and undo/redo tests. The matrix covers Enter/Shift+Enter, Backspace/Delete, all four arrows and Shift+arrows, line navigation, Tab/Shift+Tab, copy/cut/paste, select-all deletion, undo/redo, search, TOC reveal, and keyboard activation of the fold control.
  • Desktop TOC navigation/key tests: 13 passed.
  • Muya ESLint (zero errors), declaration build, desktop typecheck, circular-dependency check, and Muya typecheck with ES2022 libraries.

Local verification used the installed workspace dependencies, which are newer than this PR's lockfile, and temporary configs to allow the isolated worktree to access those dependencies. The root lint command remains blocked by the existing ESLint/@Stylistic configuration incompatibility; the changed Vue file passes using the current workspace ESLint config. Default Muya typechecking retains the existing test Array.at/library-target error, and Stylelint retains one pre-existing .mu-content specificity error. The new fold-control Stylelint errors are resolved. CI results are still pending.

Jocs added 2 commits October 19, 2026 06:16
Preserve column-aware arrow navigation alongside fold-aware traversal and update folding search integration for the renamed query parameter.
Use Home/End on Windows and Linux instead of word-navigation shortcuts. Assert the destination caret before typing and compare stale table picks against the post-undo scroll position.
@Jocs

Jocs commented Oct 19, 2026

Copy link
Copy Markdown
Member

@Jocs
Jocs requested a balanced review from Copilot October 19, 2026 04:26

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved findings include a critical lint blocker, missing advertised fold actions and ellipsis UI, and heading coverage and lifecycle correctness issues.

5 open issues

Review effort: Lite

Comment thread packages/muya/src/block/folding.ts
Comment thread packages/muya/src/block/commonMark/atxHeading/foldSection.ts
Comment thread packages/muya/src/block/commonMark/headingFoldToggle/index.ts
Comment thread packages/muya/src/block/commonMark/headingFoldToggle/index.ts Outdated
Comment thread packages/muya/src/block/folding.ts

@Jocs Jocs left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @rkristelijn for contributing heading folding and for getting this feature started.

@Jocs
Jocs merged commit 5f3dbb9 into marktext:develop Oct 19, 2026
17 checks passed
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.

Option to fold/collapse text

3 participants