@portabletext/editor 1.44.4 → 1.44.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@portabletext/editor",
3
- "version": "1.44.4",
3
+ "version": "1.44.5",
4
4
  "description": "Portable Text Editor made in React",
5
5
  "keywords": [
6
6
  "sanity",
@@ -1,5 +1,5 @@
1
- import {useSelector} from '@xstate/react'
2
- import {isEqual, noop} from 'lodash'
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 {moveRangeByOperation, toSlateRange} from '../internal-utils/ranges'
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, isEqualToEmptyEditor} from '../internal-utils/values'
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 {withSyncRangeDecorations} from './withSyncRangeDecorations'
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
- syncRangeDecorations()
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, syncRangeDecorations])
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,
@@ -0,0 +1,346 @@
1
+ import {isEqual} from 'lodash'
2
+ import {
3
+ Path,
4
+ Range,
5
+ type BaseRange,
6
+ type NodeEntry,
7
+ type Operation,
8
+ } from 'slate'
9
+ import {
10
+ and,
11
+ assertEvent,
12
+ assign,
13
+ fromCallback,
14
+ setup,
15
+ type ActorRefFrom,
16
+ type AnyEventObject,
17
+ type CallbackLogicFunction,
18
+ } from 'xstate'
19
+ import type {EditorSchema} from '..'
20
+ import {moveRangeByOperation, toSlateRange} from '../internal-utils/ranges'
21
+ import {slateRangeToSelection} from '../internal-utils/slate-utils'
22
+ import {isEqualToEmptyEditor} from '../internal-utils/values'
23
+ import type {PortableTextSlateEditor, RangeDecoration} from '../types/editor'
24
+
25
+ const slateOperationCallback: CallbackLogicFunction<
26
+ AnyEventObject,
27
+ {type: 'slate operation'; operation: Operation},
28
+ {slateEditor: PortableTextSlateEditor}
29
+ > = ({input, sendBack}) => {
30
+ const originalApply = input.slateEditor.apply
31
+
32
+ input.slateEditor.apply = (op) => {
33
+ if (op.type !== 'set_selection') {
34
+ sendBack({type: 'slate operation', operation: op})
35
+ }
36
+
37
+ originalApply(op)
38
+ }
39
+
40
+ return () => {
41
+ input.slateEditor.apply = originalApply
42
+ }
43
+ }
44
+
45
+ type DecoratedRange = BaseRange & {rangeDecoration: RangeDecoration}
46
+
47
+ export const rangeDecorationsMachine = setup({
48
+ types: {
49
+ context: {} as {
50
+ decoratedRanges: Array<DecoratedRange>
51
+ pendingRangeDecorations: Array<RangeDecoration>
52
+ readOnly: boolean
53
+ schema: EditorSchema
54
+ slateEditor: PortableTextSlateEditor
55
+ },
56
+ input: {} as {
57
+ rangeDecorations: Array<RangeDecoration>
58
+ readOnly: boolean
59
+ schema: EditorSchema
60
+ slateEditor: PortableTextSlateEditor
61
+ },
62
+ events: {} as
63
+ | {
64
+ type: 'ready'
65
+ }
66
+ | {
67
+ type: 'range decorations updated'
68
+ rangeDecorations: Array<RangeDecoration>
69
+ }
70
+ | {
71
+ type: 'slate operation'
72
+ operation: Operation
73
+ }
74
+ | {
75
+ type: 'update read only'
76
+ readOnly: boolean
77
+ },
78
+ },
79
+ actions: {
80
+ 'update pending range decorations': assign({
81
+ pendingRangeDecorations: ({event}) => {
82
+ assertEvent(event, 'range decorations updated')
83
+
84
+ return event.rangeDecorations
85
+ },
86
+ }),
87
+ 'set up initial range decorations': assign({
88
+ decoratedRanges: ({context, event}) => {
89
+ assertEvent(event, 'ready')
90
+
91
+ const rangeDecorationState: Array<DecoratedRange> = []
92
+
93
+ for (const rangeDecoration of context.pendingRangeDecorations) {
94
+ const slateRange = toSlateRange(
95
+ rangeDecoration.selection,
96
+ context.slateEditor,
97
+ )
98
+
99
+ if (!Range.isRange(slateRange)) {
100
+ rangeDecoration.onMoved?.({
101
+ newSelection: null,
102
+ rangeDecoration,
103
+ origin: 'local',
104
+ })
105
+ continue
106
+ }
107
+
108
+ rangeDecorationState.push({
109
+ rangeDecoration,
110
+ ...slateRange,
111
+ })
112
+ }
113
+
114
+ return rangeDecorationState
115
+ },
116
+ }),
117
+ 'update range decorations': assign({
118
+ decoratedRanges: ({context, event}) => {
119
+ assertEvent(event, 'range decorations updated')
120
+
121
+ const rangeDecorationState: Array<DecoratedRange> = []
122
+
123
+ for (const rangeDecoration of event.rangeDecorations) {
124
+ const slateRange = toSlateRange(
125
+ rangeDecoration.selection,
126
+ context.slateEditor,
127
+ )
128
+
129
+ if (!Range.isRange(slateRange)) {
130
+ rangeDecoration.onMoved?.({
131
+ newSelection: null,
132
+ rangeDecoration,
133
+ origin: 'local',
134
+ })
135
+ continue
136
+ }
137
+
138
+ rangeDecorationState.push({
139
+ rangeDecoration,
140
+ ...slateRange,
141
+ })
142
+ }
143
+
144
+ return rangeDecorationState
145
+ },
146
+ }),
147
+ 'move range decorations': assign({
148
+ decoratedRanges: ({context, event}) => {
149
+ assertEvent(event, 'slate operation')
150
+
151
+ const rangeDecorationState: Array<DecoratedRange> = []
152
+
153
+ for (const decoratedRange of context.decoratedRanges) {
154
+ const slateRange = toSlateRange(
155
+ decoratedRange.rangeDecoration.selection,
156
+ context.slateEditor,
157
+ )
158
+
159
+ if (!Range.isRange(slateRange)) {
160
+ decoratedRange.rangeDecoration.onMoved?.({
161
+ newSelection: null,
162
+ rangeDecoration: decoratedRange.rangeDecoration,
163
+ origin: 'local',
164
+ })
165
+ continue
166
+ }
167
+
168
+ let newRange: BaseRange | null | undefined
169
+
170
+ newRange = moveRangeByOperation(slateRange, event.operation)
171
+ if (
172
+ (newRange && newRange !== slateRange) ||
173
+ (newRange === null && slateRange)
174
+ ) {
175
+ const newRangeSelection = newRange
176
+ ? slateRangeToSelection({
177
+ schema: context.schema,
178
+ editor: context.slateEditor,
179
+ range: newRange,
180
+ })
181
+ : null
182
+
183
+ decoratedRange.rangeDecoration.onMoved?.({
184
+ newSelection: newRangeSelection,
185
+ rangeDecoration: decoratedRange.rangeDecoration,
186
+ origin: 'local',
187
+ })
188
+ }
189
+
190
+ // If the newRange is null, it means that the range is not valid anymore and should be removed
191
+ // If it's undefined, it means that the slateRange is still valid and should be kept
192
+ if (newRange !== null) {
193
+ rangeDecorationState.push({
194
+ ...(newRange || slateRange),
195
+ rangeDecoration: decoratedRange.rangeDecoration,
196
+ })
197
+ }
198
+ }
199
+
200
+ return rangeDecorationState
201
+ },
202
+ }),
203
+ 'assign readOnly': assign({
204
+ readOnly: ({event}) => {
205
+ assertEvent(event, 'update read only')
206
+ return event.readOnly
207
+ },
208
+ }),
209
+ },
210
+ actors: {
211
+ 'slate operation listener': fromCallback(slateOperationCallback),
212
+ },
213
+ guards: {
214
+ 'has range decorations': ({context}) => context.decoratedRanges.length > 0,
215
+ 'has different decorations': ({context, event}) => {
216
+ assertEvent(event, 'range decorations updated')
217
+
218
+ return !isEqual(context.pendingRangeDecorations, event.rangeDecorations)
219
+ },
220
+ 'not read only': ({context}) => !context.readOnly,
221
+ },
222
+ }).createMachine({
223
+ id: 'range decorations',
224
+ context: ({input}) => ({
225
+ readOnly: input.readOnly,
226
+ pendingRangeDecorations: input.rangeDecorations,
227
+ decoratedRanges: [],
228
+ schema: input.schema,
229
+ slateEditor: input.slateEditor,
230
+ }),
231
+ invoke: {
232
+ src: 'slate operation listener',
233
+ input: ({context}) => ({slateEditor: context.slateEditor}),
234
+ },
235
+ on: {
236
+ 'update read only': {
237
+ actions: ['assign readOnly'],
238
+ },
239
+ },
240
+ initial: 'idle',
241
+ states: {
242
+ idle: {
243
+ on: {
244
+ 'range decorations updated': {
245
+ actions: ['update pending range decorations'],
246
+ },
247
+ 'ready': {
248
+ target: 'ready',
249
+ actions: ['set up initial range decorations'],
250
+ },
251
+ },
252
+ },
253
+ ready: {
254
+ initial: 'idle',
255
+ on: {
256
+ 'range decorations updated': {
257
+ target: '.idle',
258
+ guard: 'has different decorations',
259
+ actions: [
260
+ 'update range decorations',
261
+ 'update pending range decorations',
262
+ ],
263
+ },
264
+ },
265
+ states: {
266
+ 'idle': {
267
+ on: {
268
+ 'slate operation': {
269
+ target: 'moving range decorations',
270
+ guard: and(['has range decorations', 'not read only']),
271
+ },
272
+ },
273
+ },
274
+ 'moving range decorations': {
275
+ entry: ['move range decorations'],
276
+ always: {
277
+ target: 'idle',
278
+ },
279
+ },
280
+ },
281
+ },
282
+ },
283
+ })
284
+
285
+ export function createDecorate(
286
+ rangeDecorationActor: ActorRefFrom<typeof rangeDecorationsMachine>,
287
+ ) {
288
+ return function decorate([, path]: NodeEntry): Array<BaseRange> {
289
+ if (
290
+ isEqualToEmptyEditor(
291
+ rangeDecorationActor.getSnapshot().context.slateEditor.children,
292
+ rangeDecorationActor.getSnapshot().context.schema,
293
+ )
294
+ ) {
295
+ return [
296
+ {
297
+ anchor: {
298
+ path: [0, 0],
299
+ offset: 0,
300
+ },
301
+ focus: {
302
+ path: [0, 0],
303
+ offset: 0,
304
+ },
305
+ placeholder: true,
306
+ } as BaseRange,
307
+ ]
308
+ }
309
+
310
+ // Editor node has a path length of 0 (should never be decorated)
311
+ if (path.length === 0) {
312
+ return []
313
+ }
314
+
315
+ const result = rangeDecorationActor
316
+ .getSnapshot()
317
+ .context.decoratedRanges.filter((item) => {
318
+ // Special case in order to only return one decoration for collapsed ranges
319
+ if (Range.isCollapsed(item)) {
320
+ // Collapsed ranges should only be decorated if they are on a block child level (length 2)
321
+ if (path.length !== 2) {
322
+ return false
323
+ }
324
+
325
+ return (
326
+ Path.equals(item.focus.path, path) &&
327
+ Path.equals(item.anchor.path, path)
328
+ )
329
+ }
330
+
331
+ // Include decorations that either include or intersects with this path
332
+ return (
333
+ Range.intersection(item, {
334
+ anchor: {path, offset: 0},
335
+ focus: {path, offset: 0},
336
+ }) || Range.includes(item, path)
337
+ )
338
+ })
339
+
340
+ if (result.length > 0) {
341
+ return result
342
+ }
343
+
344
+ return []
345
+ }
346
+ }