feat(input): block-editing pipeline foundation#460
Conversation
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
|
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: It demonstrates the intended extension pattern — a new block type is added by implementing the |
|
@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
left a comment
There was a problem hiding this comment.
@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 { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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]; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
Nit: this comment narrates what the code does — the function call below is self-explanatory. Consider removing.
| return inlineMarkdown | ||
| } | ||
|
|
||
| // Plain-text character offset at the start of each line. |
There was a problem hiding this comment.
Nit: the variable name lineStartOffsets already says this. Consider removing.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
Nit: obvious from context, consider removing.
| 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 |
There was a problem hiding this comment.
Nit: the if condition is clear enough on its own. Consider removing.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
Nit: over-explaining a trivial local capture. Consider removing.
- 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>
|
@hryhoriiK97 thanks for the review — everything is addressed, replies on each thread. Summary of the updates:
Verified: ktlint + |
hryhoriiK97
left a comment
There was a problem hiding this comment.
LGTM! Thank you!
@wildseansy are you planning to open the heading handler PR (from wildseansy#1) as a follow-up?
|
@hryhoriiK97 - yep. I'll re-assign base to |
|
@hryhoriiK97 - heading blocks submitted: #497. Subsequent PR here that adds into the toolbar: wildseansy#2 |
…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>
* 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>
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)
ENRMInputStyleType/StyleTypeENRMInputBlockType/BlockType(Paragraph default)ENRMStyleHandler/StyleHandlerENRMBlockHandler/BlockHandler— paragraph attributes, markdown line prefix (parser recognition stays in each parser's central map, mirroring the inline pipeline)ENRMFormattingStore/FormattingStoreENRMBlockStore/BlockStore— generic, line-scoped block ranges +adjustForEditapplyBlockRangesenter_block/leave_block(replaces the no-op TODO); Android: block ranges from the shared AST (C++ untouched)Notes addressing the #455 review
bolddid for inline.enter_block/leave_block(the existing TODO is removed), not a post-pass.MarkdownASTNodethe readonly renderer already emits —MD4CParser.cppis unchanged, so readonly parsing is unaffected.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