diff --git a/apps/playground/src/playground-machine.ts b/apps/playground/src/playground-machine.ts index 816117182..a176a2117 100644 --- a/apps/playground/src/playground-machine.ts +++ b/apps/playground/src/playground-machine.ts @@ -420,6 +420,29 @@ export const playgroundMachine = setup({ }) }, }), + 'apply decoration shifts': assign({ + rangeDecorations: ({context, event}) => { + assertEvent(event, 'editor.mutation') + const shifts = event.rangeDecorationShifts + if (!shifts || shifts.length === 0) return context.rangeDecorations + + return context.rangeDecorations.flatMap((rangeDecoration) => { + const shift = shifts.find( + (s) => s.rangeDecoration.id === rangeDecoration.id, + ) + if (!shift) return [rangeDecoration] + + if (!shift.newSelection) return [] + + return [ + { + ...rangeDecoration, + selection: shift.newSelection, + }, + ] + }) + }, + }), }, actors: { 'editor machine': editorMachine, @@ -452,6 +475,7 @@ export const playgroundMachine = setup({ 'update patch-derived value', 'broadcast value', 'add to patch feed', + 'apply decoration shifts', ], }, 'clear patches': { diff --git a/apps/playground/src/range-decoration-button.tsx b/apps/playground/src/range-decoration-button.tsx index 5e42c19ad..cf4f99828 100644 --- a/apps/playground/src/range-decoration-button.tsx +++ b/apps/playground/src/range-decoration-button.tsx @@ -174,26 +174,7 @@ export function RangeDecorationButton(props: { selection, payload: {originalText}, onMoved: (details) => { - // Always notify parent for state management - props.onRangeDecorationMoved(details) - - // If fix-up is enabled and this is a remote op, try to re-resolve - if ( - remoteFixUpRef.current && - details.origin === 'remote' && - details.newSelection !== null - ) { - const currentValue = editor.getSnapshot().context.value - const storedText = details.rangeDecoration.payload?.originalText as - | string - | undefined - if (storedText) { - const resolved = findTextInDocument(currentValue, storedText) - if (resolved) { - return resolved - } - } - } + console.debug('[RangeDecoration] onMoved', details) }, }) editor.send({ diff --git a/packages/editor/package.json b/packages/editor/package.json index 91a557479..30322ed8c 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -1,6 +1,6 @@ { "name": "@portabletext/editor", - "version": "6.5.2-canary.0", + "version": "6.0.1-canary.4", "description": "Portable Text Editor made in React", "keywords": [ "sanity", diff --git a/packages/editor/src/editor/range-decorations-machine.ts b/packages/editor/src/editor/range-decorations-machine.ts index e830a6d54..c4a767800 100644 --- a/packages/editor/src/editor/range-decorations-machine.ts +++ b/packages/editor/src/editor/range-decorations-machine.ts @@ -1,4 +1,4 @@ -import {isTextBlock} from '@portabletext/schema' +import {isSpan, isTextBlock} from '@portabletext/schema' import { and, assign, @@ -14,12 +14,17 @@ import { moveRangeBySplitAwareOperation, } from '../internal-utils/move-range-by-operation' import {slateRangeToSelection} from '../internal-utils/slate-utils' -import {toSlateRange} from '../internal-utils/to-slate-range' +import { + toSlateRange, + toSlateSelectionPoint, +} from '../internal-utils/to-slate-range' import type {Node, NodeEntry} from '../slate/interfaces/node' import type {Operation} from '../slate/interfaces/operation' +import type {Point} from '../slate/interfaces/point' import type {Range} from '../slate/interfaces/range' import {pathEquals} from '../slate/path/path-equals' import {comparePoints} from '../slate/point/compare-points' +import {transformPoint} from '../slate/point/transform-point' import {isCollapsedRange} from '../slate/range/is-collapsed-range' import {isExpandedRange} from '../slate/range/is-expanded-range' import {isRange} from '../slate/range/is-range' @@ -151,6 +156,92 @@ function mergeRangeDecorations( } } +function getSlateBlockStartPoint( + children: Node[], + schema: EditorSchema, + blockIndex: number, +): Point | null { + const block = children[blockIndex] + if (!block || !isTextBlock({schema}, block)) { + return null + } + return {path: [blockIndex, 0], offset: 0} +} + +function getSlateBlockEndPoint( + children: Node[], + schema: EditorSchema, + blockIndex: number, +): Point | null { + const block = children[blockIndex] + if (!block || !isTextBlock({schema}, block)) { + return null + } + const lastChildIdx = block.children.length - 1 + if (lastChildIdx < 0) { + return null + } + const lastChild = block.children[lastChildIdx] + const offset = isSpan({schema}, lastChild) ? lastChild.text.length : 0 + return {path: [blockIndex, lastChildIdx], offset} +} + +/** + * When a block-level `remove_node` invalidates only one endpoint of a + * multi-block decoration, salvage the decoration by clamping the lost + * endpoint to the nearest surviving text-block boundary. + */ +function salvagePartiallyInvalidatedRange( + slateRange: Range, + operation: Operation, + children: Node[], + schema: EditorSchema, +): Range | null { + if (operation.type !== 'remove_node' || operation.path.length !== 1) { + return null + } + + const removedBlockIndex = operation.path[0] + if (removedBlockIndex === undefined) { + return null + } + + const anchorOnRemoved = slateRange.anchor.path[0] === removedBlockIndex + const focusOnRemoved = slateRange.focus.path[0] === removedBlockIndex + + if (anchorOnRemoved === focusOnRemoved) { + return null + } + + if (anchorOnRemoved) { + const newFocus = transformPoint(slateRange.focus, operation) + if (!newFocus) { + return null + } + const anchorBeforeFocus = removedBlockIndex < slateRange.focus.path[0]! + const clampedAnchor = anchorBeforeFocus + ? getSlateBlockStartPoint(children, schema, removedBlockIndex) + : getSlateBlockEndPoint(children, schema, removedBlockIndex - 1) + if (!clampedAnchor) { + return null + } + return {anchor: clampedAnchor, focus: newFocus} + } + + const newAnchor = transformPoint(slateRange.anchor, operation) + if (!newAnchor) { + return null + } + const focusBeforeAnchor = removedBlockIndex < slateRange.anchor.path[0]! + const clampedFocus = focusBeforeAnchor + ? getSlateBlockStartPoint(children, schema, removedBlockIndex) + : getSlateBlockEndPoint(children, schema, removedBlockIndex - 1) + if (!clampedFocus) { + return null + } + return {anchor: newAnchor, focus: clampedFocus} +} + export const rangeDecorationsMachine = setup({ types: { context: {} as { @@ -385,6 +476,18 @@ export const rangeDecorationsMachine = setup({ ) } + // When a block-level remove_node invalidated only one endpoint, + // clamp the lost endpoint to the nearest surviving text-block + // boundary instead of dropping the entire decoration. + if (newRange === null) { + newRange = salvagePartiallyInvalidatedRange( + slateRange, + event.operation, + context.slateEditor.children, + context.schema, + ) + } + // Detect intermediate merge state: mergeContext is active and at // least one point is still on the deleted block (hasn't been mapped // to the target yet). In this state, slateRangeToSelection would @@ -550,7 +653,7 @@ export const rangeDecorationsMachine = setup({ previousSelection ?? rangeDecoration.selection // Re-resolve from EditorSelection keys against current document - const freshSlateRange = toSlateRange({ + let freshSlateRange = toSlateRange({ context: { schema: context.schema, value: context.slateEditor.children, @@ -559,6 +662,50 @@ export const rangeDecorationsMachine = setup({ blockIndexMap: context.slateEditor.blockIndexMap, }) + // When full resolution fails, try resolving each endpoint + // independently and clamping the missing one to the nearest + // surviving text-block boundary. + if (!isRange(freshSlateRange) && selectionForResolution) { + const snapshot = { + context: { + schema: context.schema, + value: context.slateEditor.children, + }, + blockIndexMap: context.slateEditor.blockIndexMap, + } + const isBackward = selectionForResolution.backward === true + const anchorPoint = toSlateSelectionPoint( + snapshot, + selectionForResolution.anchor, + isBackward ? 'forward' : 'backward', + ) + const focusPoint = toSlateSelectionPoint( + snapshot, + selectionForResolution.focus, + isBackward ? 'backward' : 'forward', + ) + + if (anchorPoint && !focusPoint) { + const clampedFocus = getSlateBlockEndPoint( + context.slateEditor.children, + context.schema, + anchorPoint.path[0]!, + ) + if (clampedFocus) { + freshSlateRange = {anchor: anchorPoint, focus: clampedFocus} + } + } else if (focusPoint && !anchorPoint) { + const clampedAnchor = getSlateBlockStartPoint( + context.slateEditor.children, + context.schema, + focusPoint.path[0]!, + ) + if (clampedAnchor) { + freshSlateRange = {anchor: clampedAnchor, focus: focusPoint} + } + } + } + if (!isRange(freshSlateRange)) { if (previousRange !== null) { const shiftDetails = { @@ -800,11 +947,16 @@ export const rangeDecorationsMachine = setup({ 'ready': { initial: 'idle', on: { - 'range decorations updated': { - target: '.idle', - guard: 'has different decorations', - actions: ['update range decorations', 'update decorate'], - }, + 'range decorations updated': [ + { + target: '.idle', + guard: 'has different decorations', + actions: ['update range decorations', 'update decorate'], + }, + { + actions: ['update decorate'], + }, + ], }, states: { 'idle': { diff --git a/packages/editor/src/internal-utils/apply-merge-node.ts b/packages/editor/src/internal-utils/apply-merge-node.ts index b07e1bcfd..24880cfd3 100644 --- a/packages/editor/src/internal-utils/apply-merge-node.ts +++ b/packages/editor/src/internal-utils/apply-merge-node.ts @@ -85,6 +85,16 @@ export function applyMergeNode( // Pre-transform decoratedRanges and snapshot original ranges so we can // fire onMoved after the decomposed operations complete. + // + // For block-level merges without mergeContext (e.g. cross-block selection + // delete via Slate's deleteFragment), an endpoint that was on the merged + // block would naturally map into the target block — placing it at a + // position that expands after undo. Instead, clamp it to the nearest + // surviving block boundary, mirroring salvagePartiallyInvalidatedRange. + const isBlockLevelMerge = path.length === 1 + const shouldClampCrossBoundary = isBlockLevelMerge && !editor.mergeContext + const mergedBlockIndex = path[0]! + const decorationSnapshots: Array<{ decoratedRange: DecoratedRange originalRange: Range @@ -94,8 +104,31 @@ export function applyMergeNode( editor.decoratedRanges = editor.decoratedRanges.map((dr) => { if (!isRange(dr)) return dr - const newAnchor = transformPointForMerge(dr.anchor, path, position) - const newFocus = transformPointForMerge(dr.focus, path, position) + let newAnchor = transformPointForMerge(dr.anchor, path, position) + let newFocus = transformPointForMerge(dr.focus, path, position) + + if (shouldClampCrossBoundary) { + const anchorWasOnMerged = dr.anchor.path[0] === mergedBlockIndex + const focusWasOnMerged = dr.focus.path[0] === mergedBlockIndex + + if (anchorWasOnMerged && !focusWasOnMerged) { + if (mergedBlockIndex < dr.focus.path[0]!) { + // Anchor before focus → clamp forward to next surviving block. + // After the decomposed remove_node, the block at + // mergedBlockIndex + 1 shifts down to mergedBlockIndex. + newAnchor = {path: [mergedBlockIndex, 0], offset: 0} + } else { + newAnchor = clampToBlockEnd(editor, mergedBlockIndex - 1) ?? newAnchor + } + } else if (focusWasOnMerged && !anchorWasOnMerged) { + if (mergedBlockIndex < dr.anchor.path[0]!) { + // Focus before anchor → clamp forward to next surviving block. + newFocus = {path: [mergedBlockIndex, 0], offset: 0} + } else { + newFocus = clampToBlockEnd(editor, mergedBlockIndex - 1) ?? newFocus + } + } + } if (pointEquals(newAnchor, dr.anchor) && pointEquals(newFocus, dr.focus)) { return dr @@ -301,6 +334,23 @@ function transformTextDiffForMerge( } } +function clampToBlockEnd( + editor: PortableTextSlateEditor, + blockIndex: number, +): Point | null { + const block = editor.children[blockIndex] + if (!block || !isTextBlock({schema: editor.schema}, block)) { + return null + } + const lastIdx = block.children.length - 1 + const lastChild = block.children[lastIdx] + const endOffset = + lastChild && isSpan({schema: editor.schema}, lastChild) + ? lastChild.text.length + : 0 + return {path: [blockIndex, Math.max(lastIdx, 0)], offset: endOffset} +} + /** * Transform a path for a merge_node operation. * diff --git a/packages/editor/src/internal-utils/pre-transform-decorations-for-history.ts b/packages/editor/src/internal-utils/pre-transform-decorations-for-history.ts index ba51f80d4..d3fce8f64 100644 --- a/packages/editor/src/internal-utils/pre-transform-decorations-for-history.ts +++ b/packages/editor/src/internal-utils/pre-transform-decorations-for-history.ts @@ -1,16 +1,11 @@ import {isSpan, isTextBlock} from '@portabletext/schema' import type {DecoratedRange} from '../editor/range-decorations-machine' import type {UndoStep} from '../editor/undo-step' -import {slateRangeToSelection} from '../internal-utils/slate-utils' import type {Point} from '../slate/interfaces/point' import {isAfterPoint} from '../slate/point/is-after-point' import {pointEquals} from '../slate/point/point-equals' import {isRange} from '../slate/range/is-range' -import type {EditorSelection} from '../types/editor' -import { - pushDecorationShift, - type PortableTextSlateEditor, -} from '../types/slate-editor' +import type {PortableTextSlateEditor} from '../types/slate-editor' import {transformPointForMerge} from './apply-merge-node' import {transformPointForSplit} from './apply-split-node' @@ -126,11 +121,9 @@ function buildTransformChain( /** * Pre-transforms `editor.decoratedRanges` for an undo or redo operation - * using stored split/merge context. Always suppresses decoration sendback - * when a structural context exists, mirroring `applySplitNode` and - * `applyMergeNode`. Returns a cleanup function that restores sendback, - * re-resolves all decoration selections, and fires `onMoved` callbacks - * for decorations whose position actually changed. + * using stored split/merge context. Suppresses decoration sendback while + * the history ops run, mirroring `applySplitNode` and `applyMergeNode`. + * Returns a cleanup function that restores sendback. */ export function preTransformDecorationsForHistory( editor: PortableTextSlateEditor, @@ -143,20 +136,15 @@ export function preTransformDecorationsForHistory( const transform = buildTransformChain(editor, step, direction) if (!transform) { - if (hasChildNodeOpsDestroyingPoints(step, direction)) { + if ( + hasBlockLevelAddOps(step, direction) || + hasChildNodeOpsDestroyingPoints(step, direction) + ) { return blockOffsetFallback(editor) } return () => {} } - // Track ALL decorations — both moved and unmoved — so the cleanup can - // re-resolve selections and fire onMoved only for actual changes. - const tracked: Array<{ - decoratedRange: DecoratedRange - originalSelection: EditorSelection - moved: boolean - }> = [] - const newDecoratedRanges: typeof editor.decoratedRanges = [] for (const dr of editor.decoratedRanges) { @@ -176,23 +164,8 @@ export function preTransformDecorationsForHistory( !pointEquals(newAnchor, dr.anchor) || !pointEquals(newFocus, dr.focus) if (moved) { - const updated: DecoratedRange = { - ...dr, - anchor: newAnchor, - focus: newFocus, - } - tracked.push({ - decoratedRange: updated, - originalSelection: dr.rangeDecoration.selection, - moved: true, - }) - newDecoratedRanges.push(updated) + newDecoratedRanges.push({...dr, anchor: newAnchor, focus: newFocus}) } else { - tracked.push({ - decoratedRange: dr, - originalSelection: dr.rangeDecoration.selection, - moved: false, - }) newDecoratedRanges.push(dr) } } @@ -202,42 +175,11 @@ export function preTransformDecorationsForHistory( return () => { editor._suppressDecorationSendBack-- - - if (editor._suppressDecorationSendBack === 0) { - for (const {decoratedRange, originalSelection, moved} of tracked) { - if (!isRange(decoratedRange)) continue - - const newSelection = slateRangeToSelection({ - schema: editor.schema, - editor, - range: {anchor: decoratedRange.anchor, focus: decoratedRange.focus}, - }) - - if (moved) { - const shiftDetails = { - previousSelection: originalSelection, - newSelection, - rangeDecoration: decoratedRange.rangeDecoration, - origin: 'local' as const, - reason: 'moved' as const, - } - decoratedRange.rangeDecoration.onMoved?.(shiftDetails) - pushDecorationShift(editor, shiftDetails) - } - - if (newSelection) { - decoratedRange.rangeDecoration = { - ...decoratedRange.rangeDecoration, - selection: newSelection, - } - } - } - } } } interface BlockTextOffset { - blockIndex: number + blockKey: string textOffset: number } @@ -261,14 +203,17 @@ function computeBlockTextOffset( } textOffset += point.offset - return {blockIndex, textOffset} + return {blockKey: block._key, textOffset} } function resolveBlockTextOffset( offset: BlockTextOffset, editor: PortableTextSlateEditor, ): Point | null { - const block = editor.children[offset.blockIndex] + const blockIndex = editor.blockIndexMap.get(offset.blockKey) + if (blockIndex === undefined) return null + + const block = editor.children[blockIndex] if (!block || !isTextBlock({schema: editor.schema}, block)) return null let remaining = offset.textOffset @@ -276,7 +221,7 @@ function resolveBlockTextOffset( const child = block.children[idx] if (isSpan({schema: editor.schema}, child)) { if (remaining <= child.text.length) { - return {path: [offset.blockIndex, idx], offset: remaining} + return {path: [blockIndex, idx], offset: remaining} } remaining -= child.text.length } @@ -285,12 +230,32 @@ function resolveBlockTextOffset( const lastIdx = block.children.length - 1 const lastChild = block.children[lastIdx] if (lastIdx >= 0 && isSpan({schema: editor.schema}, lastChild)) { - return {path: [offset.blockIndex, lastIdx], offset: lastChild.text.length} + return {path: [blockIndex, lastIdx], offset: lastChild.text.length} } return null } +/** + * Detects whether the undo/redo step will insert blocks (shifting indices + * of all existing decorations). Undoing a `remove_node` at block level + * re-inserts the block; redoing an `insert_node` at block level does the + * same. In both cases `blockOffsetFallback` should handle the decorations + * synchronously so that shifts are included in the mutation event. + */ +function hasBlockLevelAddOps( + step: UndoStep, + direction: 'undo' | 'redo', +): boolean { + for (const op of step.operations) { + if (op.type === 'set_selection') continue + if (op.path.length !== 1) continue + if (direction === 'undo' && op.type === 'remove_node') return true + if (direction === 'redo' && op.type === 'insert_node') return true + } + return false +} + /** * Detects whether applying the step's operations (inverted for undo, * forward for redo) would include `remove_node` at child level — the @@ -326,7 +291,6 @@ function hasChildNodeOpsDestroyingPoints( function blockOffsetFallback(editor: PortableTextSlateEditor): () => void { const snapshots: Array<{ decoratedRange: DecoratedRange - originalSelection: EditorSelection anchorOffset: BlockTextOffset focusOffset: BlockTextOffset }> = [] @@ -338,7 +302,6 @@ function blockOffsetFallback(editor: PortableTextSlateEditor): () => void { if (!anchorOffset || !focusOffset) continue snapshots.push({ decoratedRange: dr, - originalSelection: dr.rangeDecoration.selection, anchorOffset, focusOffset, }) @@ -357,35 +320,8 @@ function blockOffsetFallback(editor: PortableTextSlateEditor): () => void { const newFocus = resolveBlockTextOffset(snap.focusOffset, editor) if (!newAnchor || !newFocus) continue - const moved = - !pointEquals(newAnchor, snap.decoratedRange.anchor) || - !pointEquals(newFocus, snap.decoratedRange.focus) - snap.decoratedRange.anchor = newAnchor snap.decoratedRange.focus = newFocus - - const newSelection = slateRangeToSelection({ - schema: editor.schema, - editor, - range: {anchor: newAnchor, focus: newFocus}, - }) - - if (moved) { - snap.decoratedRange.rangeDecoration.onMoved?.({ - previousSelection: snap.originalSelection, - newSelection, - rangeDecoration: snap.decoratedRange.rangeDecoration, - origin: 'local', - reason: 'moved', - }) - } - - if (newSelection) { - snap.decoratedRange.rangeDecoration = { - ...snap.decoratedRange.rangeDecoration, - selection: newSelection, - } - } } } } diff --git a/packages/editor/src/internal-utils/slate-utils.ts b/packages/editor/src/internal-utils/slate-utils.ts index c6fa101ad..2cc830057 100644 --- a/packages/editor/src/internal-utils/slate-utils.ts +++ b/packages/editor/src/internal-utils/slate-utils.ts @@ -1,4 +1,4 @@ -import {isTextBlock} from '@portabletext/schema' +import {isSpan, isTextBlock} from '@portabletext/schema' import type {EditorSchema} from '../editor/editor-schema' import {getChildren} from '../node-traversal/get-children' import {getNode} from '../node-traversal/get-node' @@ -134,11 +134,15 @@ export function slateRangeToSelection({ const selection: EditorSelection = { anchor: { path: [{_key: anchorBlock._key}], - offset: range.anchor.offset, + offset: anchorChild + ? range.anchor.offset + : absoluteBlockOffset(schema, anchorBlock, range.anchor), }, focus: { path: [{_key: focusBlock._key}], - offset: range.focus.offset, + offset: focusChild + ? range.focus.offset + : absoluteBlockOffset(schema, focusBlock, range.focus), }, backward: isBackwardRange(range), } @@ -192,3 +196,30 @@ export function slatePointToSelectionPoint({ offset: point.offset, } } + +function absoluteBlockOffset( + schema: EditorSchema, + block: Node, + point: Point, +): number { + if (!isTextBlock({schema}, block)) { + return point.offset + } + + const childIndex = point.path.at(1) + + if (childIndex === undefined || childIndex === 0) { + return point.offset + } + + let cumulativeOffset = 0 + + for (let i = 0; i < childIndex && i < block.children.length; i++) { + const child = block.children[i]! + if (isSpan({schema}, child)) { + cumulativeOffset += child.text.length + } + } + + return cumulativeOffset + point.offset +} diff --git a/packages/editor/src/internal-utils/to-slate-range.ts b/packages/editor/src/internal-utils/to-slate-range.ts index 32b2f5389..607003289 100644 --- a/packages/editor/src/internal-utils/to-slate-range.ts +++ b/packages/editor/src/internal-utils/to-slate-range.ts @@ -67,7 +67,7 @@ export function toSlateRange( } } -function toSlateSelectionPoint( +export function toSlateSelectionPoint( snapshot: { context: Pick } & Pick, diff --git a/packages/editor/tests/range-decorations-operations.test.tsx b/packages/editor/tests/range-decorations-operations.test.tsx index 1adfcea26..fed7220ee 100644 --- a/packages/editor/tests/range-decorations-operations.test.tsx +++ b/packages/editor/tests/range-decorations-operations.test.tsx @@ -5807,3 +5807,1135 @@ describe('RangeDecorations: rangeDecorationShifts on mutation event', () => { expect(shifts[0]!.newSelection).not.toBeNull() }) }) + +describe('RangeDecorations: Block Removal (partial invalidation)', () => { + test('Multi-block decoration survives when anchor block is removed', async () => { + const onMovedSpy = vi.fn() + let rangeDecorations: Array = [ + { + component: RangeDecorationComponent, + payload: {id: 'dec1'}, + selection: { + anchor: { + path: [{_key: 'b1'}, 'children', {_key: 's1'}], + offset: 0, + }, + focus: { + path: [{_key: 'b2'}, 'children', {_key: 's2'}], + offset: 5, + }, + }, + onMoved: (details) => { + onMovedSpy(details) + rangeDecorations = updateRangeDecorations({rangeDecorations, details}) + }, + }, + ] + + const {editor, locator, rerender} = await createTestEditor({ + initialValue: [ + { + _type: 'block', + _key: 'b1', + children: [{_type: 'span', _key: 's1', text: 'First'}], + markDefs: [], + }, + { + _type: 'block', + _key: 'b2', + children: [{_type: 'span', _key: 's2', text: 'Second'}], + markDefs: [], + }, + { + _type: 'block', + _key: 'b3', + children: [{_type: 'span', _key: 's3', text: 'Third'}], + markDefs: [], + }, + ], + editableProps: {rangeDecorations}, + }) + + // Multi-block decoration renders as separate elements per block + await vi.waitFor(() => + expect + .element(locator.getByTestId('range-decoration').first()) + .toHaveTextContent('First'), + ) + + editor.send({ + type: 'delete.block', + at: [{_key: 'b1'}], + }) + + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value?.length).toBe(2) + }) + + expect(onMovedSpy).toHaveBeenCalled() + const lastCall = onMovedSpy.mock.calls[ + onMovedSpy.mock.calls.length - 1 + ]?.[0] as RangeDecorationOnMovedDetails | undefined + expect(lastCall?.newSelection).not.toBeNull() + expect(lastCall?.reason).toBe('moved') + + await rerender({ + initialValue: editor.getSnapshot().context.value, + editableProps: {rangeDecorations}, + }) + + await vi.waitFor(() => + expect + .element(locator.getByTestId('range-decoration')) + .toHaveTextContent('Secon'), + ) + }) + + test('Multi-block decoration survives when focus block is removed', async () => { + const onMovedSpy = vi.fn() + let rangeDecorations: Array = [ + { + component: RangeDecorationComponent, + payload: {id: 'dec1'}, + selection: { + anchor: { + path: [{_key: 'b1'}, 'children', {_key: 's1'}], + offset: 0, + }, + focus: { + path: [{_key: 'b2'}, 'children', {_key: 's2'}], + offset: 6, + }, + }, + onMoved: (details) => { + onMovedSpy(details) + rangeDecorations = updateRangeDecorations({rangeDecorations, details}) + }, + }, + ] + + const {editor, locator, rerender} = await createTestEditor({ + initialValue: [ + { + _type: 'block', + _key: 'b1', + children: [{_type: 'span', _key: 's1', text: 'First'}], + markDefs: [], + }, + { + _type: 'block', + _key: 'b2', + children: [{_type: 'span', _key: 's2', text: 'Second'}], + markDefs: [], + }, + { + _type: 'block', + _key: 'b3', + children: [{_type: 'span', _key: 's3', text: 'Third'}], + markDefs: [], + }, + ], + editableProps: {rangeDecorations}, + }) + + await vi.waitFor(() => + expect + .element(locator.getByTestId('range-decoration').first()) + .toHaveTextContent('First'), + ) + + editor.send({ + type: 'delete.block', + at: [{_key: 'b2'}], + }) + + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value?.length).toBe(2) + }) + + expect(onMovedSpy).toHaveBeenCalled() + const lastCall = onMovedSpy.mock.calls[ + onMovedSpy.mock.calls.length - 1 + ]?.[0] as RangeDecorationOnMovedDetails | undefined + expect(lastCall?.newSelection).not.toBeNull() + expect(lastCall?.reason).toBe('moved') + + await rerender({ + initialValue: editor.getSnapshot().context.value, + editableProps: {rangeDecorations}, + }) + + await vi.waitFor(() => + expect + .element(locator.getByTestId('range-decoration')) + .toHaveTextContent('First'), + ) + }) + + test('Decoration spanning all removed blocks is fully invalidated', async () => { + const onMovedSpy = vi.fn() + let rangeDecorations: Array = [ + { + component: RangeDecorationComponent, + payload: {id: 'dec1'}, + selection: { + anchor: { + path: [{_key: 'b1'}, 'children', {_key: 's1'}], + offset: 0, + }, + focus: { + path: [{_key: 'b1'}, 'children', {_key: 's1'}], + offset: 5, + }, + }, + onMoved: (details) => { + onMovedSpy(details) + rangeDecorations = updateRangeDecorations({rangeDecorations, details}) + }, + }, + ] + + const {editor, locator} = await createTestEditor({ + initialValue: [ + { + _type: 'block', + _key: 'b1', + children: [{_type: 'span', _key: 's1', text: 'First'}], + markDefs: [], + }, + { + _type: 'block', + _key: 'b2', + children: [{_type: 'span', _key: 's2', text: 'Second'}], + markDefs: [], + }, + ], + editableProps: {rangeDecorations}, + }) + + await vi.waitFor(() => + expect + .element(locator.getByTestId('range-decoration')) + .toHaveTextContent('First'), + ) + + editor.send({ + type: 'delete.block', + at: [{_key: 'b1'}], + }) + + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value?.length).toBe(1) + }) + + const lastCall = onMovedSpy.mock.calls[ + onMovedSpy.mock.calls.length - 1 + ]?.[0] as RangeDecorationOnMovedDetails | undefined + expect(lastCall?.newSelection).toBeNull() + }) + + test('rangeDecorationShifts emitted when anchor block removed', async () => { + const mutationEvents: Array<{ + patches: Array + rangeDecorationShifts: Array + }> = [] + + let rangeDecorations: Array = [ + { + component: RangeDecorationComponent, + payload: {id: 'dec1'}, + selection: { + anchor: { + path: [{_key: 'b1'}, 'children', {_key: 's1'}], + offset: 0, + }, + focus: { + path: [{_key: 'b2'}, 'children', {_key: 's2'}], + offset: 5, + }, + }, + onMoved: (details) => { + rangeDecorations = updateRangeDecorations({rangeDecorations, details}) + }, + }, + ] + + const {editor, locator} = await createTestEditor({ + initialValue: [ + { + _type: 'block', + _key: 'b1', + children: [{_type: 'span', _key: 's1', text: 'First'}], + markDefs: [], + }, + { + _type: 'block', + _key: 'b2', + children: [{_type: 'span', _key: 's2', text: 'Second'}], + markDefs: [], + }, + ], + editableProps: {rangeDecorations}, + }) + + await vi.waitFor(() => expect.element(locator).toBeInTheDocument()) + + editor.on('mutation', (event) => { + if (event.type === 'mutation') { + mutationEvents.push({ + patches: event.patches, + rangeDecorationShifts: event.rangeDecorationShifts, + }) + } + }) + + editor.send({ + type: 'delete.block', + at: [{_key: 'b1'}], + }) + + await vi.waitFor(() => { + expect(mutationEvents.length).toBeGreaterThan(0) + }) + + const withShifts = mutationEvents.filter( + (event) => event.rangeDecorationShifts.length > 0, + ) + expect(withShifts.length).toBeGreaterThan(0) + + const shifts = withShifts[0]!.rangeDecorationShifts as Array<{ + rangeDecoration: RangeDecoration + previousSelection: EditorSelection + newSelection: EditorSelection + origin: string + reason: string + }> + + expect(shifts[0]!.origin).toBe('local') + expect(shifts[0]!.reason).toBe('moved') + expect(shifts[0]!.previousSelection).not.toBeNull() + expect(shifts[0]!.newSelection).not.toBeNull() + }) + + test('Multi-block decoration survives remote block removal (anchor block)', async () => { + const onMovedSpyB = vi.fn() + let rangeDecorationsB: Array = [ + { + component: RangeDecorationComponent, + payload: {id: 'dec1'}, + selection: { + anchor: { + path: [{_key: 'b1'}, 'children', {_key: 's1'}], + offset: 0, + }, + focus: { + path: [{_key: 'b2'}, 'children', {_key: 's2'}], + offset: 6, + }, + }, + onMoved: (details) => { + onMovedSpyB(details) + rangeDecorationsB = updateRangeDecorations({ + rangeDecorations: rangeDecorationsB, + details, + }) + }, + }, + ] + + const {editor, locator, editorB, locatorB} = await createTestEditors({ + initialValue: [ + { + _type: 'block', + _key: 'b1', + children: [{_type: 'span', _key: 's1', text: 'First'}], + markDefs: [], + }, + { + _type: 'block', + _key: 'b2', + children: [{_type: 'span', _key: 's2', text: 'Second'}], + markDefs: [], + }, + { + _type: 'block', + _key: 'b3', + children: [{_type: 'span', _key: 's3', text: 'Third'}], + markDefs: [], + }, + ], + editableProps: {}, + editablePropsB: {rangeDecorations: rangeDecorationsB}, + }) + + await vi.waitFor(() => expect.element(locator).toBeInTheDocument()) + await vi.waitFor(() => expect.element(locatorB).toBeInTheDocument()) + + await vi.waitFor(() => + expect + .element(locatorB.getByTestId('range-decoration').first()) + .toHaveTextContent('First'), + ) + + await userEvent.click(locator) + editor.send({type: 'focus'}) + editor.send({ + type: 'delete.block', + at: [{_key: 'b1'}], + }) + + await vi.waitFor(() => { + const terseB = editorB.getSnapshot().context.value + expect(terseB?.length).toBe(2) + }) + + expect(onMovedSpyB).toHaveBeenCalled() + const lastCall = onMovedSpyB.mock.calls[ + onMovedSpyB.mock.calls.length - 1 + ]?.[0] as RangeDecorationOnMovedDetails | undefined + expect(lastCall?.newSelection).not.toBeNull() + }) + + test('Undo after anchor-block removal keeps decoration alive', async () => { + const onMovedSpy = vi.fn() + let rangeDecorations: Array = [ + { + component: RangeDecorationComponent, + payload: {id: 'dec1'}, + selection: { + anchor: { + path: [{_key: 'b1'}, 'children', {_key: 's1'}], + offset: 0, + }, + focus: { + path: [{_key: 'b2'}, 'children', {_key: 's2'}], + offset: 5, + }, + }, + onMoved: (details) => { + onMovedSpy(details) + rangeDecorations = updateRangeDecorations({rangeDecorations, details}) + }, + }, + ] + + const {editor, locator, rerender} = await createTestEditor({ + initialValue: [ + { + _type: 'block', + _key: 'b1', + children: [{_type: 'span', _key: 's1', text: 'First'}], + markDefs: [], + }, + { + _type: 'block', + _key: 'b2', + children: [{_type: 'span', _key: 's2', text: 'Second'}], + markDefs: [], + }, + { + _type: 'block', + _key: 'b3', + children: [{_type: 'span', _key: 's3', text: 'Third'}], + markDefs: [], + }, + ], + editableProps: {rangeDecorations}, + }) + + await vi.waitFor(() => + expect + .element(locator.getByTestId('range-decoration').first()) + .toHaveTextContent('First'), + ) + + editor.send({ + type: 'delete.block', + at: [{_key: 'b1'}], + }) + + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value?.length).toBe(2) + }) + + expect(onMovedSpy).toHaveBeenCalled() + const salvageCall = onMovedSpy.mock.calls[ + onMovedSpy.mock.calls.length - 1 + ]?.[0] as RangeDecorationOnMovedDetails | undefined + expect(salvageCall?.newSelection).not.toBeNull() + + await rerender({ + initialValue: editor.getSnapshot().context.value, + editableProps: {rangeDecorations}, + }) + + await vi.waitFor(() => + expect + .element(locator.getByTestId('range-decoration')) + .toHaveTextContent('Secon'), + ) + + onMovedSpy.mockClear() + + editor.send({type: 'history.undo'}) + + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value?.length).toBe(3) + }) + + await rerender({ + initialValue: editor.getSnapshot().context.value, + editableProps: {rangeDecorations}, + }) + + await vi.waitFor(() => + expect + .element(locator.getByTestId('range-decoration')) + .toBeInTheDocument(), + ) + + expect(onMovedSpy).not.toHaveBeenCalled() + }) + + test('Undo after focus-block removal keeps decoration alive', async () => { + const onMovedSpy = vi.fn() + let rangeDecorations: Array = [ + { + component: RangeDecorationComponent, + payload: {id: 'dec1'}, + selection: { + anchor: { + path: [{_key: 'b1'}, 'children', {_key: 's1'}], + offset: 0, + }, + focus: { + path: [{_key: 'b2'}, 'children', {_key: 's2'}], + offset: 6, + }, + }, + onMoved: (details) => { + onMovedSpy(details) + rangeDecorations = updateRangeDecorations({rangeDecorations, details}) + }, + }, + ] + + const {editor, locator, rerender} = await createTestEditor({ + initialValue: [ + { + _type: 'block', + _key: 'b1', + children: [{_type: 'span', _key: 's1', text: 'First'}], + markDefs: [], + }, + { + _type: 'block', + _key: 'b2', + children: [{_type: 'span', _key: 's2', text: 'Second'}], + markDefs: [], + }, + { + _type: 'block', + _key: 'b3', + children: [{_type: 'span', _key: 's3', text: 'Third'}], + markDefs: [], + }, + ], + editableProps: {rangeDecorations}, + }) + + await vi.waitFor(() => + expect + .element(locator.getByTestId('range-decoration').first()) + .toHaveTextContent('First'), + ) + + editor.send({ + type: 'delete.block', + at: [{_key: 'b2'}], + }) + + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value?.length).toBe(2) + }) + + expect(onMovedSpy).toHaveBeenCalled() + + await rerender({ + initialValue: editor.getSnapshot().context.value, + editableProps: {rangeDecorations}, + }) + + await vi.waitFor(() => + expect + .element(locator.getByTestId('range-decoration')) + .toHaveTextContent('First'), + ) + + onMovedSpy.mockClear() + + editor.send({type: 'history.undo'}) + + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value?.length).toBe(3) + }) + + await rerender({ + initialValue: editor.getSnapshot().context.value, + editableProps: {rangeDecorations}, + }) + + await vi.waitFor(() => + expect + .element(locator.getByTestId('range-decoration')) + .toBeInTheDocument(), + ) + + expect(onMovedSpy).not.toHaveBeenCalled() + }) + + test('Undo after deleting two blocks keeps both decorations on surviving block', async () => { + const onMovedSpyA = vi.fn() + const onMovedSpyB = vi.fn() + let rangeDecorations: Array = [ + { + component: RangeDecorationComponent, + payload: {id: 'decA'}, + selection: { + anchor: { + path: [{_key: 'b2'}, 'children', {_key: 's2'}], + offset: 0, + }, + focus: { + path: [{_key: 'b3'}, 'children', {_key: 's3'}], + offset: 3, + }, + }, + onMoved: (details) => { + onMovedSpyA(details) + rangeDecorations = updateRangeDecorations({ + rangeDecorations, + details, + }) + }, + }, + { + component: RangeDecorationComponent, + payload: {id: 'decB'}, + selection: { + anchor: { + path: [{_key: 'b2'}, 'children', {_key: 's2'}], + offset: 4, + }, + focus: { + path: [{_key: 'b3'}, 'children', {_key: 's3'}], + offset: 5, + }, + }, + onMoved: (details) => { + onMovedSpyB(details) + rangeDecorations = updateRangeDecorations({ + rangeDecorations, + details, + }) + }, + }, + ] + + const {editor, locator, rerender} = await createTestEditor({ + initialValue: [ + { + _type: 'block', + _key: 'b1', + children: [{_type: 'span', _key: 's1', text: 'First'}], + markDefs: [], + }, + { + _type: 'block', + _key: 'b2', + children: [{_type: 'span', _key: 's2', text: 'Second'}], + markDefs: [], + }, + { + _type: 'block', + _key: 'b3', + children: [{_type: 'span', _key: 's3', text: 'Third'}], + markDefs: [], + }, + ], + editableProps: {rangeDecorations}, + }) + + await vi.waitFor(() => + expect + .element(locator.getByTestId('range-decoration').first()) + .toBeInTheDocument(), + ) + + // --- Delete b1 --- + editor.send({type: 'delete.block', at: [{_key: 'b1'}]}) + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value?.length).toBe(2) + }) + + await rerender({ + initialValue: editor.getSnapshot().context.value, + editableProps: {rangeDecorations}, + }) + + // --- Delete b2 (triggers salvage: both decorations clamp to b3) --- + editor.send({type: 'delete.block', at: [{_key: 'b2'}]}) + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value?.length).toBe(1) + }) + + await rerender({ + initialValue: editor.getSnapshot().context.value, + editableProps: {rangeDecorations}, + }) + + await vi.waitFor(() => + expect + .element(locator.getByTestId('range-decoration').first()) + .toBeInTheDocument(), + ) + + // After salvage: both decorations should only cover b3 text (not b1/b2 text) + const anchorKeyA = rangeDecorations.find( + (rd) => rd.payload?.['id'] === 'decA', + )?.selection?.anchor.path[0] + const anchorKeyB = rangeDecorations.find( + (rd) => rd.payload?.['id'] === 'decB', + )?.selection?.anchor.path[0] + expect(anchorKeyA).toEqual({_key: 'b3'}) + expect(anchorKeyB).toEqual({_key: 'b3'}) + + onMovedSpyA.mockClear() + onMovedSpyB.mockClear() + + // --- Undo (restores b2) --- + editor.send({type: 'history.undo'}) + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value?.length).toBe(2) + }) + + await rerender({ + initialValue: editor.getSnapshot().context.value, + editableProps: {rangeDecorations}, + }) + + await vi.waitFor(() => + expect + .element(locator.getByTestId('range-decoration').first()) + .toBeInTheDocument(), + ) + + expect(onMovedSpyA).not.toHaveBeenCalled() + expect(onMovedSpyB).not.toHaveBeenCalled() + + // After first undo: decorations must remain on b3, not span back to b2 + const postUndo1AnchorA = rangeDecorations.find( + (rd) => rd.payload?.['id'] === 'decA', + )?.selection?.anchor.path[0] + const postUndo1AnchorB = rangeDecorations.find( + (rd) => rd.payload?.['id'] === 'decB', + )?.selection?.anchor.path[0] + expect(postUndo1AnchorA).toEqual({_key: 'b3'}) + expect(postUndo1AnchorB).toEqual({_key: 'b3'}) + + // Decoration text should only be on b3, not spanning b2→b3 + const decorationElements = locator.getByTestId('range-decoration') + const decorationCount = await decorationElements.all() + for (const el of decorationCount) { + const text = el.element().textContent + expect(text).not.toContain('Second') + expect(text).not.toContain('First') + } + + onMovedSpyA.mockClear() + onMovedSpyB.mockClear() + + // --- Second undo (restores b1) --- + editor.send({type: 'history.undo'}) + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value?.length).toBe(3) + }) + + await rerender({ + initialValue: editor.getSnapshot().context.value, + editableProps: {rangeDecorations}, + }) + + await vi.waitFor(() => + expect + .element(locator.getByTestId('range-decoration').first()) + .toBeInTheDocument(), + ) + + expect(onMovedSpyA).not.toHaveBeenCalled() + expect(onMovedSpyB).not.toHaveBeenCalled() + + // After second undo: decorations must still remain on b3 + const postUndo2AnchorA = rangeDecorations.find( + (rd) => rd.payload?.['id'] === 'decA', + )?.selection?.anchor.path[0] + const postUndo2AnchorB = rangeDecorations.find( + (rd) => rd.payload?.['id'] === 'decB', + )?.selection?.anchor.path[0] + expect(postUndo2AnchorA).toEqual({_key: 'b3'}) + expect(postUndo2AnchorB).toEqual({_key: 'b3'}) + + // Decoration text should only be on b3 + const decorationElements2 = locator.getByTestId('range-decoration') + const decorationCount2 = await decorationElements2.all() + for (const el of decorationCount2) { + const text = el.element().textContent + expect(text).not.toContain('Second') + expect(text).not.toContain('First') + } + }) + + test('Undo after anchor-block removal preserves exact clamped selection', async () => { + const onMovedSpy = vi.fn() + let rangeDecorations: Array = [ + { + component: RangeDecorationComponent, + payload: {id: 'dec1'}, + selection: { + anchor: { + path: [{_key: 'b1'}, 'children', {_key: 's1'}], + offset: 0, + }, + focus: { + path: [{_key: 'b2'}, 'children', {_key: 's2'}], + offset: 5, + }, + }, + onMoved: (details) => { + onMovedSpy(details) + rangeDecorations = updateRangeDecorations({rangeDecorations, details}) + }, + }, + ] + + const {editor, locator, rerender} = await createTestEditor({ + initialValue: [ + { + _type: 'block', + _key: 'b1', + children: [{_type: 'span', _key: 's1', text: 'First'}], + markDefs: [], + }, + { + _type: 'block', + _key: 'b2', + children: [{_type: 'span', _key: 's2', text: 'Second'}], + markDefs: [], + }, + { + _type: 'block', + _key: 'b3', + children: [{_type: 'span', _key: 's3', text: 'Third'}], + markDefs: [], + }, + ], + editableProps: {rangeDecorations}, + }) + + await vi.waitFor(() => + expect + .element(locator.getByTestId('range-decoration').first()) + .toHaveTextContent('First'), + ) + + editor.send({ + type: 'delete.block', + at: [{_key: 'b1'}], + }) + + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value?.length).toBe(2) + }) + + expect(onMovedSpy).toHaveBeenCalled() + const salvageCall = onMovedSpy.mock.calls[ + onMovedSpy.mock.calls.length - 1 + ]?.[0] as RangeDecorationOnMovedDetails | undefined + const clampedSelection = salvageCall?.newSelection + expect(clampedSelection).not.toBeNull() + expect(clampedSelection?.anchor.path[0]).toEqual({_key: 'b2'}) + expect(clampedSelection?.focus.path[0]).toEqual({_key: 'b2'}) + + await rerender({ + initialValue: editor.getSnapshot().context.value, + editableProps: {rangeDecorations}, + }) + + await vi.waitFor(() => + expect + .element(locator.getByTestId('range-decoration')) + .toHaveTextContent('Secon'), + ) + + onMovedSpy.mockClear() + + editor.send({type: 'history.undo'}) + + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value?.length).toBe(3) + }) + + await rerender({ + initialValue: editor.getSnapshot().context.value, + editableProps: {rangeDecorations}, + }) + + await vi.waitFor(() => + expect + .element(locator.getByTestId('range-decoration')) + .toBeInTheDocument(), + ) + + expect(onMovedSpy).not.toHaveBeenCalled() + + const currentSelection = rangeDecorations[0]?.selection + expect(currentSelection).toEqual(clampedSelection) + }) + + test('Undo after merge-based block removal preserves clamped selection', async () => { + const onMovedSpy = vi.fn() + let rangeDecorations: Array = [ + { + component: RangeDecorationComponent, + payload: {id: 'dec1'}, + selection: { + anchor: { + path: [{_key: 'b2'}, 'children', {_key: 's2'}], + offset: 0, + }, + focus: { + path: [{_key: 'b3'}, 'children', {_key: 's3'}], + offset: 5, + }, + }, + onMoved: (details) => { + onMovedSpy(details) + rangeDecorations = updateRangeDecorations({rangeDecorations, details}) + }, + }, + ] + + const {editor, locator, rerender} = await createTestEditor({ + initialValue: [ + { + _type: 'block', + _key: 'b1', + children: [{_type: 'span', _key: 's1', text: 'First'}], + markDefs: [], + }, + { + _type: 'block', + _key: 'b2', + children: [{_type: 'span', _key: 's2', text: 'Second'}], + markDefs: [], + }, + { + _type: 'block', + _key: 'b3', + children: [{_type: 'span', _key: 's3', text: 'Third'}], + markDefs: [], + }, + ], + editableProps: {rangeDecorations}, + }) + + await vi.waitFor(() => + expect + .element(locator.getByTestId('range-decoration').first()) + .toHaveTextContent('Second'), + ) + + editor.send({type: 'focus'}) + editor.send({ + type: 'select', + at: { + anchor: {path: [{_key: 'b2'}], offset: 0}, + focus: {path: [{_key: 'b2'}], offset: 0}, + }, + }) + + await vi.waitFor(() => { + const sel = editor.getSnapshot().context.selection + expect(sel?.anchor.path[0]).toEqual({_key: 'b2'}) + }) + + editor.send({type: 'delete', direction: 'backward'}) + + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value?.length).toBe(2) + }) + + expect(onMovedSpy).toHaveBeenCalled() + const salvageCall = onMovedSpy.mock.calls[ + onMovedSpy.mock.calls.length - 1 + ]?.[0] as RangeDecorationOnMovedDetails | undefined + const clampedSelection = salvageCall?.newSelection + expect(clampedSelection).not.toBeNull() + + await rerender({ + initialValue: editor.getSnapshot().context.value, + editableProps: {rangeDecorations}, + }) + + onMovedSpy.mockClear() + + editor.send({type: 'history.undo'}) + + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value?.length).toBe(3) + }) + + const decorationBeforeRerender = + locator.getByTestId('range-decoration').elements().length > 0 + + await rerender({ + initialValue: editor.getSnapshot().context.value, + editableProps: {rangeDecorations}, + }) + + const decorationAfterRerender = + locator.getByTestId('range-decoration').elements().length > 0 + + expect({ + beforeRerender: decorationBeforeRerender, + afterRerender: decorationAfterRerender, + }).toEqual({ + beforeRerender: true, + afterRerender: true, + }) + + expect(onMovedSpy).not.toHaveBeenCalled() + + const currentSelection = rangeDecorations[0]?.selection + expect(currentSelection).toEqual(clampedSelection) + }) + + test('Undo after cross-block selection delete does not extend range', async () => { + const onMovedSpy = vi.fn() + let rangeDecorations: Array = [ + { + component: RangeDecorationComponent, + payload: {id: 'dec1'}, + selection: { + anchor: { + path: [{_key: 'b2'}, 'children', {_key: 's2'}], + offset: 0, + }, + focus: { + path: [{_key: 'b3'}, 'children', {_key: 's3'}], + offset: 5, + }, + }, + onMoved: (details) => { + onMovedSpy(details) + rangeDecorations = updateRangeDecorations({rangeDecorations, details}) + }, + }, + ] + + const {editor, locator, rerender} = await createTestEditor({ + initialValue: [ + { + _type: 'block', + _key: 'b1', + children: [{_type: 'span', _key: 's1', text: 'First'}], + markDefs: [], + }, + { + _type: 'block', + _key: 'b2', + children: [{_type: 'span', _key: 's2', text: 'Second'}], + markDefs: [], + }, + { + _type: 'block', + _key: 'b3', + children: [{_type: 'span', _key: 's3', text: 'Third'}], + markDefs: [], + }, + ], + editableProps: {rangeDecorations}, + }) + + await vi.waitFor(() => + expect + .element(locator.getByTestId('range-decoration').first()) + .toHaveTextContent('Second'), + ) + + editor.send({type: 'focus'}) + + editor.send({ + type: 'select', + at: { + anchor: {path: [{_key: 'b1'}, 'children', {_key: 's1'}], offset: 2}, + focus: {path: [{_key: 'b2'}, 'children', {_key: 's2'}], offset: 6}, + }, + }) + + await vi.waitFor(() => { + const sel = editor.getSnapshot().context.selection + expect(sel?.anchor.path[0]).toEqual({_key: 'b1'}) + expect(sel?.focus.path[0]).toEqual({_key: 'b2'}) + }) + + editor.send({type: 'delete'}) + + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value?.length).toBe(2) + }) + + expect(onMovedSpy).toHaveBeenCalled() + const lastCall = onMovedSpy.mock.calls[ + onMovedSpy.mock.calls.length - 1 + ]?.[0] as RangeDecorationOnMovedDetails | undefined + const clampedSelection = lastCall?.newSelection + expect(clampedSelection).not.toBeNull() + expect(clampedSelection?.anchor.path[0]).toEqual({_key: 'b3'}) + + await rerender({ + initialValue: editor.getSnapshot().context.value, + editableProps: {rangeDecorations}, + }) + + onMovedSpy.mockClear() + + editor.send({type: 'history.undo'}) + + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value?.length).toBe(3) + }) + + await rerender({ + initialValue: editor.getSnapshot().context.value, + editableProps: {rangeDecorations}, + }) + + await vi.waitFor(() => + expect + .element(locator.getByTestId('range-decoration')) + .toBeInTheDocument(), + ) + + expect(onMovedSpy).not.toHaveBeenCalled() + + const currentSelection = rangeDecorations[0]?.selection + expect(currentSelection).toEqual(clampedSelection) + + const decorationElements = locator.getByTestId('range-decoration') + const allDecorations = await decorationElements.all() + for (const el of allDecorations) { + expect(el.element().textContent).not.toContain('First') + } + }) +})