Skip to content

feat(input): heading blocks (H1-H6) on the block pipeline#497

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

feat(input): heading blocks (H1-H6) on the block pipeline#497
wildseansy wants to merge 2 commits into
software-mansion:mainfrom
wildseansy:feat/heading-blocks-v2

Conversation

@wildseansy

Copy link
Copy Markdown
Contributor

What

Second PR in the block-editing series: the first concrete block type — headings H1–H6 — plugging into the pipeline foundation merged in #460. Follows the extension pattern exactly: one entry in each parser's central block map + one registered handler, no orchestrator changes.

How

Both platforms

  • HeadingBlockHandler / ENRMHeadingBlockHandler — owns heading paragraph styling (per-level font size/weight/color) and the markdown line prefix ("# ""###### "). One handler instance serves all six levels, registered under each HEADING_n key.
  • BlockType.HEADING_1..6 / ENRMInputBlockTypeHeading1..6; parsers map heading nodes centrally — Android nodeTypeToBlockType reads the AST node's level attribute, iOS kSupportedBlocks + resolveBlockLevel reads MD_BLOCK_H_DETAIL.level.
  • Per-level h1h6 style props on the input, mirroring the readonly renderer's markdownStyle so heading sizing is configured consistently across read and edit views (normalized with complete defaults on the JS side).
  • BlockStore gains normalizeToLineBounds (idempotent): after an edit is adjusted, ranges re-snap to whole-line bounds — re-absorbs characters typed at a block's edges, clips a newline-split heading to its first line, and drops blocks orphaned by line joins.

Testing

  • Example app builds green on both platforms (iOS sim via xcodebuild, Android compileDebugKotlin), ktlint/clang-format clean.
  • Manual: type # prefixes, import markdown with H1–H6, edit at heading boundaries, serialize round-trip.

🤖 Generated with Claude Code

First concrete block type on the software-mansion#460 foundation. Adds
HeadingBlockHandler / ENRMHeadingBlockHandler (paragraph styling +
markdown line prefix), maps md4c/AST heading nodes in each parser's
central block map, exposes per-level h1-h6 style props mirroring the
readonly renderer's markdownStyle, and re-normalizes block ranges to
whole-line bounds after edits.

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

@hryhoriiK97 hryhoriiK97 left a comment

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.

Paste doesn't import block ranges

Pasting markdown containing headings (e.g. # Hello) will lose the heading — the paste path only round-trips through InputParserFormattingStore, not BlockStore. This gap becomes user-visible and should be addressed in a follow-up 🙏

text?.let { blockStore.normalizeToLineBounds(it) }
applyPendingStyles(editStart, insertedLength)
applyFormatting()
applyFormattingScopedToEdit(editStart, insertedLength)

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.

Android scopes block re-application to the edited line(s) here via applyFormattingScopedToEdit, but iOS re-applies across the full document on every edit (see EnrichedMarkdownTextInput.mm:668). This should be aligned for performance parity.

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.

Done in fe13ff9. handleTextChanged now calls a scoped variant that re-applies attributes only on the line(s) touched by the edit, mirroring Android's applyFormattingScopedToEdit. One iOS-specific wrinkle: scoping only applyBlockRanges: would have broken headings — the inline pass stamps baseFont across its whole range, so any heading outside the scope would lose its size. Both passes therefore share the same line-expanded scope, and layout invalidation shrinks to it as well (previously it invalidated + ensured layout for the full document every keystroke, which was the bigger cost). Full-range re-apply is kept for import, style changes, and commands — same split as Android.

return [self isValidHeadingLevel:level] ? _headingColors[level] : nil;
}

- (UIFont *)headingFontForLevel:(NSInteger)level

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.

This creates a new UIFont on every call, bypassing the _fontCache already used by fontForTraits: (line 131 in this file). Could we cache heading fonts keyed by level in the same dictionary to avoid repeated allocations?

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.

Done in fe13ff9. Used a per-level _headingFontCache[7] array alongside _fontCache rather than sharing the dictionary — level ints would collide with the traits-bitmask key space. Invalidated together with _fontCache on base-font change, and per level when that level's size/weight config changes.


// Headings h1..h6 share an identical struct shape (fontSize / fontWeight /
// color). Read each level into the formatter style's per-level heading config.
#define ENRM_APPLY_HEADING_PROPS(levelNumber, headingKey) \

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.

The rest of this file (and the input codebase) uses static inline functions for reusable logic (e.g. ENRMAutocapitalizationTypeFromString at line 9, applyInputStyleProps at line 22). A static helper function or a loop over a {level, accessor} mapping would be more consistent while avoiding the readability/debugging drawbacks of multi-line macros.

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.

Done in fe13ff9 — replaced with a static template helper applyHeadingLevelProps (template because the codegen h1..h6 structs are distinct types with an identical shape), matching the existing applyInputStyleProps pattern.

// A handler instance maps to one ENRMInputBlockType, but a single
// ENRMHeadingBlockHandler serves all six heading levels (it dispatches on
// blockRange.level), so the same instance is registered under all six
// heading keys.

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.

This comment narrates what the loop below already shows (same instance registered 6 times). The code is self-explanatory — consider removing.

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 fe13ff9.

/// Representative block type. One instance serves all six heading levels; the
/// formatter registers it under every heading key explicitly rather than relying
/// on this single value, and applyAttributesToParagraphStyle: dispatches on
/// blockRange.level.

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.

This repeats the class-level doc from the .h and explains caller internals that don't belong on a simple property accessor. Consider removing.

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 fe13ff9.

}

// The font is merged onto existing inline runs by the formatter (preserving
// bold/italic traits), so it is set here as the target size/weight only.

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.

Redundant with the mergeFontSize: doc comment in ENRMInputFormatter.mm which already explains this. One or the other is enough.

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 fe13ff9.

if (level < 1 || level > 6) {
return @"";
}
// `#` * level followed by a single space, e.g. "### " for an H3.

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.

The code ([@"" stringByPaddingToLength:level withString:@"#"...]) is self-explanatory. Remove.

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 fe13ff9.

deletedLength:(NSUInteger)deletedLength
insertedLength:(NSUInteger)insertedLength;

/// Re-normalizes every stored range back to the whole-line bounds of the line

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.

10 lines is a lot for a method doc — it reads more like an algorithm tutorial than a contract. Consider shortening to:

/// Snaps every stored range to the line bounds of its start position.
/// Absorbs edge-typed chars, clips split ranges to first line, drops
/// duplicates. Call after adjustForEditAtLocation: once text is final.
/// Idempotent.

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.

Shortened to the suggested wording in fe13ff9.

}
}

// Block-level syntax (e.g. a heading's "# " line prefix) is structural markup,

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.

The doubling bug is non-obvious and worth documenting, but 5 lines is heavy. Shorten to:

// Strip block markers (e.g. "# ") from plain text — same as inline delimiters.
// Without this the marker survives and the serializer doubles 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.

Shortened to the suggested two lines in fe13ff9.

import com.swmansion.enriched.markdown.input.model.InputFormatterStyle

/**
* Sizes (and optionally weights/colors) a heading line in the editor per the

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.

9 lines — could be halved:

/**
 * Sizes and optionally weights/colors a heading line per [InputFormatterStyle.headingStyle].
 * Preserves inline bold/italic via [BOLD_ITALIC_MASK] so emphasis composes with heading weight.
 * Tagged [MarkdownSpan] for formatter cleanup.
 */

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.

Applied the suggested doc in fe13ff9.

try {
formattingStore.adjustForEdit(editStart, deletedLength, insertedLength)
blockStore.adjustForEdit(editStart, deletedLength, insertedLength)
// Blocks are line-scoped: snap ranges back to whole-line bounds so

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.

This 3-line comment is duplicated verbatim at all 3 normalizeToLineBounds call sites (here + iOS x2). The method name + its doc comment are enough context at the call site — remove these.

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 at all three call sites in fe13ff9.


[_formattingStore adjustForEditAtLocation:editLocation deletedLength:selection.length insertedLength:text.length];
[_blockStore adjustForEditAtLocation:editLocation deletedLength:selection.length insertedLength:text.length];
// Blocks are line-scoped: snap ranges back to whole-line bounds so characters

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.

Duplicate of the comment at the other call site and on Android. The method doc covers it — remove.

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 fe13ff9.


[_formattingStore adjustForEditAtLocation:editLocation deletedLength:deletedLength insertedLength:insertedLength];
[_blockStore adjustForEditAtLocation:editLocation deletedLength:deletedLength insertedLength:insertedLength];
// Blocks are line-scoped: snap ranges back to whole-line bounds so characters

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.

Same duplicate — remove.

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 fe13ff9.

…ines, cache heading fonts

- iOS now re-applies inline + block attributes only on the line(s) touched
  by an edit (parity with Android's applyFormattingScopedToEdit). Scoping
  the block pass alone would break: the inline pass stamps baseFont over
  its range, so both passes share the same line-expanded scope. Layout
  invalidation shrinks to the scope as well.
- Cache heading fonts per level in ENRMInputFormatterStyle, invalidated
  with the trait font cache on base-font change and per level on config
  change.
- Replace ENRM_APPLY_HEADING_PROPS macro with a template helper, matching
  the file's existing applyInputStyleProps pattern.
- Trim/remove narrating and duplicated comments flagged in review.

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

wildseansy commented Jul 4, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review @hryhoriiK97!

Review comments addressed in fe13ff9:

  • iOS/Android perf parity — iOS now scopes per-keystroke re-formatting (inline + block passes, and layout invalidation) to the line(s) touched by the edit, mirroring Android's applyFormattingScopedToEdit. Both passes had to share the scope: the inline pass stamps baseFont over its range, so scoping only the block pass would have dropped heading sizes outside the edited line. Full-document re-apply remains for import, style changes, and commands.
  • Heading font cache — per-level cache in ENRMInputFormatterStyle, invalidated with the trait cache on base-font change and per level on config change.
  • Macro → helperENRM_APPLY_HEADING_PROPS replaced by a static template function following the applyInputStyleProps pattern.
  • Comment cleanup — all flagged comments removed or shortened to the suggested wording.

Paste gap — confirmed: pasteMarkdown (iOS) and the Android paste path only feed FormattingStore, so pasted # Hello loses the heading. Agreed on handling it in a follow-up PR; happy to pick that up next.

Verified: ktlint + compileDebugKotlin green, example app xcodebuild succeeds, and a simulator run importing # Big Title / **bold** / ## Subheading then typing inside a heading line confirms the scoped path re-stamps the edited line while untouched lines keep their heading/inline styling.

🤖 Generated with Claude Code

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