Skip to content

feat(input): block-editing pipeline foundation#460

Merged
hryhoriiK97 merged 4 commits into
software-mansion:mainfrom
wildseansy:feat/block-pipeline
Jul 3, 2026
Merged

feat(input): block-editing pipeline foundation#460
hryhoriiK97 merged 4 commits into
software-mansion:mainfrom
wildseansy:feat/block-pipeline

Conversation

@wildseansy

@wildseansy wildseansy commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

What

First PR in the block-editing series requested in #455 (closed in favor of this rebuild). This adds the generic block-editing pipeline foundation for the editable input, mirroring the existing inline-style pipeline (StyleType → FormattingStore → StyleHandler → InputFormatter → serializer/parser). No concrete block type is registered yet — with no handler the editor behaves exactly as today (plain paragraphs, all inline styles unaffected). A follow-up PR plugs the first handler (headings, H1–H6) in without touching the orchestrator.

Abstractions (both platforms)

Inline (existing) Block (this PR)
ENRMInputStyleType / StyleType ENRMInputBlockType / BlockType (Paragraph default)
ENRMStyleHandler / StyleHandler ENRMBlockHandler / BlockHandler — paragraph attributes, markdown line prefix (parser recognition stays in each parser's central map, mirroring the inline pipeline)
ENRMFormattingStore / FormattingStore ENRMBlockStore / BlockStore — generic, line-scoped block ranges + adjustForEdit
formatter style-handler registry formatter block-handler registry (empty here) + applyBlockRanges
serializer inline delimiters block handlers contribute line prefixes (no central switch)
parser inline span mapping iOS: real md4c enter_block/leave_block (replaces the no-op TODO); Android: block ranges from the shared AST (C++ untouched)

Notes addressing the #455 review

  • No monolithic orchestrator logic — blocks plug in via handlers, like bold did for inline.
  • iOS parser uses md4c enter_block/leave_block (the existing TODO is removed), not a post-pass.
  • Android reuses the shared MarkdownASTNode the readonly renderer already emits — MD4CParser.cpp is unchanged, so readonly parsing is unaffected.
  • No silent serializer fallback — the block-aware serializer asserts the line-count invariant instead of silently dropping blocks.

Testing

Both platforms build green (example app, iOS sim + Android emulator). Behavior is inert (byte-for-byte unchanged) with no block handler registered.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EhuNE6PjRGjroxobhdd72q

wildseansy and others added 2 commits June 28, 2026 18:44
Add the generic block-editing abstractions for the input component,
mirroring the inline-style pipeline (StyleType→FormattingStore→StyleHandler
→InputFormatter→serializer/parser). No concrete block type is registered
yet, so behavior is unchanged (plain paragraphs, inline styles intact); a
later change plugs a heading handler in without touching the orchestrator.

- ENRMInputBlockType enum + block paragraph attribute keys
- ENRMBlockRange model (type + range + generic level payload)
- ENRMBlockHandler protocol: paragraph attributes, markdown line prefix,
  md4c block matching — sufficient for headings and list items
- ENRMBlockStore: generic line-scoped block range store with edit adjustment
- InputFormatter: block-handler registry (empty) + applyBlockRanges
- Serializer: block-aware overload; asserts the line-count invariant
  instead of silently dropping blocks
- Parser: real md4c enter_block/leave_block via a kSupportedBlocks table
  (paragraph-only today), replacing the no-op TODO
- Orchestrator: wires a block store through apply/edit/import/export, inert
  until a handler is registered

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhuNE6PjRGjroxobhdd72q
Android mirror of the iOS block-editing foundation: the generic block
abstractions for the input component, paralleling the inline-style pipeline
(StyleType→FormattingStore→StyleHandler→InputFormatter→serializer/parser).
No concrete block type is registered, so behavior is unchanged; a later
change plugs a heading handler in without touching the orchestrator.

- BlockType enum (PARAGRAPH default) + BlockRange model (type/start/end/level)
- BlockHandler interface: createSpans, spanClasses, markdownLinePrefix,
  matchesNodeType — sufficient for headings and list items
- BlockStore: generic line-scoped block range store with edit adjustment
  (reuses FormattingStore's edit-overlap logic)
- InputFormatter: block-handler registry (empty) + applyBlockFormatting
- MarkdownSerializer: block-aware overload; asserts the line-count invariant
  instead of silently dropping blocks
- InputParser: ParseResult.blockRanges from the shared AST (paragraph-only
  today; the C++ parser is unchanged so readonly rendering is unaffected)
- Orchestrator + event emitter: wire a block store through apply/edit/
  import/export, inert until a handler is registered

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhuNE6PjRGjroxobhdd72q
@wildseansy

Copy link
Copy Markdown
Contributor Author

To show how this foundation is meant to be used, here's the first handler (headings, H1–H6) plugging into it as a reference PR, stacked on this branch so the diff is only the heading delta:

wildseansy#1

It demonstrates the intended extension pattern — a new block type is added by implementing the BlockHandler protocol (paragraph styling + markdown line prefix + parser-block matching) and registering it, with no orchestrator changes. Per-level heading sizes are configured via markdownStyle.h1..h6, mirroring the readonly renderer. Verified on both platforms (clean editor text, per-level sizing, single-marker round-trip). Lists would follow the same pattern.

@wildseansy

wildseansy commented Jun 29, 2026

Copy link
Copy Markdown
Contributor Author

@hryhoriiK97 - base pipeline ready for review. See wildseansy#1 for how it's used for headings, and wildseansy#2 for the headings fully integrated into the test app

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

@wildseansy thanks for the PR! I'll try to take a look this week if possible. We have a lot on our plate right now, so I can't make any promises.

- drop the unused handler-matching API (matchesNodeType /
  matchesMd4cBlockType / allBlockHandlers); parser recognition stays in
  each parser's central map, mirroring the inline pipeline
  (nodeTypeToStyleType / kSupportedSpans)
- derive iOS inline + block ranges from a single md4c run (was two full
  parses per import)
- iOS applyBlockRanges: strip the previous pass's attributes (tracked
  via ENRMBlockTypeAttributeName) before re-applying, and seed the
  paragraph style from existing attributes instead of replacing it
- align the serializer line-count invariant contract: log + fall back
  to inline-only output on both platforms instead of crashing release
  Android via check()
- clamp paragraphBoundsForRange unconditionally so out-of-bounds input
  can't reach paragraphRangeForRange: (iOS)
- document the store's non-overlap invariant and insert-at-range-start
  semantics on both platforms

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

private enum class EditOverlap {

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.

adjustForEdit, EditOverlap, and classifyOverlap are copy-pasted from FormattingStore. Any future bug fix to overlap logic would need to be applied in both places. Worth extracting into a shared utility that both stores can delegate to.

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.

Extracted into a shared unit on both platforms in 5019270: RangeEditAdjustment (Kotlin, operates on a MutableRangeBounds interface both range models implement) and ENRMRangeEditAdjustment (iOS). Both stores now delegate; the duplicated EditOverlap/classifyOverlap copies are gone.

* @property level generic integer payload, 0 by default. Headings use it for the
* H-level (1-6); list items will use it for nesting depth.
*/
data class BlockRange(

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 is a data class with mutable var fields, which means equals()/hashCode() depend on mutable state. If a BlockRange ever ends up in a set or as a map key, mutations will silently break lookups. Same pattern as FormattingRange so not new to this PR, but worth keeping in mind.

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.

Converted both BlockRange and FormattingRange to plain classes in 5019270 — bounds mutate as edits shift ranges, so identity semantics are the safe default. No call site relied on structural equality (removeRange receives the store's own instance).

NSRange range = rangeValue.rangeValue;
[textStorage removeAttribute:ENRMBlockTypeAttributeName range:range];
[textStorage removeAttribute:ENRMBlockLevelAttributeName range:range];
[textStorage removeAttribute:NSParagraphStyleAttributeName range:range];

@hryhoriiK97 hryhoriiK97 Jul 3, 2026

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 reset pass strips NSParagraphStyleAttributeName from previously-claimed ranges, so the "seed from existing attributes" on re-apply will always get the default paragraph style rather than whatever was there before. In practice this is probably fine since applyWritingDirection runs after and handlers set absolute values - just something to be aware of.

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.

Good catch — the comment overpromised. Reworded in 5019270: seeding only matters for newly claimed paragraphs; re-claimed ones read the default after the reset pass, which is fine since handlers set absolute values and applyWritingDirection re-derives direction afterwards.

private fun serializeToMarkdown(): String {
val plainText = view.text?.toString() ?: ""
return MarkdownSerializer.serialize(plainText, view.allFormattingRangesForSerialization())
// Each block resolves its markdown line prefix through its registered handler.

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 comment narrates what the code does — the function call below 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 5019270.

return inlineMarkdown
}

// Plain-text character offset at the start of each 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.

Nit: the variable name lineStartOffsets already says this. 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 5019270 (also from the iOS twin).

var runningOffset = 0
for (i in plainLines.indices) {
lineStartOffsets[i] = runningOffset
runningOffset += plainLines[i].length + 1 // +1 for the '\n' separator

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: obvious from context, 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 5019270.

for (lineIndex in plainLines.indices) {
val lineStart = lineStartOffsets[lineIndex]
val lineEnd = lineStart + plainLines[lineIndex].length
// A block claims a line if their ranges intersect (block ranges are

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: the if condition is clear enough on its own. 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 5019270 (also from the iOS twin).

ranges:(NSArray<ENRMFormattingRange *> *)ranges
blockRanges:(NSArray<ENRMBlockRange *> *)blockRanges
{
// The provider runs synchronously inside the serializer call, so capturing

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: over-explaining a trivial local capture. 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 5019270.

- extract the duplicated shift/clip edit-adjustment logic into one
  shared unit per platform (RangeEditAdjustment / ENRMRangeEditAdjustment);
  FormattingStore and BlockStore now delegate to it on both platforms
- convert FormattingRange and BlockRange from data classes to plain
  classes: their bounds mutate as edits shift ranges, so value-based
  equals/hashCode would silently break set/map lookups
- correct the applyBlockRanges seeding comment: re-claimed paragraphs
  read the default style after the reset pass; seeding matters only for
  newly claimed paragraphs
- drop comments that narrate what the adjacent code already says
  (InputEventEmitter, MarkdownSerializer on both platforms,
  EnrichedMarkdownTextInput)

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

Copy link
Copy Markdown
Contributor Author

@hryhoriiK97 thanks for the review — everything is addressed, replies on each thread. Summary of the updates:

5019270 — your review:

  • Extracted the duplicated adjustForEdit/EditOverlap/classifyOverlap logic into one shared unit per platform: RangeEditAdjustment (Kotlin, via a small MutableRangeBounds interface both range models implement) and ENRMRangeEditAdjustment (iOS). FormattingStore and BlockStore now delegate on both platforms — net −91 lines.
  • Converted BlockRange and FormattingRange from data classes to plain classes so equals/hashCode don't depend on mutable bounds. Verified no call site relied on structural equality.
  • Fixed the misleading "seed from existing attributes" comment in applyBlockRanges.
  • Removed all the narrating comments you flagged (plus their identical twins in the iOS serializer).

c3d7992 — earlier self-review pass, in case you're re-reviewing the whole diff:

  • Dropped the unused handler-matching API (matchesNodeType / matchesMd4cBlockType / allBlockHandlers); parser recognition lives in each parser's central map, mirroring the inline pipeline. (The extension-pattern note in my earlier comment above predates this — a new block type now adds one map entry + one handler.)
  • iOS parses markdown once per import instead of twice (block ranges come from the same md4c run as inline spans).
  • iOS block pass now strips its previous attributes before re-applying, so removed blocks can't leave stale paragraph styling.
  • Serializer line-count invariant no longer crashes release Android — both platforms log + fall back to inline-only output.

Verified: ktlint + compileDebugKotlin green, iOS builds clean via xcodebuild, and the maestro smoke suite passes on functional assertions (screenshot-threshold diffs on my machine are iOS-runtime font drift vs the 26.3 baselines, unrelated to this diff).

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

LGTM! Thank you!

@wildseansy are you planning to open the heading handler PR (from wildseansy#1) as a follow-up?

@hryhoriiK97 hryhoriiK97 merged commit 13898e0 into software-mansion:main Jul 3, 2026
9 checks passed
@wildseansy

wildseansy commented Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

@hryhoriiK97 - yep. I'll re-assign base to software:main. Thanks for the review!

@wildseansy

wildseansy commented Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

@hryhoriiK97 - heading blocks submitted: #497. Subsequent PR here that adds into the toolbar: wildseansy#2

wildseansy added a commit to wildseansy/react-native-enriched-markdown that referenced this pull request Jul 3, 2026
…into feat/list-blocks

Rebases the bullet-list work onto the upstream-merged pipeline. The v2 branch
carries main (0.7.3) plus the squashed foundation (software-mansion#460) and heading handler,
including the maintainer refactors the list branch predated — resolutions:

- adopt the shared RangeEditAdjustment / ENRMRangeEditAdjustment and move the
  anchored-block (heading / bullet) persistence out of the old per-store
  switch: stores delegate to the shared adjustment and layer the anchor rules
  around it (zero-length anchors kept at / shifted past / dropped with the
  edit; a block deleted exactly to its end collapses to an anchor at the edit
  location)
- generalize normalizeToLineBounds to keep empty-line anchors of persisting
  block types (BlockType.ANCHORED / ENRMBlockTypePersistsWhenEmpty); it
  supersedes normalizeAnchoredRangesToLines (Android) and
  clipHeadingBlocksToFirstParagraph (iOS)
- iOS dropStaleEmptyBlockAnchors becomes pruneOrphanedBlockAnchors, matching
  Android's prune: any persisting block no longer at a line start reverts its
  merged line to a plain paragraph; this also fixes interior empty-line
  anchors being dropped (the old check required paragraph.length == 0, never
  true for a line with a terminator) and covers the paste path
- adopt the central-map parser recognition (drop matchesNodeType /
  matchesMd4cBlockType from heading + list handlers, drop allBlockHandlers),
  the single md4c run on iOS, and the serializer log-and-fall-back invariant
  (composed with the list ZWSP stripping; also fixes the Kotlin ZWSP constant
  being an escaped literal instead of the U+200B character)
- iOS setBlockType: stores content-only bounds (trims the line terminator),
  matching the parser convention
- keep main-side additions (copyToClipboard command, PasteboardUtils,
  context-menu changes) alongside the list layer

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
hryhoriiK97 pushed a commit that referenced this pull request Jul 4, 2026
* feat(input): heading blocks (H1-H6) on the block pipeline

First concrete block type on the #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>

* refactor(input): address review — scope iOS re-formatting to edited lines, 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>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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