Definition
This is a pattern used in an editor where multiple "blocks" — paragraphs, images, tables — are stacked, when you want to let the user drag-select across several blocks and then delete, bold, or replace-by-typing that range. It is tricky because the rich-text editing engine is attached only to the single block where the cursor currently sits, so there is no owner to handle a selection that crosses block boundaries.
To be precise, Cross-block text range operations is a pattern in a hybrid editor where the document is owned by the host (the upper component that owns the whole document state) as a block tree, and the rich-text engine mounts on only the focused block, applying delete, mark (formatting like bold/italic), and insertion to a native text selection the user made across multiple blocks (the drag-created selection region the browser manages by default). Instead of storing the range in React state, it derives it statelessly from window.getSelection() (the browser API that returns the current selection) at operation time, then reflects it through host patches (change fragments that partially mutate state).
Why it matters
A single ProseMirror/TipTap (representative rich-text editor engines) document has one selection owner. When a guest (an editor engine instance mounted briefly on only one block) edits a single block, there is no owning editor instance for a selection that crosses block boundaries. Yet the browser Selection API already expresses cross-node ranges (selections spanning multiple DOM nodes). Mirroring the range (duplicating it separately into React state) causes drift (the two values diverging) from the native selection, and it breaks easily during IME (composing input for Korean, Japanese, etc.) composition and re-renders.
How it works
- The user drag-selects across static blocks and guest blocks.
- On
selectionchange(the browser event fired whenever the selection changes) or keydown, convert the anchor/focus DOM node (the selection's start/end points) into (blockId, inlineOffset). - If
isCrossBlock, a document-level handler runs instead of the guest'sonKeyDown. - Keep the inline-content split logic at block boundaries (splitting text into two pieces at the selection boundary) in one module — reused by delete·addMark·removeMark·slice (copying only the range).
- Deletion (Backspace) is often a merge: combine the first block's prefix + the last block's suffix into the first block and remove the intermediate blocks.
- After patches apply, a re-render of the static DOM can drop the native selection → reverse-map offsets to DOM positions and re-select in
requestAnimationFrame(the browser API that runs a callback right before the next paint). - Single-block internal selection is handled by the existing guest — intercept only when cross-block.
Practical application
- Maintain DOM position ↔ logical offset conversion helpers in both directions.
- Read the derived content of blocks that have only
text, as inblockInlineContent(block)— beware the rawcontentundefined trap. - The floating format toolbar reuses the single-block UI, and only routes its callbacks to patch operations when cross-block.
- Enter on cross-block selection: state the product rule explicitly, e.g. a policy that only merges rather than splits.
- copy/cut/paste is a natural next step by serializing the slice + reusing the existing clipboard pipeline.
Trade-offs
- Not building a new global selection model reduces synchronization burden.
- In exchange, the per-block patch·boundary split·selection restore logic becomes complex.
- Guest transactions and the patch path coexist — strictly isolate single-block regressions with guards.
When not to use it
- The whole document is a single contenteditable / single PM doc — default selection·commands suffice.
- Blocks are always one line of plain text with no marks — range operations can be simplified.
- Collaborative OT/CRDT (algorithms that merge concurrent edits from multiple users without conflict) holds selection state as server authority — needs a separate protocol.
Common mistakes
- Storing from/to in React state during the drag, causing drift from native.
- Duplicating the boundary-split math for every delete·mark.
- Running both the guest keymap and the document handler on cross-block.
- The toolbar disappearing after mark application without selection restore.
- Range iteration that conflicts with team lint like
for...of(unify with reduce, etc.).
Related concepts
- focused-guest-editor — guest single-block lease
- selectionchange-floating-ui — toolbar trigger·ownership
- two-stack-inverse-undo — patch inverse-operation undo
- fixed-position-floating-ui-scroll — toolbar positioning