Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions apps/playground/src/playground-machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -452,6 +475,7 @@ export const playgroundMachine = setup({
'update patch-derived value',
'broadcast value',
'add to patch feed',
'apply decoration shifts',
],
},
'clear patches': {
Expand Down
21 changes: 1 addition & 20 deletions apps/playground/src/range-decoration-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
2 changes: 1 addition & 1 deletion packages/editor/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
168 changes: 160 additions & 8 deletions packages/editor/src/editor/range-decorations-machine.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {isTextBlock} from '@portabletext/schema'
import {isSpan, isTextBlock} from '@portabletext/schema'
import {
and,
assign,
Expand All @@ -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'
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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 = {
Expand Down Expand Up @@ -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': {
Expand Down
54 changes: 52 additions & 2 deletions packages/editor/src/internal-utils/apply-merge-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
*
Expand Down
Loading
Loading