@portabletext/editor 0.0.0

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.
Files changed (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +3 -0
  3. package/lib/index.d.mts +911 -0
  4. package/lib/index.d.ts +911 -0
  5. package/lib/index.esm.js +4896 -0
  6. package/lib/index.esm.js.map +1 -0
  7. package/lib/index.js +4874 -0
  8. package/lib/index.js.map +1 -0
  9. package/lib/index.mjs +4896 -0
  10. package/lib/index.mjs.map +1 -0
  11. package/package.json +119 -0
  12. package/src/editor/Editable.tsx +683 -0
  13. package/src/editor/PortableTextEditor.tsx +308 -0
  14. package/src/editor/__tests__/PortableTextEditor.test.tsx +386 -0
  15. package/src/editor/__tests__/PortableTextEditorTester.tsx +116 -0
  16. package/src/editor/__tests__/RangeDecorations.test.tsx +115 -0
  17. package/src/editor/__tests__/handleClick.test.tsx +218 -0
  18. package/src/editor/__tests__/pteWarningsSelfSolving.test.tsx +389 -0
  19. package/src/editor/__tests__/utils.ts +39 -0
  20. package/src/editor/components/DraggableBlock.tsx +287 -0
  21. package/src/editor/components/Element.tsx +279 -0
  22. package/src/editor/components/Leaf.tsx +288 -0
  23. package/src/editor/components/SlateContainer.tsx +81 -0
  24. package/src/editor/components/Synchronizer.tsx +190 -0
  25. package/src/editor/hooks/usePortableTextEditor.ts +23 -0
  26. package/src/editor/hooks/usePortableTextEditorKeyGenerator.ts +24 -0
  27. package/src/editor/hooks/usePortableTextEditorSelection.ts +22 -0
  28. package/src/editor/hooks/usePortableTextEditorValue.ts +16 -0
  29. package/src/editor/hooks/usePortableTextReadOnly.ts +20 -0
  30. package/src/editor/hooks/useSyncValue.test.tsx +125 -0
  31. package/src/editor/hooks/useSyncValue.ts +372 -0
  32. package/src/editor/nodes/DefaultAnnotation.tsx +16 -0
  33. package/src/editor/nodes/DefaultObject.tsx +15 -0
  34. package/src/editor/nodes/index.ts +189 -0
  35. package/src/editor/plugins/__tests__/withEditableAPIDelete.test.tsx +244 -0
  36. package/src/editor/plugins/__tests__/withEditableAPIGetFragment.test.tsx +142 -0
  37. package/src/editor/plugins/__tests__/withEditableAPIInsert.test.tsx +346 -0
  38. package/src/editor/plugins/__tests__/withEditableAPISelectionsOverlapping.test.tsx +162 -0
  39. package/src/editor/plugins/__tests__/withHotkeys.test.tsx +212 -0
  40. package/src/editor/plugins/__tests__/withInsertBreak.test.tsx +204 -0
  41. package/src/editor/plugins/__tests__/withPlaceholderBlock.test.tsx +133 -0
  42. package/src/editor/plugins/__tests__/withPortableTextLists.test.tsx +65 -0
  43. package/src/editor/plugins/__tests__/withPortableTextMarkModel.test.tsx +1377 -0
  44. package/src/editor/plugins/__tests__/withPortableTextSelections.test.tsx +91 -0
  45. package/src/editor/plugins/__tests__/withUndoRedo.test.tsx +115 -0
  46. package/src/editor/plugins/createWithEditableAPI.ts +573 -0
  47. package/src/editor/plugins/createWithHotKeys.ts +304 -0
  48. package/src/editor/plugins/createWithInsertBreak.ts +45 -0
  49. package/src/editor/plugins/createWithInsertData.ts +359 -0
  50. package/src/editor/plugins/createWithMaxBlocks.ts +24 -0
  51. package/src/editor/plugins/createWithObjectKeys.ts +63 -0
  52. package/src/editor/plugins/createWithPatches.ts +274 -0
  53. package/src/editor/plugins/createWithPlaceholderBlock.ts +36 -0
  54. package/src/editor/plugins/createWithPortableTextBlockStyle.ts +91 -0
  55. package/src/editor/plugins/createWithPortableTextLists.ts +160 -0
  56. package/src/editor/plugins/createWithPortableTextMarkModel.ts +441 -0
  57. package/src/editor/plugins/createWithPortableTextSelections.ts +65 -0
  58. package/src/editor/plugins/createWithSchemaTypes.ts +76 -0
  59. package/src/editor/plugins/createWithUndoRedo.ts +494 -0
  60. package/src/editor/plugins/createWithUtils.ts +81 -0
  61. package/src/editor/plugins/index.ts +155 -0
  62. package/src/index.ts +11 -0
  63. package/src/patch/PatchEvent.ts +33 -0
  64. package/src/patch/applyPatch.ts +29 -0
  65. package/src/patch/array.ts +89 -0
  66. package/src/patch/arrayInsert.ts +27 -0
  67. package/src/patch/object.ts +39 -0
  68. package/src/patch/patches.ts +53 -0
  69. package/src/patch/primitive.ts +43 -0
  70. package/src/patch/string.ts +51 -0
  71. package/src/types/editor.ts +576 -0
  72. package/src/types/options.ts +17 -0
  73. package/src/types/patch.ts +65 -0
  74. package/src/types/slate.ts +25 -0
  75. package/src/utils/__tests__/dmpToOperations.test.ts +181 -0
  76. package/src/utils/__tests__/operationToPatches.test.ts +421 -0
  77. package/src/utils/__tests__/patchToOperations.test.ts +293 -0
  78. package/src/utils/__tests__/ranges.test.ts +18 -0
  79. package/src/utils/__tests__/valueNormalization.test.tsx +62 -0
  80. package/src/utils/__tests__/values.test.ts +253 -0
  81. package/src/utils/applyPatch.ts +407 -0
  82. package/src/utils/bufferUntil.ts +15 -0
  83. package/src/utils/debug.ts +12 -0
  84. package/src/utils/getPortableTextMemberSchemaTypes.ts +100 -0
  85. package/src/utils/operationToPatches.ts +357 -0
  86. package/src/utils/patches.ts +36 -0
  87. package/src/utils/paths.ts +60 -0
  88. package/src/utils/ranges.ts +77 -0
  89. package/src/utils/schema.ts +8 -0
  90. package/src/utils/selection.ts +65 -0
  91. package/src/utils/ucs2Indices.ts +67 -0
  92. package/src/utils/validateValue.ts +394 -0
  93. package/src/utils/values.ts +208 -0
  94. package/src/utils/weakMaps.ts +24 -0
  95. package/src/utils/withChanges.ts +25 -0
  96. package/src/utils/withPreserveKeys.ts +14 -0
  97. package/src/utils/withoutPatching.ts +14 -0
@@ -0,0 +1,683 @@
1
+ import {type PortableTextBlock} from '@sanity/types'
2
+ import {isEqual, noop} from 'lodash'
3
+ import {
4
+ type ClipboardEvent,
5
+ type CSSProperties,
6
+ type FocusEventHandler,
7
+ type ForwardedRef,
8
+ forwardRef,
9
+ type HTMLProps,
10
+ type KeyboardEvent,
11
+ type MutableRefObject,
12
+ type ReactNode,
13
+ type TextareaHTMLAttributes,
14
+ useCallback,
15
+ useEffect,
16
+ useImperativeHandle,
17
+ useMemo,
18
+ useRef,
19
+ useState,
20
+ } from 'react'
21
+ import {
22
+ type BaseRange,
23
+ Editor,
24
+ Node,
25
+ type NodeEntry,
26
+ type Operation,
27
+ Path,
28
+ Range as SlateRange,
29
+ type Text,
30
+ Transforms,
31
+ } from 'slate'
32
+ import {
33
+ Editable as SlateEditable,
34
+ ReactEditor,
35
+ type RenderElementProps,
36
+ type RenderLeafProps,
37
+ useSlate,
38
+ } from 'slate-react'
39
+
40
+ import {
41
+ type EditorChange,
42
+ type EditorSelection,
43
+ type OnCopyFn,
44
+ type OnPasteFn,
45
+ type OnPasteResult,
46
+ type RangeDecoration,
47
+ type RenderAnnotationFunction,
48
+ type RenderBlockFunction,
49
+ type RenderChildFunction,
50
+ type RenderDecoratorFunction,
51
+ type RenderListItemFunction,
52
+ type RenderStyleFunction,
53
+ type ScrollSelectionIntoViewFunction,
54
+ } from '../types/editor'
55
+ import {type HotkeyOptions} from '../types/options'
56
+ import {type SlateTextBlock, type VoidElement} from '../types/slate'
57
+ import {debugWithName} from '../utils/debug'
58
+ import {moveRangeByOperation, toPortableTextRange, toSlateRange} from '../utils/ranges'
59
+ import {normalizeSelection} from '../utils/selection'
60
+ import {fromSlateValue, isEqualToEmptyEditor, toSlateValue} from '../utils/values'
61
+ import {Element} from './components/Element'
62
+ import {Leaf} from './components/Leaf'
63
+ import {usePortableTextEditor} from './hooks/usePortableTextEditor'
64
+ import {usePortableTextEditorKeyGenerator} from './hooks/usePortableTextEditorKeyGenerator'
65
+ import {usePortableTextEditorReadOnlyStatus} from './hooks/usePortableTextReadOnly'
66
+ import {createWithHotkeys, createWithInsertData} from './plugins'
67
+ import {PortableTextEditor} from './PortableTextEditor'
68
+
69
+ const debug = debugWithName('component:Editable')
70
+
71
+ const PLACEHOLDER_STYLE: CSSProperties = {
72
+ position: 'absolute',
73
+ userSelect: 'none',
74
+ pointerEvents: 'none',
75
+ left: 0,
76
+ right: 0,
77
+ }
78
+
79
+ interface BaseRangeWithDecoration extends BaseRange {
80
+ rangeDecoration: RangeDecoration
81
+ }
82
+
83
+ const EMPTY_DECORATIONS_STATE: BaseRangeWithDecoration[] = []
84
+
85
+ /**
86
+ * @public
87
+ */
88
+ export type PortableTextEditableProps = Omit<
89
+ TextareaHTMLAttributes<HTMLDivElement>,
90
+ 'onPaste' | 'onCopy' | 'onBeforeInput'
91
+ > & {
92
+ hotkeys?: HotkeyOptions
93
+ onBeforeInput?: (event: InputEvent) => void
94
+ onPaste?: OnPasteFn
95
+ onCopy?: OnCopyFn
96
+ ref: MutableRefObject<HTMLDivElement | null>
97
+ rangeDecorations?: RangeDecoration[]
98
+ renderAnnotation?: RenderAnnotationFunction
99
+ renderBlock?: RenderBlockFunction
100
+ renderChild?: RenderChildFunction
101
+ renderDecorator?: RenderDecoratorFunction
102
+ renderListItem?: RenderListItemFunction
103
+ renderPlaceholder?: () => ReactNode
104
+ renderStyle?: RenderStyleFunction
105
+ scrollSelectionIntoView?: ScrollSelectionIntoViewFunction
106
+ selection?: EditorSelection
107
+ spellCheck?: boolean
108
+ }
109
+
110
+ /**
111
+ * @public
112
+ */
113
+ export const PortableTextEditable = forwardRef(function PortableTextEditable(
114
+ props: PortableTextEditableProps &
115
+ Omit<HTMLProps<HTMLDivElement>, 'as' | 'onPaste' | 'onBeforeInput'>,
116
+ forwardedRef: ForwardedRef<HTMLDivElement>,
117
+ ) {
118
+ const {
119
+ hotkeys,
120
+ onBlur,
121
+ onFocus,
122
+ onBeforeInput,
123
+ onPaste,
124
+ onCopy,
125
+ onClick,
126
+ rangeDecorations,
127
+ renderAnnotation,
128
+ renderBlock,
129
+ renderChild,
130
+ renderDecorator,
131
+ renderListItem,
132
+ renderPlaceholder,
133
+ renderStyle,
134
+ selection: propsSelection,
135
+ scrollSelectionIntoView,
136
+ spellCheck,
137
+ ...restProps
138
+ } = props
139
+
140
+ const portableTextEditor = usePortableTextEditor()
141
+ const readOnly = usePortableTextEditorReadOnlyStatus()
142
+ const keyGenerator = usePortableTextEditorKeyGenerator()
143
+ const ref = useRef<HTMLDivElement | null>(null)
144
+ const [editableElement, setEditableElement] = useState<HTMLDivElement | null>(null)
145
+ const [hasInvalidValue, setHasInvalidValue] = useState(false)
146
+ const [rangeDecorationState, setRangeDecorationsState] =
147
+ useState<BaseRangeWithDecoration[]>(EMPTY_DECORATIONS_STATE)
148
+
149
+ // Forward ref to parent component
150
+ useImperativeHandle<HTMLDivElement | null, HTMLDivElement | null>(forwardedRef, () => ref.current)
151
+
152
+ const rangeDecorationsRef = useRef(rangeDecorations)
153
+
154
+ const {change$, schemaTypes} = portableTextEditor
155
+ const slateEditor = useSlate()
156
+
157
+ const blockTypeName = schemaTypes.block.name
158
+
159
+ // React/UI-specific plugins
160
+ const withInsertData = useMemo(
161
+ () => createWithInsertData(change$, schemaTypes, keyGenerator),
162
+ [change$, keyGenerator, schemaTypes],
163
+ )
164
+ const withHotKeys = useMemo(
165
+ () => createWithHotkeys(schemaTypes, portableTextEditor, hotkeys),
166
+ [hotkeys, portableTextEditor, schemaTypes],
167
+ )
168
+
169
+ // Output a minimal React editor inside Editable when in readOnly mode.
170
+ // NOTE: make sure all the plugins used here can be safely run over again at any point.
171
+ // There will be a problem if they redefine editor methods and then calling the original method within themselves.
172
+ useMemo(() => {
173
+ if (readOnly) {
174
+ debug('Editable is in read only mode')
175
+ return withInsertData(slateEditor)
176
+ }
177
+ debug('Editable is in edit mode')
178
+ return withInsertData(withHotKeys(slateEditor))
179
+ }, [readOnly, slateEditor, withHotKeys, withInsertData])
180
+
181
+ const renderElement = useCallback(
182
+ (eProps: RenderElementProps) => (
183
+ <Element
184
+ {...eProps}
185
+ readOnly={readOnly}
186
+ renderBlock={renderBlock}
187
+ renderChild={renderChild}
188
+ renderListItem={renderListItem}
189
+ renderStyle={renderStyle}
190
+ schemaTypes={schemaTypes}
191
+ spellCheck={spellCheck}
192
+ />
193
+ ),
194
+ [schemaTypes, spellCheck, readOnly, renderBlock, renderChild, renderListItem, renderStyle],
195
+ )
196
+
197
+ const renderLeaf = useCallback(
198
+ (
199
+ lProps: RenderLeafProps & {
200
+ leaf: Text & {placeholder?: boolean; rangeDecoration?: RangeDecoration}
201
+ },
202
+ ) => {
203
+ if (lProps.leaf._type === 'span') {
204
+ let rendered = (
205
+ <Leaf
206
+ {...lProps}
207
+ schemaTypes={schemaTypes}
208
+ renderAnnotation={renderAnnotation}
209
+ renderChild={renderChild}
210
+ renderDecorator={renderDecorator}
211
+ readOnly={readOnly}
212
+ />
213
+ )
214
+ if (renderPlaceholder && lProps.leaf.placeholder && lProps.text.text === '') {
215
+ return (
216
+ <>
217
+ <span style={PLACEHOLDER_STYLE} contentEditable={false}>
218
+ {renderPlaceholder()}
219
+ </span>
220
+ {rendered}
221
+ </>
222
+ )
223
+ }
224
+ const decoration = lProps.leaf.rangeDecoration
225
+ if (decoration) {
226
+ rendered = decoration.component({children: rendered})
227
+ }
228
+ return rendered
229
+ }
230
+ return lProps.children
231
+ },
232
+ [readOnly, renderAnnotation, renderChild, renderDecorator, renderPlaceholder, schemaTypes],
233
+ )
234
+
235
+ const restoreSelectionFromProps = useCallback(() => {
236
+ if (propsSelection) {
237
+ debug(`Selection from props ${JSON.stringify(propsSelection)}`)
238
+ const normalizedSelection = normalizeSelection(
239
+ propsSelection,
240
+ fromSlateValue(slateEditor.children, blockTypeName),
241
+ )
242
+ if (normalizedSelection !== null) {
243
+ debug(`Normalized selection from props ${JSON.stringify(normalizedSelection)}`)
244
+ const slateRange = toSlateRange(normalizedSelection, slateEditor)
245
+ if (slateRange) {
246
+ Transforms.select(slateEditor, slateRange)
247
+ // Output selection here in those cases where the editor selection was the same, and there are no set_selection operations made.
248
+ // The selection is usually automatically emitted to change$ by the withPortableTextSelections plugin whenever there is a set_selection operation applied.
249
+ if (!slateEditor.operations.some((o) => o.type === 'set_selection')) {
250
+ change$.next({type: 'selection', selection: normalizedSelection})
251
+ }
252
+ slateEditor.onChange()
253
+ }
254
+ }
255
+ }
256
+ }, [propsSelection, slateEditor, blockTypeName, change$])
257
+
258
+ const syncRangeDecorations = useCallback(
259
+ (operation?: Operation) => {
260
+ if (rangeDecorations && rangeDecorations.length > 0) {
261
+ const newSlateRanges: BaseRangeWithDecoration[] = []
262
+ rangeDecorations.forEach((rangeDecorationItem) => {
263
+ const slateRange = toSlateRange(rangeDecorationItem.selection, slateEditor)
264
+ if (!SlateRange.isRange(slateRange)) {
265
+ if (rangeDecorationItem.onMoved) {
266
+ rangeDecorationItem.onMoved({
267
+ newSelection: null,
268
+ rangeDecoration: rangeDecorationItem,
269
+ origin: 'local',
270
+ })
271
+ }
272
+ return
273
+ }
274
+ let newRange: BaseRange | null | undefined
275
+ if (operation) {
276
+ newRange = moveRangeByOperation(slateRange, operation)
277
+ if ((newRange && newRange !== slateRange) || (newRange === null && slateRange)) {
278
+ const value = PortableTextEditor.getValue(portableTextEditor)
279
+ const newRangeSelection = toPortableTextRange(value, newRange, schemaTypes)
280
+ if (rangeDecorationItem.onMoved) {
281
+ rangeDecorationItem.onMoved({
282
+ newSelection: newRangeSelection,
283
+ rangeDecoration: rangeDecorationItem,
284
+ origin: 'local',
285
+ })
286
+ }
287
+ }
288
+ }
289
+ // If the newRange is null, it means that the range is not valid anymore and should be removed
290
+ // If it's undefined, it means that the slateRange is still valid and should be kept
291
+ if (newRange !== null) {
292
+ newSlateRanges.push({...(newRange || slateRange), rangeDecoration: rangeDecorationItem})
293
+ }
294
+ })
295
+ if (newSlateRanges.length > 0) {
296
+ setRangeDecorationsState(newSlateRanges)
297
+ return
298
+ }
299
+ }
300
+ setRangeDecorationsState(EMPTY_DECORATIONS_STATE)
301
+ },
302
+ [portableTextEditor, rangeDecorations, schemaTypes, slateEditor],
303
+ )
304
+
305
+ // Subscribe to change$ and restore selection from props when the editor has been initialized properly with it's value
306
+ useEffect(() => {
307
+ // debug('Subscribing to editor changes$')
308
+ const sub = change$.subscribe((next: EditorChange): void => {
309
+ switch (next.type) {
310
+ case 'ready':
311
+ restoreSelectionFromProps()
312
+ break
313
+ case 'invalidValue':
314
+ setHasInvalidValue(true)
315
+ break
316
+ case 'value':
317
+ setHasInvalidValue(false)
318
+ break
319
+ default:
320
+ }
321
+ })
322
+ return () => {
323
+ // debug('Unsubscribing to changes$')
324
+ sub.unsubscribe()
325
+ }
326
+ }, [change$, restoreSelectionFromProps])
327
+
328
+ // Restore selection from props when it changes
329
+ useEffect(() => {
330
+ if (propsSelection && !hasInvalidValue) {
331
+ restoreSelectionFromProps()
332
+ }
333
+ }, [hasInvalidValue, propsSelection, restoreSelectionFromProps])
334
+
335
+ // Store reference to original apply function (see below for usage in useEffect)
336
+ const originalApply = useMemo(() => slateEditor.apply, [slateEditor])
337
+
338
+ const [syncedRangeDecorations, setSyncedRangeDecorations] = useState(false)
339
+ useEffect(() => {
340
+ if (!syncedRangeDecorations) {
341
+ // We only want this to run once, on mount
342
+ setSyncedRangeDecorations(true)
343
+ syncRangeDecorations()
344
+ }
345
+ }, [syncRangeDecorations, syncedRangeDecorations])
346
+
347
+ useEffect(() => {
348
+ if (!isEqual(rangeDecorations, rangeDecorationsRef.current)) {
349
+ syncRangeDecorations()
350
+ }
351
+ rangeDecorationsRef.current = rangeDecorations
352
+ }, [rangeDecorations, syncRangeDecorations])
353
+
354
+ // Sync range decorations after an operation is applied
355
+ useEffect(() => {
356
+ slateEditor.apply = (op: Operation) => {
357
+ originalApply(op)
358
+ if (op.type !== 'set_selection') {
359
+ syncRangeDecorations(op)
360
+ }
361
+ }
362
+ return () => {
363
+ slateEditor.apply = originalApply
364
+ }
365
+ }, [originalApply, slateEditor, syncRangeDecorations])
366
+
367
+ // Handle from props onCopy function
368
+ const handleCopy = useCallback(
369
+ (event: ClipboardEvent<HTMLDivElement>): void | ReactEditor => {
370
+ if (onCopy) {
371
+ const result = onCopy(event)
372
+ // CopyFn may return something to avoid doing default stuff
373
+ if (result !== undefined) {
374
+ event.preventDefault()
375
+ }
376
+ }
377
+ },
378
+ [onCopy],
379
+ )
380
+
381
+ // Handle incoming pasting events in the editor
382
+ const handlePaste = useCallback(
383
+ (event: ClipboardEvent<HTMLDivElement>): Promise<void> | void => {
384
+ event.preventDefault()
385
+ if (!slateEditor.selection) {
386
+ return
387
+ }
388
+ if (!onPaste) {
389
+ debug('Pasting normally')
390
+ slateEditor.insertData(event.clipboardData)
391
+ return
392
+ }
393
+ // Resolve it as promise (can be either async promise or sync return value)
394
+ new Promise<OnPasteResult>((resolve) => {
395
+ const value = PortableTextEditor.getValue(portableTextEditor)
396
+ const ptRange = toPortableTextRange(value, slateEditor.selection, schemaTypes)
397
+ const path = ptRange?.focus.path || []
398
+ resolve(
399
+ onPaste({
400
+ event,
401
+ value,
402
+ path,
403
+ schemaTypes,
404
+ }),
405
+ )
406
+ })
407
+ .then((result) => {
408
+ debug('Custom paste function from client resolved', result)
409
+ change$.next({type: 'loading', isLoading: true})
410
+ if (!result || !result.insert) {
411
+ debug('No result from custom paste handler, pasting normally')
412
+ slateEditor.insertData(event.clipboardData)
413
+ return
414
+ }
415
+ if (result && result.insert) {
416
+ slateEditor.insertFragment(
417
+ toSlateValue(result.insert as PortableTextBlock[], {schemaTypes}),
418
+ )
419
+ change$.next({type: 'loading', isLoading: false})
420
+ return
421
+ }
422
+ console.warn('Your onPaste function returned something unexpected:', result)
423
+ })
424
+ .catch((error) => {
425
+ change$.next({type: 'loading', isLoading: false})
426
+ console.error(error) // eslint-disable-line no-console
427
+ return error
428
+ })
429
+ },
430
+ [change$, onPaste, portableTextEditor, schemaTypes, slateEditor],
431
+ )
432
+
433
+ const handleOnFocus: FocusEventHandler<HTMLDivElement> = useCallback(
434
+ (event) => {
435
+ if (onFocus) {
436
+ onFocus(event)
437
+ }
438
+ if (!event.isDefaultPrevented()) {
439
+ const selection = PortableTextEditor.getSelection(portableTextEditor)
440
+ // Create an editor selection if it does'nt exist
441
+ if (selection === null) {
442
+ Transforms.select(slateEditor, Editor.start(slateEditor, []))
443
+ slateEditor.onChange()
444
+ }
445
+ change$.next({type: 'focus', event})
446
+ const newSelection = PortableTextEditor.getSelection(portableTextEditor)
447
+ // If the selection is the same, emit it explicitly here as there is no actual onChange event triggered.
448
+ if (selection === newSelection) {
449
+ change$.next({
450
+ type: 'selection',
451
+ selection,
452
+ })
453
+ }
454
+ }
455
+ },
456
+ [onFocus, portableTextEditor, change$, slateEditor],
457
+ )
458
+
459
+ const handleClick = useCallback(
460
+ (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
461
+ if (onClick) {
462
+ onClick(event)
463
+ }
464
+ // Inserts a new block if it's clicking on the editor, focused on the last block and it's a void element
465
+ if (slateEditor.selection && event.target === event.currentTarget) {
466
+ const [lastBlock, path] = Node.last(slateEditor, [])
467
+ const focusPath = slateEditor.selection.focus.path.slice(0, 1)
468
+ const lastPath = path.slice(0, 1)
469
+ if (Path.equals(focusPath, lastPath)) {
470
+ const node = Node.descendant(slateEditor, path.slice(0, 1)) as
471
+ | SlateTextBlock
472
+ | VoidElement
473
+ if (lastBlock && Editor.isVoid(slateEditor, node)) {
474
+ Transforms.insertNodes(slateEditor, slateEditor.pteCreateEmptyBlock())
475
+ slateEditor.onChange()
476
+ }
477
+ }
478
+ }
479
+ },
480
+ [onClick, slateEditor],
481
+ )
482
+
483
+ const handleOnBlur: FocusEventHandler<HTMLDivElement> = useCallback(
484
+ (event) => {
485
+ if (onBlur) {
486
+ onBlur(event)
487
+ }
488
+ if (!event.isPropagationStopped()) {
489
+ change$.next({type: 'blur', event})
490
+ }
491
+ },
492
+ [change$, onBlur],
493
+ )
494
+
495
+ const handleOnBeforeInput = useCallback(
496
+ (event: InputEvent) => {
497
+ if (onBeforeInput) {
498
+ onBeforeInput(event)
499
+ }
500
+ },
501
+ [onBeforeInput],
502
+ )
503
+
504
+ // This function will handle unexpected DOM changes inside the Editable rendering,
505
+ // and make sure that we can maintain a stable slateEditor.selection when that happens.
506
+ //
507
+ // For example, if this Editable is rendered inside something that might re-render
508
+ // this component (hidden contexts) while the user is still actively changing the
509
+ // contentEditable, this could interfere with the intermediate DOM selection,
510
+ // which again could be picked up by ReactEditor's event listeners.
511
+ // If that range is invalid at that point, the slate.editorSelection could be
512
+ // set either wrong, or invalid, to which slateEditor will throw exceptions
513
+ // that are impossible to recover properly from or result in a wrong selection.
514
+ //
515
+ // Also the other way around, when the ReactEditor will try to create a DOM Range
516
+ // from the current slateEditor.selection, it may throw unrecoverable errors
517
+ // if the current editor.selection is invalid according to the DOM.
518
+ // If this is the case, default to selecting the top of the document, if the
519
+ // user already had a selection.
520
+ const validateSelection = useCallback(() => {
521
+ if (!slateEditor.selection) {
522
+ return
523
+ }
524
+ const root = ReactEditor.findDocumentOrShadowRoot(slateEditor)
525
+ const {activeElement} = root
526
+ // Return if the editor isn't the active element
527
+ if (ref.current !== activeElement) {
528
+ return
529
+ }
530
+ const window = ReactEditor.getWindow(slateEditor)
531
+ const domSelection = window.getSelection()
532
+ if (!domSelection || domSelection.rangeCount === 0) {
533
+ return
534
+ }
535
+ const existingDOMRange = domSelection.getRangeAt(0)
536
+ try {
537
+ const newDOMRange = ReactEditor.toDOMRange(slateEditor, slateEditor.selection)
538
+ if (
539
+ newDOMRange.startOffset !== existingDOMRange.startOffset ||
540
+ newDOMRange.endOffset !== existingDOMRange.endOffset
541
+ ) {
542
+ debug('DOM range out of sync, validating selection')
543
+ // Remove all ranges temporary
544
+ domSelection?.removeAllRanges()
545
+ // Set the correct range
546
+ domSelection.addRange(newDOMRange)
547
+ }
548
+ } catch (error) {
549
+ debug(`Could not resolve selection, selecting top document`)
550
+ // Deselect the editor
551
+ Transforms.deselect(slateEditor)
552
+ // Select top document if there is a top block to select
553
+ if (slateEditor.children.length > 0) {
554
+ Transforms.select(slateEditor, [0, 0])
555
+ }
556
+ slateEditor.onChange()
557
+ }
558
+ }, [ref, slateEditor])
559
+
560
+ // Observe mutations (child list and subtree) to this component's DOM,
561
+ // and make sure the editor selection is valid when that happens.
562
+ useEffect(() => {
563
+ if (editableElement) {
564
+ const mutationObserver = new MutationObserver(validateSelection)
565
+ mutationObserver.observe(editableElement, {
566
+ attributeOldValue: false,
567
+ attributes: false,
568
+ characterData: false,
569
+ childList: true,
570
+ subtree: true,
571
+ })
572
+ return () => {
573
+ mutationObserver.disconnect()
574
+ }
575
+ }
576
+ return undefined
577
+ }, [validateSelection, editableElement])
578
+
579
+ const handleKeyDown = useCallback(
580
+ (event: KeyboardEvent<HTMLDivElement>) => {
581
+ if (props.onKeyDown) {
582
+ props.onKeyDown(event)
583
+ }
584
+ if (!event.isDefaultPrevented()) {
585
+ slateEditor.pteWithHotKeys(event)
586
+ }
587
+ },
588
+ [props, slateEditor],
589
+ )
590
+
591
+ const scrollSelectionIntoViewToSlate = useMemo(() => {
592
+ // Use slate-react default scroll into view
593
+ if (scrollSelectionIntoView === undefined) {
594
+ return undefined
595
+ }
596
+ // Disable scroll into view totally
597
+ if (scrollSelectionIntoView === null) {
598
+ return noop
599
+ }
600
+ // Translate PortableTextEditor prop fn to Slate plugin fn
601
+ return (editor: ReactEditor, domRange: Range) => {
602
+ scrollSelectionIntoView(portableTextEditor, domRange)
603
+ }
604
+ }, [portableTextEditor, scrollSelectionIntoView])
605
+
606
+ const decorate: (entry: NodeEntry) => BaseRange[] = useCallback(
607
+ ([, path]) => {
608
+ if (isEqualToEmptyEditor(slateEditor.children, schemaTypes)) {
609
+ return [
610
+ {
611
+ anchor: {
612
+ path: [0, 0],
613
+ offset: 0,
614
+ },
615
+ focus: {
616
+ path: [0, 0],
617
+ offset: 0,
618
+ },
619
+ placeholder: true,
620
+ },
621
+ ]
622
+ }
623
+ // Editor node has a path length of 0 (should never be decorated)
624
+ if (path.length === 0) {
625
+ return EMPTY_DECORATIONS_STATE
626
+ }
627
+ const result = rangeDecorationState.filter((item) => {
628
+ // Special case in order to only return one decoration for collapsed ranges
629
+ if (SlateRange.isCollapsed(item)) {
630
+ // Collapsed ranges should only be decorated if they are on a block child level (length 2)
631
+ if (path.length !== 2) {
632
+ return false
633
+ }
634
+ return Path.equals(item.focus.path, path) && Path.equals(item.anchor.path, path)
635
+ }
636
+ // Include decorations that either include or intersects with this path
637
+ return (
638
+ SlateRange.intersection(item, {anchor: {path, offset: 0}, focus: {path, offset: 0}}) ||
639
+ SlateRange.includes(item, path)
640
+ )
641
+ })
642
+ if (result.length > 0) {
643
+ return result
644
+ }
645
+ return EMPTY_DECORATIONS_STATE
646
+ },
647
+ [slateEditor, schemaTypes, rangeDecorationState],
648
+ )
649
+
650
+ // Set the forwarded ref to be the Slate editable DOM element
651
+ // Also set the editable element in a state so that the MutationObserver
652
+ // is setup when this element is ready.
653
+ useEffect(() => {
654
+ ref.current = ReactEditor.toDOMNode(slateEditor, slateEditor) as HTMLDivElement | null
655
+ setEditableElement(ref.current)
656
+ }, [slateEditor, ref])
657
+
658
+ if (!portableTextEditor) {
659
+ return null
660
+ }
661
+ return hasInvalidValue ? null : (
662
+ <SlateEditable
663
+ {...restProps}
664
+ autoFocus={false}
665
+ className={restProps.className || 'pt-editable'}
666
+ decorate={decorate}
667
+ onBlur={handleOnBlur}
668
+ onCopy={handleCopy}
669
+ onClick={handleClick}
670
+ onDOMBeforeInput={handleOnBeforeInput}
671
+ onFocus={handleOnFocus}
672
+ onKeyDown={handleKeyDown}
673
+ onPaste={handlePaste}
674
+ readOnly={readOnly}
675
+ // We have implemented our own placeholder logic with decorations.
676
+ // This 'renderPlaceholder' should not be used.
677
+ renderPlaceholder={undefined}
678
+ renderElement={renderElement}
679
+ renderLeaf={renderLeaf}
680
+ scrollSelectionIntoView={scrollSelectionIntoViewToSlate}
681
+ />
682
+ )
683
+ })