feat(input): heading blocks (H1-H6) on the block pipeline#497
feat(input): heading blocks (H1-H6) on the block pipeline#497wildseansy wants to merge 2 commits into
Conversation
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>
There was a problem hiding this comment.
Paste doesn't import block ranges
Pasting markdown containing headings (e.g. # Hello) will lose the heading — the paste path only round-trips through InputParser → FormattingStore, 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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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) \ |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
This comment narrates what the loop below already shows (same instance registered 6 times). The code is self-explanatory — consider removing.
| /// 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. |
There was a problem hiding this comment.
This repeats the class-level doc from the .h and explains caller internals that don't belong on a simple property accessor. Consider removing.
| } | ||
|
|
||
| // 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. |
There was a problem hiding this comment.
Redundant with the mergeFontSize: doc comment in ENRMInputFormatter.mm which already explains this. One or the other is enough.
| if (level < 1 || level > 6) { | ||
| return @""; | ||
| } | ||
| // `#` * level followed by a single space, e.g. "### " for an H3. |
There was a problem hiding this comment.
The code ([@"" stringByPaddingToLength:level withString:@"#"...]) is self-explanatory. Remove.
| deletedLength:(NSUInteger)deletedLength | ||
| insertedLength:(NSUInteger)insertedLength; | ||
|
|
||
| /// Re-normalizes every stored range back to the whole-line bounds of the line |
There was a problem hiding this comment.
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.There was a problem hiding this comment.
Shortened to the suggested wording in fe13ff9.
| } | ||
| } | ||
|
|
||
| // Block-level syntax (e.g. a heading's "# " line prefix) is structural markup, |
There was a problem hiding this comment.
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 ("# # ").There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
*/| try { | ||
| formattingStore.adjustForEdit(editStart, deletedLength, insertedLength) | ||
| blockStore.adjustForEdit(editStart, deletedLength, insertedLength) | ||
| // Blocks are line-scoped: snap ranges back to whole-line bounds so |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
Duplicate of the comment at the other call site and on Android. The method doc covers it — remove.
|
|
||
| [_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 |
There was a problem hiding this comment.
Same duplicate — remove.
…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>
|
Thanks for the review @hryhoriiK97! Review comments addressed in fe13ff9:
Paste gap — confirmed: Verified: ktlint + 🤖 Generated with Claude Code |
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 eachHEADING_nkey.BlockType.HEADING_1..6/ENRMInputBlockTypeHeading1..6; parsers map heading nodes centrally — AndroidnodeTypeToBlockTypereads the AST node'slevelattribute, iOSkSupportedBlocks+resolveBlockLevelreadsMD_BLOCK_H_DETAIL.level.h1–h6style props on the input, mirroring the readonly renderer'smarkdownStyleso heading sizing is configured consistently across read and edit views (normalized with complete defaults on the JS side).BlockStoregainsnormalizeToLineBounds(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
compileDebugKotlin), ktlint/clang-format clean.#prefixes, import markdown with H1–H6, edit at heading boundaries, serialize round-trip.🤖 Generated with Claude Code