@portabletext/editor 1.44.4 → 1.44.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/index.cjs +255 -95
- package/lib/index.cjs.map +1 -1
- package/lib/index.js +257 -96
- package/lib/index.js.map +1 -1
- package/package.json +8 -8
- package/src/editor/Editable.tsx +43 -169
- package/src/editor/__tests__/RangeDecorations.test.tsx +26 -0
- package/src/editor/range-decorations-machine.ts +346 -0
- package/src/editor/withSyncRangeDecorations.ts +0 -32
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@portabletext/editor",
|
|
3
|
-
"version": "1.44.
|
|
3
|
+
"version": "1.44.6",
|
|
4
4
|
"description": "Portable Text Editor made in React",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"sanity",
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
"get-random-values-esm": "^1.0.2",
|
|
74
74
|
"lodash": "^4.17.21",
|
|
75
75
|
"lodash.startcase": "^4.4.0",
|
|
76
|
-
"react-compiler-runtime": "19.0.0-beta-
|
|
76
|
+
"react-compiler-runtime": "19.0.0-beta-e993439-20250328",
|
|
77
77
|
"slate": "0.112.0",
|
|
78
78
|
"slate-dom": "^0.112.2",
|
|
79
79
|
"slate-react": "0.112.1",
|
|
@@ -86,8 +86,8 @@
|
|
|
86
86
|
"@portabletext/toolkit": "^2.0.17",
|
|
87
87
|
"@sanity/diff-match-patch": "^3.2.0",
|
|
88
88
|
"@sanity/pkg-utils": "^7.2.2",
|
|
89
|
-
"@sanity/schema": "^3.
|
|
90
|
-
"@sanity/types": "^3.
|
|
89
|
+
"@sanity/schema": "^3.82.0",
|
|
90
|
+
"@sanity/types": "^3.82.0",
|
|
91
91
|
"@testing-library/jest-dom": "^6.6.3",
|
|
92
92
|
"@testing-library/react": "^16.2.0",
|
|
93
93
|
"@types/debug": "^4.1.12",
|
|
@@ -100,9 +100,9 @@
|
|
|
100
100
|
"@vitejs/plugin-react": "^4.3.4",
|
|
101
101
|
"@vitest/browser": "^3.1.1",
|
|
102
102
|
"@vitest/coverage-istanbul": "^3.1.1",
|
|
103
|
-
"babel-plugin-react-compiler": "19.0.0-beta-
|
|
103
|
+
"babel-plugin-react-compiler": "19.0.0-beta-e993439-20250328",
|
|
104
104
|
"eslint": "8.57.1",
|
|
105
|
-
"eslint-plugin-react-compiler": "19.0.0-beta-
|
|
105
|
+
"eslint-plugin-react-compiler": "19.0.0-beta-e993439-20250328",
|
|
106
106
|
"eslint-plugin-react-hooks": "experimental",
|
|
107
107
|
"jsdom": "^26.0.0",
|
|
108
108
|
"react": "^19.1.0",
|
|
@@ -115,8 +115,8 @@
|
|
|
115
115
|
"racejar": "1.2.3"
|
|
116
116
|
},
|
|
117
117
|
"peerDependencies": {
|
|
118
|
-
"@sanity/schema": "^3.
|
|
119
|
-
"@sanity/types": "^3.
|
|
118
|
+
"@sanity/schema": "^3.82.0",
|
|
119
|
+
"@sanity/types": "^3.82.0",
|
|
120
120
|
"react": "^16.9 || ^17 || ^18 || ^19",
|
|
121
121
|
"rxjs": "^7.8.2"
|
|
122
122
|
},
|
package/src/editor/Editable.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {useSelector} from '@xstate/react'
|
|
2
|
-
import {
|
|
1
|
+
import {useActorRef, useSelector} from '@xstate/react'
|
|
2
|
+
import {noop} from 'lodash'
|
|
3
3
|
import {
|
|
4
4
|
forwardRef,
|
|
5
5
|
useCallback,
|
|
@@ -16,16 +16,7 @@ import {
|
|
|
16
16
|
type MutableRefObject,
|
|
17
17
|
type TextareaHTMLAttributes,
|
|
18
18
|
} from 'react'
|
|
19
|
-
import {
|
|
20
|
-
Editor,
|
|
21
|
-
Path,
|
|
22
|
-
Range as SlateRange,
|
|
23
|
-
Transforms,
|
|
24
|
-
type BaseRange,
|
|
25
|
-
type NodeEntry,
|
|
26
|
-
type Operation,
|
|
27
|
-
type Text,
|
|
28
|
-
} from 'slate'
|
|
19
|
+
import {Editor, Transforms, type Text} from 'slate'
|
|
29
20
|
import {
|
|
30
21
|
ReactEditor,
|
|
31
22
|
Editable as SlateEditable,
|
|
@@ -38,11 +29,11 @@ import {debugWithName} from '../internal-utils/debug'
|
|
|
38
29
|
import {getDragSelection} from '../internal-utils/drag-selection'
|
|
39
30
|
import {getEventPosition} from '../internal-utils/event-position'
|
|
40
31
|
import {parseBlocks} from '../internal-utils/parse-blocks'
|
|
41
|
-
import {
|
|
32
|
+
import {toSlateRange} from '../internal-utils/ranges'
|
|
42
33
|
import {normalizeSelection} from '../internal-utils/selection'
|
|
43
34
|
import {getSelectionDomNodes} from '../internal-utils/selection-elements'
|
|
44
35
|
import {slateRangeToSelection} from '../internal-utils/slate-utils'
|
|
45
|
-
import {fromSlateValue
|
|
36
|
+
import {fromSlateValue} from '../internal-utils/values'
|
|
46
37
|
import * as selectors from '../selectors'
|
|
47
38
|
import type {
|
|
48
39
|
EditorSelection,
|
|
@@ -68,7 +59,10 @@ import {getEditorSnapshot} from './editor-selector'
|
|
|
68
59
|
import {usePortableTextEditor} from './hooks/usePortableTextEditor'
|
|
69
60
|
import {createWithHotkeys} from './plugins/createWithHotKeys'
|
|
70
61
|
import {PortableTextEditor} from './PortableTextEditor'
|
|
71
|
-
import {
|
|
62
|
+
import {
|
|
63
|
+
createDecorate,
|
|
64
|
+
rangeDecorationsMachine,
|
|
65
|
+
} from './range-decorations-machine'
|
|
72
66
|
|
|
73
67
|
const debug = debugWithName('component:Editable')
|
|
74
68
|
|
|
@@ -80,10 +74,6 @@ const PLACEHOLDER_STYLE: CSSProperties = {
|
|
|
80
74
|
right: 0,
|
|
81
75
|
}
|
|
82
76
|
|
|
83
|
-
interface BaseRangeWithDecoration extends BaseRange {
|
|
84
|
-
rangeDecoration: RangeDecoration
|
|
85
|
-
}
|
|
86
|
-
|
|
87
77
|
/**
|
|
88
78
|
* @public
|
|
89
79
|
*/
|
|
@@ -169,9 +159,6 @@ export const PortableTextEditable = forwardRef<
|
|
|
169
159
|
null,
|
|
170
160
|
)
|
|
171
161
|
const [hasInvalidValue, setHasInvalidValue] = useState(false)
|
|
172
|
-
const [rangeDecorationState, setRangeDecorationsState] = useState<
|
|
173
|
-
BaseRangeWithDecoration[]
|
|
174
|
-
>([])
|
|
175
162
|
|
|
176
163
|
// Forward ref to parent component
|
|
177
164
|
useImperativeHandle<HTMLDivElement | null, HTMLDivElement | null>(
|
|
@@ -179,8 +166,6 @@ export const PortableTextEditable = forwardRef<
|
|
|
179
166
|
() => ref.current,
|
|
180
167
|
)
|
|
181
168
|
|
|
182
|
-
const rangeDecorationsRef = useRef(rangeDecorations)
|
|
183
|
-
|
|
184
169
|
const editorActor = useContext(EditorActorContext)
|
|
185
170
|
const readOnly = useSelector(editorActor, (s) =>
|
|
186
171
|
s.matches({'edit mode': 'read only'}),
|
|
@@ -188,6 +173,33 @@ export const PortableTextEditable = forwardRef<
|
|
|
188
173
|
const schemaTypes = useSelector(editorActor, (s) => s.context.schema)
|
|
189
174
|
const slateEditor = useSlate()
|
|
190
175
|
|
|
176
|
+
const rangeDecorationsActor = useActorRef(rangeDecorationsMachine, {
|
|
177
|
+
input: {
|
|
178
|
+
readOnly,
|
|
179
|
+
slateEditor,
|
|
180
|
+
schema: schemaTypes,
|
|
181
|
+
rangeDecorations: rangeDecorations ?? [],
|
|
182
|
+
},
|
|
183
|
+
})
|
|
184
|
+
const decorate = useMemo(
|
|
185
|
+
() => createDecorate(rangeDecorationsActor),
|
|
186
|
+
[rangeDecorationsActor],
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
rangeDecorationsActor.send({
|
|
191
|
+
type: 'update read only',
|
|
192
|
+
readOnly,
|
|
193
|
+
})
|
|
194
|
+
}, [rangeDecorationsActor, readOnly])
|
|
195
|
+
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
rangeDecorationsActor.send({
|
|
198
|
+
type: 'range decorations updated',
|
|
199
|
+
rangeDecorations: rangeDecorations ?? [],
|
|
200
|
+
})
|
|
201
|
+
}, [rangeDecorationsActor, rangeDecorations])
|
|
202
|
+
|
|
191
203
|
const blockTypeName = schemaTypes.block.name
|
|
192
204
|
|
|
193
205
|
// Output a minimal React editor inside Editable when in readOnly mode.
|
|
@@ -312,83 +324,20 @@ export const PortableTextEditable = forwardRef<
|
|
|
312
324
|
}
|
|
313
325
|
}, [blockTypeName, editorActor, propsSelection, slateEditor])
|
|
314
326
|
|
|
315
|
-
const syncRangeDecorations = useCallback(
|
|
316
|
-
(operation?: Operation) => {
|
|
317
|
-
if (rangeDecorations && rangeDecorations.length > 0) {
|
|
318
|
-
const newSlateRanges: BaseRangeWithDecoration[] = []
|
|
319
|
-
rangeDecorations.forEach((rangeDecorationItem) => {
|
|
320
|
-
const slateRange = toSlateRange(
|
|
321
|
-
rangeDecorationItem.selection,
|
|
322
|
-
slateEditor,
|
|
323
|
-
)
|
|
324
|
-
if (!SlateRange.isRange(slateRange)) {
|
|
325
|
-
if (rangeDecorationItem.onMoved) {
|
|
326
|
-
rangeDecorationItem.onMoved({
|
|
327
|
-
newSelection: null,
|
|
328
|
-
rangeDecoration: rangeDecorationItem,
|
|
329
|
-
origin: 'local',
|
|
330
|
-
})
|
|
331
|
-
}
|
|
332
|
-
return
|
|
333
|
-
}
|
|
334
|
-
let newRange: BaseRange | null | undefined
|
|
335
|
-
if (operation) {
|
|
336
|
-
newRange = moveRangeByOperation(slateRange, operation)
|
|
337
|
-
if (
|
|
338
|
-
(newRange && newRange !== slateRange) ||
|
|
339
|
-
(newRange === null && slateRange)
|
|
340
|
-
) {
|
|
341
|
-
const newRangeSelection = newRange
|
|
342
|
-
? slateRangeToSelection({
|
|
343
|
-
schema: schemaTypes,
|
|
344
|
-
editor: slateEditor,
|
|
345
|
-
range: newRange,
|
|
346
|
-
})
|
|
347
|
-
: null
|
|
348
|
-
if (rangeDecorationItem.onMoved) {
|
|
349
|
-
rangeDecorationItem.onMoved({
|
|
350
|
-
newSelection: newRangeSelection,
|
|
351
|
-
rangeDecoration: rangeDecorationItem,
|
|
352
|
-
origin: 'local',
|
|
353
|
-
})
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
// If the newRange is null, it means that the range is not valid anymore and should be removed
|
|
358
|
-
// If it's undefined, it means that the slateRange is still valid and should be kept
|
|
359
|
-
if (newRange !== null) {
|
|
360
|
-
newSlateRanges.push({
|
|
361
|
-
...(newRange || slateRange),
|
|
362
|
-
rangeDecoration: rangeDecorationItem,
|
|
363
|
-
})
|
|
364
|
-
}
|
|
365
|
-
})
|
|
366
|
-
if (newSlateRanges.length > 0) {
|
|
367
|
-
setRangeDecorationsState(newSlateRanges)
|
|
368
|
-
return
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
setRangeDecorationsState((rangeDecorationState) => {
|
|
372
|
-
// If there's state then we want to reset
|
|
373
|
-
if (rangeDecorationState.length > 0) {
|
|
374
|
-
return []
|
|
375
|
-
}
|
|
376
|
-
// Otherwise we no-op, React will skip a state update if what we return has reference equality to the previous state
|
|
377
|
-
return rangeDecorationState
|
|
378
|
-
})
|
|
379
|
-
},
|
|
380
|
-
[rangeDecorations, schemaTypes, slateEditor],
|
|
381
|
-
)
|
|
382
|
-
|
|
383
327
|
// Restore selection from props when the editor has been initialized properly with it's value
|
|
384
328
|
useEffect(() => {
|
|
385
329
|
const onReady = editorActor.on('ready', () => {
|
|
386
|
-
|
|
330
|
+
rangeDecorationsActor.send({
|
|
331
|
+
type: 'ready',
|
|
332
|
+
})
|
|
333
|
+
|
|
387
334
|
restoreSelectionFromProps()
|
|
388
335
|
})
|
|
336
|
+
|
|
389
337
|
const onInvalidValue = editorActor.on('invalid value', () => {
|
|
390
338
|
setHasInvalidValue(true)
|
|
391
339
|
})
|
|
340
|
+
|
|
392
341
|
const onValueChanged = editorActor.on('value changed', () => {
|
|
393
342
|
setHasInvalidValue(false)
|
|
394
343
|
})
|
|
@@ -398,7 +347,7 @@ export const PortableTextEditable = forwardRef<
|
|
|
398
347
|
onInvalidValue.unsubscribe()
|
|
399
348
|
onValueChanged.unsubscribe()
|
|
400
349
|
}
|
|
401
|
-
}, [editorActor, restoreSelectionFromProps
|
|
350
|
+
}, [rangeDecorationsActor, editorActor, restoreSelectionFromProps])
|
|
402
351
|
|
|
403
352
|
// Restore selection from props when it changes
|
|
404
353
|
useEffect(() => {
|
|
@@ -407,32 +356,6 @@ export const PortableTextEditable = forwardRef<
|
|
|
407
356
|
}
|
|
408
357
|
}, [hasInvalidValue, propsSelection, restoreSelectionFromProps])
|
|
409
358
|
|
|
410
|
-
const [syncedRangeDecorations, setSyncedRangeDecorations] = useState(false)
|
|
411
|
-
useEffect(() => {
|
|
412
|
-
if (!syncedRangeDecorations) {
|
|
413
|
-
// We only want this to run once, on mount
|
|
414
|
-
setSyncedRangeDecorations(true)
|
|
415
|
-
syncRangeDecorations()
|
|
416
|
-
}
|
|
417
|
-
}, [syncRangeDecorations, syncedRangeDecorations])
|
|
418
|
-
|
|
419
|
-
useEffect(() => {
|
|
420
|
-
if (!isEqual(rangeDecorations, rangeDecorationsRef.current)) {
|
|
421
|
-
syncRangeDecorations()
|
|
422
|
-
}
|
|
423
|
-
rangeDecorationsRef.current = rangeDecorations
|
|
424
|
-
}, [rangeDecorations, syncRangeDecorations])
|
|
425
|
-
|
|
426
|
-
// Sync range decorations after an operation is applied
|
|
427
|
-
useEffect(() => {
|
|
428
|
-
const teardown = withSyncRangeDecorations({
|
|
429
|
-
editorActor,
|
|
430
|
-
slateEditor,
|
|
431
|
-
syncRangeDecorations,
|
|
432
|
-
})
|
|
433
|
-
return () => teardown()
|
|
434
|
-
}, [editorActor, slateEditor, syncRangeDecorations])
|
|
435
|
-
|
|
436
359
|
// Handle from props onCopy function
|
|
437
360
|
const handleCopy = useCallback(
|
|
438
361
|
(event: ClipboardEvent<HTMLDivElement>): void | ReactEditor => {
|
|
@@ -855,55 +778,6 @@ export const PortableTextEditable = forwardRef<
|
|
|
855
778
|
}
|
|
856
779
|
}, [portableTextEditor, scrollSelectionIntoView])
|
|
857
780
|
|
|
858
|
-
const decorate: (entry: NodeEntry) => BaseRange[] = useCallback(
|
|
859
|
-
([, path]) => {
|
|
860
|
-
if (isEqualToEmptyEditor(slateEditor.children, schemaTypes)) {
|
|
861
|
-
return [
|
|
862
|
-
{
|
|
863
|
-
anchor: {
|
|
864
|
-
path: [0, 0],
|
|
865
|
-
offset: 0,
|
|
866
|
-
},
|
|
867
|
-
focus: {
|
|
868
|
-
path: [0, 0],
|
|
869
|
-
offset: 0,
|
|
870
|
-
},
|
|
871
|
-
placeholder: true,
|
|
872
|
-
},
|
|
873
|
-
]
|
|
874
|
-
}
|
|
875
|
-
// Editor node has a path length of 0 (should never be decorated)
|
|
876
|
-
if (path.length === 0) {
|
|
877
|
-
return []
|
|
878
|
-
}
|
|
879
|
-
const result = rangeDecorationState.filter((item) => {
|
|
880
|
-
// Special case in order to only return one decoration for collapsed ranges
|
|
881
|
-
if (SlateRange.isCollapsed(item)) {
|
|
882
|
-
// Collapsed ranges should only be decorated if they are on a block child level (length 2)
|
|
883
|
-
if (path.length !== 2) {
|
|
884
|
-
return false
|
|
885
|
-
}
|
|
886
|
-
return (
|
|
887
|
-
Path.equals(item.focus.path, path) &&
|
|
888
|
-
Path.equals(item.anchor.path, path)
|
|
889
|
-
)
|
|
890
|
-
}
|
|
891
|
-
// Include decorations that either include or intersects with this path
|
|
892
|
-
return (
|
|
893
|
-
SlateRange.intersection(item, {
|
|
894
|
-
anchor: {path, offset: 0},
|
|
895
|
-
focus: {path, offset: 0},
|
|
896
|
-
}) || SlateRange.includes(item, path)
|
|
897
|
-
)
|
|
898
|
-
})
|
|
899
|
-
if (result.length > 0) {
|
|
900
|
-
return result
|
|
901
|
-
}
|
|
902
|
-
return []
|
|
903
|
-
},
|
|
904
|
-
[slateEditor, schemaTypes, rangeDecorationState],
|
|
905
|
-
)
|
|
906
|
-
|
|
907
781
|
// Set the forwarded ref to be the Slate editable DOM element
|
|
908
782
|
// Also set the editable element in a state so that the MutationObserver
|
|
909
783
|
// is setup when this element is ready.
|
|
@@ -118,6 +118,32 @@ describe('RangeDecorations', () => {
|
|
|
118
118
|
value={value}
|
|
119
119
|
/>,
|
|
120
120
|
)
|
|
121
|
+
await waitFor(() => {
|
|
122
|
+
expect([rangeDecorationIteration, 'updated-with-different']).toEqual([
|
|
123
|
+
1,
|
|
124
|
+
'updated-with-different',
|
|
125
|
+
])
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
// Update the range decorations with a new offset again
|
|
129
|
+
rangeDecorations = [
|
|
130
|
+
{
|
|
131
|
+
component: RangeDecorationTestComponent,
|
|
132
|
+
selection: {
|
|
133
|
+
anchor: {path: [{_key: '123'}, 'children', {_key: '567'}], offset: 0},
|
|
134
|
+
focus: {path: [{_key: '123'}, 'children', {_key: '567'}], offset: 2},
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
]
|
|
138
|
+
rerender(
|
|
139
|
+
<PortableTextEditorTester
|
|
140
|
+
onChange={onChange}
|
|
141
|
+
rangeDecorations={rangeDecorations}
|
|
142
|
+
ref={editorRef}
|
|
143
|
+
schemaType={schemaType}
|
|
144
|
+
value={value}
|
|
145
|
+
/>,
|
|
146
|
+
)
|
|
121
147
|
await waitFor(() => {
|
|
122
148
|
expect([rangeDecorationIteration, 'updated-with-different']).toEqual([
|
|
123
149
|
2,
|