Skip to content

Commit f83e8e4

Browse files
authoredFeb 28, 2024
feat(portable-text-editor): preserve keys on undo/redo (#5805)
* refactor(portable-text-editor): preserve keys on undo/redo * fix(portable-text-editor): add forgotten static class functions for undo and redo * test(portable-text-editor): add tests for undo/redo preserve keys
1 parent 6e551b0 commit f83e8e4

File tree

4 files changed

+148
-11
lines changed

4 files changed

+148
-11
lines changed
 

‎packages/@sanity/portable-text-editor/src/editor/PortableTextEditor.tsx

+8
Original file line numberDiff line numberDiff line change
@@ -284,4 +284,12 @@ export class PortableTextEditor extends Component<PortableTextEditorProps> {
284284
debug(`Host getting fragment`)
285285
return editor.editable?.getFragment()
286286
}
287+
static undo = (editor: PortableTextEditor): void => {
288+
debug('Host undoing')
289+
editor.editable?.undo()
290+
}
291+
static redo = (editor: PortableTextEditor): void => {
292+
debug('Host redoing')
293+
editor.editable?.redo()
294+
}
287295
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import {describe, expect, it, jest} from '@jest/globals'
2+
import {render, waitFor} from '@testing-library/react'
3+
import {createRef, type RefObject} from 'react'
4+
5+
import {PortableTextEditorTester, schemaType} from '../../__tests__/PortableTextEditorTester'
6+
import {PortableTextEditor} from '../../PortableTextEditor'
7+
8+
const initialValue = [
9+
{
10+
_key: 'a',
11+
_type: 'myTestBlockType',
12+
children: [
13+
{
14+
_key: 'a1',
15+
_type: 'span',
16+
marks: [],
17+
text: 'Block A',
18+
},
19+
],
20+
markDefs: [],
21+
style: 'normal',
22+
},
23+
{
24+
_key: 'b',
25+
_type: 'myTestBlockType',
26+
children: [
27+
{
28+
_key: 'b1',
29+
_type: 'span',
30+
marks: [],
31+
text: 'Block B',
32+
},
33+
],
34+
markDefs: [],
35+
style: 'normal',
36+
},
37+
]
38+
39+
const initialSelection = {
40+
focus: {path: [{_key: 'b'}, 'children', {_key: 'b1'}], offset: 7},
41+
anchor: {path: [{_key: 'b'}, 'children', {_key: 'b1'}], offset: 7},
42+
}
43+
44+
describe('plugin:withUndoRedo', () => {
45+
it('preserves the keys when undoing ', async () => {
46+
const editorRef: RefObject<PortableTextEditor> = createRef()
47+
const onChange = jest.fn()
48+
render(
49+
<PortableTextEditorTester
50+
onChange={onChange}
51+
ref={editorRef}
52+
schemaType={schemaType}
53+
value={initialValue}
54+
/>,
55+
)
56+
await waitFor(() => {
57+
if (editorRef.current) {
58+
PortableTextEditor.focus(editorRef.current)
59+
PortableTextEditor.select(editorRef.current, initialSelection)
60+
PortableTextEditor.delete(
61+
editorRef.current,
62+
PortableTextEditor.getSelection(editorRef.current),
63+
{mode: 'blocks'},
64+
)
65+
expect(PortableTextEditor.getValue(editorRef.current)).toMatchInlineSnapshot(`
66+
Array [
67+
Object {
68+
"_key": "a",
69+
"_type": "myTestBlockType",
70+
"children": Array [
71+
Object {
72+
"_key": "a1",
73+
"_type": "span",
74+
"marks": Array [],
75+
"text": "Block A",
76+
},
77+
],
78+
"markDefs": Array [],
79+
"style": "normal",
80+
},
81+
]
82+
`)
83+
PortableTextEditor.undo(editorRef.current)
84+
expect(PortableTextEditor.getValue(editorRef.current)).toEqual(initialValue)
85+
}
86+
})
87+
})
88+
it('preserves the keys when redoing ', async () => {
89+
const editorRef: RefObject<PortableTextEditor> = createRef()
90+
const onChange = jest.fn()
91+
render(
92+
<PortableTextEditorTester
93+
onChange={onChange}
94+
ref={editorRef}
95+
schemaType={schemaType}
96+
value={initialValue}
97+
/>,
98+
)
99+
await waitFor(() => {
100+
if (editorRef.current) {
101+
PortableTextEditor.focus(editorRef.current)
102+
PortableTextEditor.select(editorRef.current, initialSelection)
103+
PortableTextEditor.insertBlock(editorRef.current, editorRef.current.schemaTypes.block, {
104+
children: [{_key: 'c1', _type: 'span', marks: [], text: 'Block C'}],
105+
})
106+
const producedKey = PortableTextEditor.getValue(editorRef.current)?.slice(-1)[0]?._key
107+
PortableTextEditor.undo(editorRef.current)
108+
PortableTextEditor.redo(editorRef.current)
109+
expect(PortableTextEditor.getValue(editorRef.current)?.slice(-1)[0]?._key).toEqual(
110+
producedKey,
111+
)
112+
}
113+
})
114+
})
115+
})

‎packages/@sanity/portable-text-editor/src/editor/plugins/createWithObjectKeys.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,18 @@ export function createWithObjectKeys(
1414
return function withKeys(editor: PortableTextSlateEditor): PortableTextSlateEditor {
1515
PRESERVE_KEYS.set(editor, false)
1616
const {apply, normalizeNode} = editor
17+
18+
// The apply function can be called with a scope (withPreserveKeys) that will
19+
// preserve keys for the produced nodes if they have a _key property set already.
20+
// The default behavior is to always generate a new key here.
21+
// For example, when undoing and redoing we want to retain the keys, but
22+
// when we create a new bold span by splitting a non-bold-span we want the produced node to get a new key.
1723
editor.apply = (operation) => {
1824
if (operation.type === 'split_node') {
25+
const withNewKey = !isPreservingKeys(editor) || !('_key' in operation.properties)
1926
operation.properties = {
2027
...operation.properties,
21-
_key: keyGenerator(),
28+
...(withNewKey ? {_key: keyGenerator()} : {}),
2229
}
2330
}
2431
if (operation.type === 'insert_node') {

‎packages/@sanity/portable-text-editor/src/editor/plugins/createWithUndoRedo.ts

+17-10
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {type PatchObservable, type PortableTextSlateEditor} from '../../types/ed
1212
import {type Patch} from '../../types/patch'
1313
import {debugWithName} from '../../utils/debug'
1414
import {fromSlateValue} from '../../utils/values'
15+
import {withPreserveKeys} from '../../utils/withPreserveKeys'
1516

1617
const debug = debugWithName('plugin:withUndoRedo')
1718
const debugVerbose = debug.enabled && false
@@ -147,13 +148,16 @@ export function createWithUndoRedo(
147148
})
148149
try {
149150
Editor.withoutNormalizing(editor, () => {
150-
withoutSaving(editor, () => {
151-
transformedOperations
152-
.map(Operation.inverse)
153-
.reverse()
154-
.forEach((op) => {
155-
editor.apply(op)
156-
})
151+
withPreserveKeys(editor, () => {
152+
withoutSaving(editor, () => {
153+
transformedOperations
154+
.map(Operation.inverse)
155+
.reverse()
156+
// eslint-disable-next-line max-nested-callbacks
157+
.forEach((op) => {
158+
editor.apply(op)
159+
})
160+
})
157161
})
158162
})
159163
editor.normalize()
@@ -193,9 +197,12 @@ export function createWithUndoRedo(
193197
})
194198
try {
195199
Editor.withoutNormalizing(editor, () => {
196-
withoutSaving(editor, () => {
197-
transformedOperations.forEach((op) => {
198-
editor.apply(op)
200+
withPreserveKeys(editor, () => {
201+
withoutSaving(editor, () => {
202+
// eslint-disable-next-line max-nested-callbacks
203+
transformedOperations.forEach((op) => {
204+
editor.apply(op)
205+
})
199206
})
200207
})
201208
})

0 commit comments

Comments
 (0)
Please sign in to comment.