feat(input): heading toolbar support — toggleHeading command, active-state, empty-line persistence#499
Conversation
…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 }; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
Nit: this JSDoc just narrates what the field name and type already say - let's remove it
| text: selectedText, | ||
| selection: { start: selectionStart, end: selectionEnd }, | ||
| styleState, | ||
| // Heading is block-level — not relevant to selection-based menu actions. |
There was a problem hiding this comment.
nit: Let's also remove this comment 🙏
There was a problem hiding this comment.
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. */ |
There was a problem hiding this comment.
Nit: remove - the function name and signature already communicate this.
| strikethrough: { isActive: boolean }; | ||
| spoiler: { isActive: boolean }; | ||
| link: { isActive: boolean }; | ||
| /** Heading level of the cursor's paragraph: 0 = none, 1-6 = H1-H6. */ |
|
|
||
| if (lineRange.length == 0 || (NSInteger)lineRange.location <= previousEnd) { | ||
| // Headings persist on an empty line as a zero-length anchor; other | ||
| // collapsed ranges are dropped. |
| 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). |
| } | ||
|
|
||
| // Only headings persist on an empty line (as a zero-length anchor); other | ||
| // block types have nothing to anchor. |
| #if !TARGET_OS_OSX | ||
| if (newLength == 0) { | ||
| [self resetBaseTypingAttributes]; | ||
| // Keep typing heading-sized when a heading anchor survives the emptied |
| attrs[NSFontAttributeName] = _formatterStyle.baseFont; | ||
| attrs[NSForegroundColorAttributeName] = _formatterStyle.baseTextColor; | ||
| _textView.typingAttributes = attrs; | ||
| // An empty heading line keeps its zero-length anchor — stay |
…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>
|
All review comments addressed in 748e7f2:
Verified: ktlint + 🤖 Generated with Claude Code |
Third piece of the heading/block series, building on #460 and #497.
What
toggleHeading(level)command (JS → both platforms), routed through a generictoggleBlockTypethat mirrors the inlinetoggleInlineStylepath — 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).onChangeStategainsheading.level(0 = plain paragraph, 1–6 = H1–H6) for toolbar active-state, emitted only when the value changes — same dedupe as the inline flags.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.Note on renderer defaults
Sharing the defaults surfaces one deliberate change: default heading
fontWeightmoves 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 inheadingDefaults.ts.Verification
compileDebugKotlingreen.xcodebuildBUILD SUCCEEDED; clang-format clean.getMarkdown()returns# Hello heading\nbody line.🤖 Generated with Claude Code