Trigger Points
Merge is triggered by two keyboard events in blockEvents.ts:
Backspace at the start of a block → merges previousBlock ← currentBlock
Delete at the end of a block → merges currentBlock ← nextBlock
Before attempting a merge, both paths handle simpler cases first:
If the neighboring block is empty → just remove it (no merge needed)
If the current block is empty → remove current, move caret
Mergeability Check (areBlocksMergeable in utils/blocks.ts)
A merge is allowed only if:
The target block (the one being merged into) has a merge() method — i.e., targetBlock.mergeable === true
Then one of two conditions:
Same tool type: both blocks use the same tool (tool handles its own format)
Different tool types: blockToMerge has a valid export conversion config AND targetBlock has a valid import conversion config
Note: even if conversion is possible in both directions, if the target has no merge() method, the merge is blocked (just navigates instead). This avoids unexpected block deletion/recreation.
Actual Merge Execution (BlockManager.mergeBlocks)
Two code paths:
Path 1 — Same tool type:
Code
blockToMerge.data → sanitize → targetBlock.mergeWith(data)
Extracts raw data from blockToMerge
Sanitizes it using targetBlock's sanitize config
Calls targetBlock.mergeWith(data) → which calls toolInstance.merge(data)
Path 2 — Different tool types (cross-tool merge via conversion):
Code
blockToMerge.exportDataAsString() → sanitize (plain text) → convertStringToBlockData() → targetBlock.mergeWith(data)
Exports blockToMerge's data as a plain string (via its export conversion config)
Sanitizes as plain text
Converts that string into targetBlock's data format (via its import conversion config)
Calls targetBlock.mergeWith(data)
After either path, blockToMerge is removed and currentBlockIndex is updated to targetBlock.
Caret Handling
Before calling BlockManager.mergeBlocks, blockEvents.mergeBlocks focuses the last input of the targetBlock (using focus(targetBlock.lastInput, false)) so caret ends up at the right position after the merge. The toolbar is closed on completion.
Block-level API (Block.mergeable / Block.mergeWith)
block.mergeable → true if toolInstance.merge is a function
block.mergeWith(data) → simply calls await toolInstance.merge(data) — the tool's own implementation is responsible for combining the data into its DOM/state
Summary Flow
Code
Backspace/Delete at boundary
↓
areBlocksMergeable(target, source)?
├─ No → just move caret
└─ Yes
↓
Same tool? → extract data, sanitize, mergeWith()
Diff tools? → export→string, sanitize, import→data, mergeWith()
↓
Remove source block, update currentBlockIndex
Should be implemented in the BlockManager and exposed in the BlocksAPI
How it works in the v2: