Skip to content

feat(input): heading toolbar support — toggleHeading command, active-state, empty-line persistence#499

Open
wildseansy wants to merge 2 commits into
software-mansion:mainfrom
wildseansy:feat/heading-toolbar-v2
Open

feat(input): heading toolbar support — toggleHeading command, active-state, empty-line persistence#499
wildseansy wants to merge 2 commits into
software-mansion:mainfrom
wildseansy:feat/heading-toolbar-v2

Conversation

@wildseansy

Copy link
Copy Markdown
Contributor

Third piece of the heading/block series, building on #460 and #497.

What

  • toggleHeading(level) command (JS → both platforms), routed through a generic toggleBlockType that mirrors the inline toggleInlineStyle path — future block toggles (lists, blockquotes) reuse it. Toggling the active level reverts the paragraph; a multi-paragraph selection sets one block per paragraph (blocks are single-paragraph by the store's line normalization).
  • onChangeState gains heading.level (0 = plain paragraph, 1–6 = H1–H6) for toolbar active-state, emitted only when the value changes — same dedupe as the inline flags.
  • Empty-line heading persistence: deleting a heading's text keeps the line a heading via a zero-length anchor in the BlockStore; typing attributes stay heading-sized so the caret and next characters render correctly. A line merge (Backspace at line start) prunes headings no longer anchored at a line start — the prune runs before line normalization so a merged range can't grow over the line it merged into.
  • Shared heading defaults (headingDefaults.ts): size/weight/color defaults now come from one module used by both the read-only renderer and the input, replacing the input's duplicated table and the iOS hardcoded scale fallback.
  • Example app: H1–H6 toolbar buttons (horizontal scroller); INPUT.md/API_REFERENCE.md updated.

Note on renderer defaults

Sharing the defaults surfaces one deliberate change: default heading fontWeight moves from '' (inherit) to 'bold' in the read-only renderer as well, so read and edit views match. If you'd rather keep the renderer's current default untouched, happy to flip the shared default to '' instead — one-line change in headingDefaults.ts.

Verification

  • Android: ktlint + compileDebugKotlin green.
  • iOS: example app xcodebuild BUILD SUCCEEDED; clang-format clean.
  • TypeScript green.
  • Simulator (iPhone 17): typed text → H1 toggle renders + button active-state; switch H1→H2; toggle-off reverts to paragraph; Enter after a heading yields a plain body line; getMarkdown() returns # Hello heading\nbody line.

🤖 Generated with Claude Code

…mpty-line persistence

- toggleHeading(level) command on both platforms, routed through a generic
  block toggle (toggleBlockType) that mirrors the inline toggle path.
- onChangeState now carries heading.level (0 = paragraph) for toolbar
  active-state; emitted only when it changes.
- Empty-line heading persistence: an emptied heading line keeps a zero-length
  anchor in the store so the line stays a heading (typing attributes stay
  heading-sized); a line merge prunes headings no longer anchored at a line
  start before normalization.
- Heading defaults (size/weight/color) extracted to a shared headingDefaults
  module used by both the read-only renderer and the input, replacing the
  hardcoded iOS scale fallback.
- Example toolbar gains H1-H6 buttons in a horizontal scroller; docs updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
spoiler: { isActive: boolean };
link: { isActive: boolean };
// Heading level of the cursor's paragraph: 0 = none (paragraph), 1-6 = H1-H6.
heading: { level: CodegenTypes.Int32 };

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Every other field in StyleState uses { isActive: boolean }, but heading uses { level: number } with 0 as an implicit "not active" sentinel. Consider { isActive: boolean; level: number } so toolbar code can uniformly check .isActive.

Also, the context menu callback strips heading to { level: 0 } - consider passing the real heading level and letting the consumer decide whether it's relevant. A custom menu action might need the block context.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Both done in 748e7f2. heading is now { isActive: boolean; level: number } everywhere (onChangeState, StyleState, context-menu styleState, both native emitters), and the context-menu callback passes the cursor paragraph's real heading state through instead of stripping it to 0.

strikethrough: { isActive: boolean };
spoiler: { isActive: boolean };
link: { isActive: boolean };
// Heading level of the cursor's paragraph: 0 = none (paragraph), 1-6 = H1-H6.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nit: this JSDoc just narrates what the field name and type already say - let's remove it

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed in 748e7f2.

text: selectedText,
selection: { start: selectionStart, end: selectionEnd },
styleState,
// Heading is block-level — not relevant to selection-based menu actions.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: Let's also remove this comment 🙏

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed in 748e7f2 (the strip itself is gone too — real heading state passes through now).

toggleUnderline: () => void;
toggleStrikethrough: () => void;
toggleSpoiler: () => void;
/** Toggle a heading level (1-6) on the cursor's paragraph; the same level toggles back to a paragraph. */

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nit: remove - the function name and signature already communicate this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed in 748e7f2.

strikethrough: { isActive: boolean };
spoiler: { isActive: boolean };
link: { isActive: boolean };
/** Heading level of the cursor's paragraph: 0 = none, 1-6 = H1-H6. */

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🧹

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed in 748e7f2.


if (lineRange.length == 0 || (NSInteger)lineRange.location <= previousEnd) {
// Headings persist on an empty line as a zero-length anchor; other
// collapsed ranges are dropped.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🧹

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed in 748e7f2.

removeIndexesInReverse(_ranges, indexesToRemove);

// Prune zero-length ranges, but keep zero-length headings: they anchor an
// emptied-but-still-present heading line (see the collapse rule above).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🧹

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed in 748e7f2.

}

// Only headings persist on an empty line (as a zero-length anchor); other
// block types have nothing to anchor.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🧹

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed in 748e7f2.

#if !TARGET_OS_OSX
if (newLength == 0) {
[self resetBaseTypingAttributes];
// Keep typing heading-sized when a heading anchor survives the emptied

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🧹

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed in 748e7f2.

attrs[NSFontAttributeName] = _formatterStyle.baseFont;
attrs[NSForegroundColorAttributeName] = _formatterStyle.baseTextColor;
_textView.typingAttributes = attrs;
// An empty heading line keeps its zero-length anchor — stay

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🧹

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed in 748e7f2.

…l level in context menu

- StyleState/onChangeState heading is now { isActive, level }, matching the
  { isActive } shape of the inline entries.
- onContextMenuItemPress styleState includes the cursor paragraph's real
  heading state instead of a stripped level 0.
- Remove comments flagged in review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@wildseansy

Copy link
Copy Markdown
Contributor Author

All review comments addressed in 748e7f2:

  • Heading state shapeheading is now { isActive: boolean; level: number } across onChangeState, StyleState, and the context-menu styleState, matching the { isActive } shape of the inline entries. Both native emitters set isActive = level > 0.
  • Context menu — passes the cursor paragraph's real heading state instead of stripping it to level: 0.
  • Comments — all flagged comments removed.

Verified: ktlint + compileDebugKotlin green, TypeScript green, iOS example xcodebuild BUILD SUCCEEDED (codegen regenerated for the event shape change).

🤖 Generated with Claude Code

@wildseansy wildseansy requested a review from hryhoriiK97 July 4, 2026 23:23
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.

2 participants