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
12 changes: 12 additions & 0 deletions .changeset/remote-children-replacement-selection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@portabletext/editor': patch
---

fix: preserve selections through remote children replacements that keep the text

When a remote change replaces a block's children with re-keyed spans
(a collaborator toggling a mark that splits spans), the local cursor
and any other tracked selection no longer jump to the start of the
block. As long as the block's text is unchanged, selections keep
their exact textual position; replacements that change the text keep
the previous behavior.
305 changes: 305 additions & 0 deletions packages/editor/src/engine/point/transform-point.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,4 +217,309 @@ describe(transformPoint.name, () => {
offset: 0,
})
})

test('set children: remaps a point through a text-preserving replacement', () => {
const point = {
path: [{_key: 'b1'}, 'children', {_key: 's1'}],
offset: 5,
}
const op: EngineOperation = {
type: 'set',
path: [{_key: 'b1'}, 'children'],
value: [
{_type: 'span', _key: 'n1', text: 'foo', marks: []},
{_type: 'span', _key: 'n2', text: 'bar', marks: ['strong']},
{_type: 'span', _key: 'n3', text: 'baz', marks: []},
],
inverse: {
type: 'set',
path: [{_key: 'b1'}, 'children'],
value: [{_type: 'span', _key: 's1', text: 'foobarbaz', marks: []}],
},
}
expect(transformPoint(point, op)).toEqual({
path: [{_key: 'b1'}, 'children', {_key: 'n2'}],
offset: 2,
})
})

test('set children: a boundary offset lands at the end of the earlier span', () => {
const point = {
path: [{_key: 'b1'}, 'children', {_key: 's1'}],
offset: 3,
}
const op: EngineOperation = {
type: 'set',
path: [{_key: 'b1'}, 'children'],
value: [
{_type: 'span', _key: 'n1', text: 'foo', marks: []},
{_type: 'span', _key: 'n2', text: 'barbaz', marks: []},
],
inverse: {
type: 'set',
path: [{_key: 'b1'}, 'children'],
value: [{_type: 'span', _key: 's1', text: 'foobarbaz', marks: []}],
},
}
expect(transformPoint(point, op)).toEqual({
path: [{_key: 'b1'}, 'children', {_key: 'n1'}],
offset: 3,
})
})

test('set children: inline objects occupy no text offset on either side', () => {
const point = {
path: [{_key: 'b1'}, 'children', {_key: 's2'}],
offset: 1,
}
const op: EngineOperation = {
type: 'set',
path: [{_key: 'b1'}, 'children'],
value: [
{_type: 'span', _key: 'n1', text: 'foo', marks: []},
{_type: 'stock-ticker', _key: 'n2'},
{_type: 'span', _key: 'n3', text: 'bar', marks: []},
],
inverse: {
type: 'set',
path: [{_key: 'b1'}, 'children'],
value: [
{_type: 'span', _key: 's1', text: 'foo', marks: []},
{_type: 'stock-ticker', _key: 'o1'},
{_type: 'span', _key: 's2', text: 'bar', marks: []},
],
},
}
expect(transformPoint(point, op)).toEqual({
path: [{_key: 'b1'}, 'children', {_key: 'n3'}],
offset: 1,
})
})

test('set children: a surviving span keeps its identity over offset mapping', () => {
const point = {
path: [{_key: 'b1'}, 'children', {_key: 's1'}],
offset: 2,
}
const op: EngineOperation = {
type: 'set',
path: [{_key: 'b1'}, 'children'],
value: [
{_type: 'span', _key: 'n1', text: 'bar', marks: []},
{_type: 'span', _key: 's1', text: 'foo', marks: []},
],
inverse: {
type: 'set',
path: [{_key: 'b1'}, 'children'],
value: [
{_type: 'span', _key: 's1', text: 'foo', marks: []},
{_type: 'span', _key: 'o2', text: 'bar', marks: []},
],
},
}
expect(transformPoint(point, op)).toBe(point)
})

test('set children: a text-changing replacement leaves the point untransformed', () => {
const point = {
path: [{_key: 'b1'}, 'children', {_key: 's1'}],
offset: 5,
}
const op: EngineOperation = {
type: 'set',
path: [{_key: 'b1'}, 'children'],
value: [{_type: 'span', _key: 'n1', text: 'hello', marks: []}],
inverse: {
type: 'set',
path: [{_key: 'b1'}, 'children'],
value: [{_type: 'span', _key: 's1', text: 'foobarbaz', marks: []}],
},
}
expect(transformPoint(point, op)).toBe(point)
})

test('set children: no inverse leaves the point untransformed', () => {
const point = {
path: [{_key: 'b1'}, 'children', {_key: 's1'}],
offset: 5,
}
const op: EngineOperation = {
type: 'set',
path: [{_key: 'b1'}, 'children'],
value: [{_type: 'span', _key: 'n1', text: 'foobarbaz', marks: []}],
}
expect(transformPoint(point, op)).toBe(point)
})

test('set children: a point in another block is untouched', () => {
const point = {
path: [{_key: 'b2'}, 'children', {_key: 's9'}],
offset: 5,
}
const op: EngineOperation = {
type: 'set',
path: [{_key: 'b1'}, 'children'],
value: [{_type: 'span', _key: 'n1', text: 'foobarbaz', marks: []}],
inverse: {
type: 'set',
path: [{_key: 'b1'}, 'children'],
value: [{_type: 'span', _key: 's1', text: 'foobarbaz', marks: []}],
},
}
expect(transformPoint(point, op)).toBe(point)
})

test('set children: container blocks in a field named `children` are not offset-mapped', () => {
// A container's array field may be named `children` and hold
// blocks. Blocks carry no `text`, so a point addressing a replaced
// block keeps the untransformed-point behavior.
const point = {
path: [{_key: 'callout1'}, 'children', {_key: 'block1'}],
offset: 0,
}
const op: EngineOperation = {
type: 'set',
path: [{_key: 'callout1'}, 'children'],
value: [
{
_type: 'block',
_key: 'newBlock1',
children: [{_type: 'span', _key: 'n1', text: 'foo', marks: []}],
},
],
inverse: {
type: 'set',
path: [{_key: 'callout1'}, 'children'],
value: [
{
_type: 'block',
_key: 'block1',
children: [{_type: 'span', _key: 's1', text: 'foo', marks: []}],
},
],
},
}
expect(transformPoint(point, op)).toBe(point)
})

test('set children: a point deeper inside replaced container blocks is untouched', () => {
const point = {
path: [
{_key: 'callout1'},
'children',
{_key: 'block1'},
'children',
{_key: 's1'},
],
offset: 2,
}
const op: EngineOperation = {
type: 'set',
path: [{_key: 'callout1'}, 'children'],
value: [
{
_type: 'block',
_key: 'newBlock1',
children: [{_type: 'span', _key: 'n1', text: 'foo', marks: []}],
},
],
inverse: {
type: 'set',
path: [{_key: 'callout1'}, 'children'],
value: [
{
_type: 'block',
_key: 'block1',
children: [{_type: 'span', _key: 's1', text: 'foo', marks: []}],
},
],
},
}
expect(transformPoint(point, op)).toBe(point)
})

test('set children: remaps span points of a text block nested in a container', () => {
const point = {
path: [
{_key: 'table1'},
'rows',
{_key: 'row1'},
'cells',
{_key: 'cell1'},
'content',
{_key: 'block1'},
'children',
{_key: 's1'},
],
offset: 5,
}
const op: EngineOperation = {
type: 'set',
path: [
{_key: 'table1'},
'rows',
{_key: 'row1'},
'cells',
{_key: 'cell1'},
'content',
{_key: 'block1'},
'children',
],
value: [
{_type: 'span', _key: 'n1', text: 'foo', marks: []},
{_type: 'span', _key: 'n2', text: 'bar', marks: ['strong']},
{_type: 'span', _key: 'n3', text: 'baz', marks: []},
],
inverse: {
type: 'set',
path: [
{_key: 'table1'},
'rows',
{_key: 'row1'},
'cells',
{_key: 'cell1'},
'content',
{_key: 'block1'},
'children',
],
value: [{_type: 'span', _key: 's1', text: 'foobarbaz', marks: []}],
},
}
expect(transformPoint(point, op)).toEqual({
path: [
{_key: 'table1'},
'rows',
{_key: 'row1'},
'cells',
{_key: 'cell1'},
'content',
{_key: 'block1'},
'children',
{_key: 'n2'},
],
offset: 2,
})
})

test('set children: a raw block-offset point is never offset-mapped', () => {
// Selections can carry raw block-offset points ({path: [block],
// offset: N}) that resolve lazily. The children remap only applies
// to points addressing a direct child of the replaced array, so
// block-path points pass through untouched.
const point = {
path: [{_key: 'b1'}],
offset: 5,
}
const op: EngineOperation = {
type: 'set',
path: [{_key: 'b1'}, 'children'],
value: [{_type: 'span', _key: 'n1', text: 'foobarbaz', marks: []}],
inverse: {
type: 'set',
path: [{_key: 'b1'}, 'children'],
value: [{_type: 'span', _key: 's1', text: 'foobarbaz', marks: []}],
},
}
expect(transformPoint(point, op)).toBe(point)
})
})
Loading
Loading