@portabletext/editor 2.4.3 → 2.6.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.
@@ -1,5 +1,5 @@
1
1
  import { Behavior, Editor, EditorEmittedEvent, EditorSchema } from "../_chunks-dts/behavior.types.action.cjs";
2
- import * as react12 from "react";
2
+ import * as react22 from "react";
3
3
  import React from "react";
4
4
  /**
5
5
  * @beta
@@ -181,7 +181,7 @@ type MarkdownPluginConfig = MarkdownBehaviorsConfig & {
181
181
  */
182
182
  declare function MarkdownPlugin(props: {
183
183
  config: MarkdownPluginConfig;
184
- }): react12.JSX.Element;
184
+ }): react22.JSX.Element;
185
185
  /**
186
186
  * @beta
187
187
  * Restrict the editor to one line. The plugin takes care of blocking
@@ -192,5 +192,5 @@ declare function MarkdownPlugin(props: {
192
192
  *
193
193
  * @deprecated Install the plugin from `@portabletext/plugin-one-line`
194
194
  */
195
- declare function OneLinePlugin(): react12.JSX.Element;
195
+ declare function OneLinePlugin(): react22.JSX.Element;
196
196
  export { BehaviorPlugin, DecoratorShortcutPlugin, EditorRefPlugin, EventListenerPlugin, MarkdownPlugin, type MarkdownPluginConfig, OneLinePlugin };
@@ -1,5 +1,5 @@
1
1
  import { BlockOffset, BlockPath, ChildPath, EditorContext, EditorSelection, EditorSelectionPoint } from "../_chunks-dts/behavior.types.action.js";
2
- import * as _sanity_types9 from "@sanity/types";
2
+ import * as _sanity_types8 from "@sanity/types";
3
3
  import { KeyedSegment, PortableTextBlock, PortableTextChild, PortableTextSpan, PortableTextTextBlock } from "@sanity/types";
4
4
  /**
5
5
  * @public
@@ -150,7 +150,7 @@ declare function mergeTextBlocks({
150
150
  context: Pick<EditorContext, 'keyGenerator' | 'schema'>;
151
151
  targetBlock: PortableTextTextBlock;
152
152
  incomingBlock: PortableTextTextBlock;
153
- }): PortableTextTextBlock<_sanity_types9.PortableTextObject | _sanity_types9.PortableTextSpan>;
153
+ }): PortableTextTextBlock<_sanity_types8.PortableTextObject | _sanity_types8.PortableTextSpan>;
154
154
  /**
155
155
  * @public
156
156
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@portabletext/editor",
3
- "version": "2.4.3",
3
+ "version": "2.6.0",
4
4
  "description": "Portable Text Editor made in React",
5
5
  "keywords": [
6
6
  "sanity",
@@ -67,7 +67,7 @@
67
67
  "src"
68
68
  ],
69
69
  "dependencies": {
70
- "@portabletext/to-html": "^2.0.16",
70
+ "@portabletext/to-html": "^3.0.0",
71
71
  "@xstate/react": "^6.0.0",
72
72
  "debug": "^4.4.1",
73
73
  "get-random-values-esm": "^1.0.2",
@@ -79,10 +79,10 @@
79
79
  "slate-dom": "^0.117.4",
80
80
  "slate-react": "0.117.4",
81
81
  "xstate": "^5.20.2",
82
- "@portabletext/block-tools": "^3.3.1",
82
+ "@portabletext/block-tools": "^3.3.2",
83
83
  "@portabletext/keyboard-shortcuts": "^1.1.1",
84
- "@portabletext/patches": "^1.1.7",
85
- "@portabletext/schema": "^1.0.1"
84
+ "@portabletext/patches": "^1.1.8",
85
+ "@portabletext/schema": "^1.1.0"
86
86
  },
87
87
  "devDependencies": {
88
88
  "@sanity/diff-match-patch": "^3.2.0",
@@ -111,15 +111,15 @@
111
111
  "vite": "^7.1.3",
112
112
  "vitest": "^3.2.4",
113
113
  "vitest-browser-react": "^1.0.1",
114
- "@portabletext/sanity-bridge": "1.1.3",
114
+ "@portabletext/sanity-bridge": "1.1.4",
115
115
  "racejar": "1.2.14"
116
116
  },
117
117
  "peerDependencies": {
118
+ "@portabletext/sanity-bridge": "^1.1.4",
118
119
  "@sanity/schema": "^4.5.0",
119
120
  "@sanity/types": "^4.5.0",
120
121
  "react": "^18.3 || ^19",
121
- "rxjs": "^7.8.2",
122
- "@portabletext/sanity-bridge": "^1.1.3"
122
+ "rxjs": "^7.8.2"
123
123
  },
124
124
  "engines": {
125
125
  "node": ">=20.19 <22 || >=22.12"
@@ -5,14 +5,11 @@ import {
5
5
  useCallback,
6
6
  useContext,
7
7
  useEffect,
8
- useImperativeHandle,
9
8
  useMemo,
10
- useRef,
11
9
  useState,
12
10
  type ClipboardEvent,
13
11
  type FocusEventHandler,
14
12
  type KeyboardEvent,
15
- type MutableRefObject,
16
13
  type TextareaHTMLAttributes,
17
14
  } from 'react'
18
15
  import {Editor, Transforms, type Text} from 'slate'
@@ -68,7 +65,6 @@ export type PortableTextEditableProps = Omit<
68
65
  onBeforeInput?: (event: InputEvent) => void
69
66
  onPaste?: OnPasteFn
70
67
  onCopy?: OnCopyFn
71
- ref: MutableRefObject<HTMLDivElement | null>
72
68
  rangeDecorations?: RangeDecoration[]
73
69
  renderAnnotation?: RenderAnnotationFunction
74
70
  renderBlock?: RenderBlockFunction
@@ -137,18 +133,8 @@ export const PortableTextEditable = forwardRef<
137
133
  } = props
138
134
 
139
135
  const portableTextEditor = usePortableTextEditor()
140
- const ref = useRef<HTMLDivElement | null>(null)
141
- const [editableElement, setEditableElement] = useState<HTMLDivElement | null>(
142
- null,
143
- )
144
136
  const [hasInvalidValue, setHasInvalidValue] = useState(false)
145
137
 
146
- // Forward ref to parent component
147
- useImperativeHandle<HTMLDivElement | null, HTMLDivElement | null>(
148
- forwardedRef,
149
- () => ref.current,
150
- )
151
-
152
138
  const editorActor = useContext(EditorActorContext)
153
139
  const relayActor = useContext(RelayActorContext)
154
140
  const readOnly = useSelector(editorActor, (s) =>
@@ -605,84 +591,6 @@ export const PortableTextEditable = forwardRef<
605
591
  [onBeforeInput],
606
592
  )
607
593
 
608
- // This function will handle unexpected DOM changes inside the Editable rendering,
609
- // and make sure that we can maintain a stable slateEditor.selection when that happens.
610
- //
611
- // For example, if this Editable is rendered inside something that might re-render
612
- // this component (hidden contexts) while the user is still actively changing the
613
- // contentEditable, this could interfere with the intermediate DOM selection,
614
- // which again could be picked up by ReactEditor's event listeners.
615
- // If that range is invalid at that point, the slate.editorSelection could be
616
- // set either wrong, or invalid, to which slateEditor will throw exceptions
617
- // that are impossible to recover properly from or result in a wrong selection.
618
- //
619
- // Also the other way around, when the ReactEditor will try to create a DOM Range
620
- // from the current slateEditor.selection, it may throw unrecoverable errors
621
- // if the current editor.selection is invalid according to the DOM.
622
- // If this is the case, default to selecting the top of the document, if the
623
- // user already had a selection.
624
- const validateSelection = useCallback(() => {
625
- if (!slateEditor.selection) {
626
- return
627
- }
628
- const root = ReactEditor.findDocumentOrShadowRoot(slateEditor)
629
- const {activeElement} = root
630
- // Return if the editor isn't the active element
631
- if (ref.current !== activeElement) {
632
- return
633
- }
634
- const window = ReactEditor.getWindow(slateEditor)
635
- const domSelection = window.getSelection()
636
- if (!domSelection || domSelection.rangeCount === 0) {
637
- return
638
- }
639
- const existingDOMRange = domSelection.getRangeAt(0)
640
- try {
641
- const newDOMRange = ReactEditor.toDOMRange(
642
- slateEditor,
643
- slateEditor.selection,
644
- )
645
- if (
646
- newDOMRange.startOffset !== existingDOMRange.startOffset ||
647
- newDOMRange.endOffset !== existingDOMRange.endOffset
648
- ) {
649
- debug('DOM range out of sync, validating selection')
650
- // Remove all ranges temporary
651
- domSelection?.removeAllRanges()
652
- // Set the correct range
653
- domSelection.addRange(newDOMRange)
654
- }
655
- } catch {
656
- debug(`Could not resolve selection, selecting top document`)
657
- // Deselect the editor
658
- Transforms.deselect(slateEditor)
659
- // Select top document if there is a top block to select
660
- if (slateEditor.children.length > 0) {
661
- Transforms.select(slateEditor, [0, 0])
662
- }
663
- slateEditor.onChange()
664
- }
665
- }, [ref, slateEditor])
666
-
667
- // Observe mutations (child list and subtree) to this component's DOM,
668
- // and make sure the editor selection is valid when that happens.
669
- useEffect(() => {
670
- if (editableElement) {
671
- const mutationObserver = new MutationObserver(validateSelection)
672
- mutationObserver.observe(editableElement, {
673
- attributeOldValue: false,
674
- attributes: false,
675
- characterData: false,
676
- childList: true,
677
- subtree: true,
678
- })
679
- return () => {
680
- mutationObserver.disconnect()
681
- }
682
- }
683
- return undefined
684
- }, [validateSelection, editableElement])
685
-
686
594
  const handleKeyDown = useCallback(
687
595
  (event: KeyboardEvent<HTMLDivElement>) => {
688
596
  if (props.onKeyDown) {
@@ -755,17 +663,6 @@ export const PortableTextEditable = forwardRef<
755
663
  }
756
664
  }, [portableTextEditor, scrollSelectionIntoView])
757
665
 
758
- // Set the forwarded ref to be the Slate editable DOM element
759
- // Also set the editable element in a state so that the MutationObserver
760
- // is setup when this element is ready.
761
- useEffect(() => {
762
- ref.current = ReactEditor.toDOMNode(
763
- slateEditor,
764
- slateEditor,
765
- ) as HTMLDivElement | null
766
- setEditableElement(ref.current)
767
- }, [slateEditor, ref])
768
-
769
666
  useEffect(() => {
770
667
  const window = ReactEditor.getWindow(slateEditor)
771
668
 
@@ -1037,6 +934,37 @@ export const PortableTextEditable = forwardRef<
1037
934
  [onDragLeave, editorActor, slateEditor],
1038
935
  )
1039
936
 
937
+ const callbackRef = useCallback(
938
+ (node: HTMLDivElement | null) => {
939
+ if (typeof forwardedRef === 'function') {
940
+ forwardedRef(node)
941
+ } else if (forwardedRef) {
942
+ forwardedRef.current = node
943
+ }
944
+
945
+ if (node) {
946
+ // Observe mutations (child list and subtree) to this component's DOM,
947
+ // and make sure the editor selection is valid when that happens.
948
+ const mutationObserver = new MutationObserver(() => {
949
+ validateSelection(slateEditor, node)
950
+ })
951
+
952
+ mutationObserver.observe(node, {
953
+ attributeOldValue: false,
954
+ attributes: false,
955
+ characterData: false,
956
+ childList: true,
957
+ subtree: true,
958
+ })
959
+
960
+ return () => {
961
+ mutationObserver.disconnect()
962
+ }
963
+ }
964
+ },
965
+ [forwardedRef, slateEditor],
966
+ )
967
+
1040
968
  if (!portableTextEditor) {
1041
969
  return null
1042
970
  }
@@ -1044,6 +972,7 @@ export const PortableTextEditable = forwardRef<
1044
972
  return hasInvalidValue ? null : (
1045
973
  <SlateEditable
1046
974
  {...restProps}
975
+ ref={callbackRef}
1047
976
  data-read-only={readOnly}
1048
977
  autoFocus={false}
1049
978
  className={restProps.className || 'pt-editable'}
@@ -1077,3 +1006,61 @@ export const PortableTextEditable = forwardRef<
1077
1006
  })
1078
1007
 
1079
1008
  PortableTextEditable.displayName = 'ForwardRef(PortableTextEditable)'
1009
+
1010
+ // This function will handle unexpected DOM changes inside the Editable rendering,
1011
+ // and make sure that we can maintain a stable slateEditor.selection when that happens.
1012
+ //
1013
+ // For example, if this Editable is rendered inside something that might re-render
1014
+ // this component (hidden contexts) while the user is still actively changing the
1015
+ // contentEditable, this could interfere with the intermediate DOM selection,
1016
+ // which again could be picked up by ReactEditor's event listeners.
1017
+ // If that range is invalid at that point, the slate.editorSelection could be
1018
+ // set either wrong, or invalid, to which slateEditor will throw exceptions
1019
+ // that are impossible to recover properly from or result in a wrong selection.
1020
+ //
1021
+ // Also the other way around, when the ReactEditor will try to create a DOM Range
1022
+ // from the current slateEditor.selection, it may throw unrecoverable errors
1023
+ // if the current editor.selection is invalid according to the DOM.
1024
+ // If this is the case, default to selecting the top of the document, if the
1025
+ // user already had a selection.
1026
+ function validateSelection(slateEditor: Editor, activeElement: HTMLDivElement) {
1027
+ if (!slateEditor.selection) {
1028
+ return
1029
+ }
1030
+ const root = ReactEditor.findDocumentOrShadowRoot(slateEditor)
1031
+ // Return if the editor isn't the active element
1032
+ if (activeElement !== root.activeElement) {
1033
+ return
1034
+ }
1035
+ const window = ReactEditor.getWindow(slateEditor)
1036
+ const domSelection = window.getSelection()
1037
+ if (!domSelection || domSelection.rangeCount === 0) {
1038
+ return
1039
+ }
1040
+ const existingDOMRange = domSelection.getRangeAt(0)
1041
+ try {
1042
+ const newDOMRange = ReactEditor.toDOMRange(
1043
+ slateEditor,
1044
+ slateEditor.selection,
1045
+ )
1046
+ if (
1047
+ newDOMRange.startOffset !== existingDOMRange.startOffset ||
1048
+ newDOMRange.endOffset !== existingDOMRange.endOffset
1049
+ ) {
1050
+ debug('DOM range out of sync, validating selection')
1051
+ // Remove all ranges temporary
1052
+ domSelection?.removeAllRanges()
1053
+ // Set the correct range
1054
+ domSelection.addRange(newDOMRange)
1055
+ }
1056
+ } catch {
1057
+ debug(`Could not resolve selection, selecting top document`)
1058
+ // Deselect the editor
1059
+ Transforms.deselect(slateEditor)
1060
+ // Select top document if there is a top block to select
1061
+ if (slateEditor.children.length > 0) {
1062
+ Transforms.select(slateEditor, [0, 0])
1063
+ }
1064
+ slateEditor.onChange()
1065
+ }
1066
+ }
@@ -280,6 +280,95 @@ describe(parseBlock.name, () => {
280
280
  style: 'normal',
281
281
  })
282
282
  })
283
+
284
+ describe('validating custom fields', () => {
285
+ test('none defined', () => {
286
+ expect(
287
+ parseBlock({
288
+ block: {_type: 'block', map: {}},
289
+ context: {
290
+ keyGenerator: createTestKeyGenerator(),
291
+ schema: compileSchema(defineSchema({})),
292
+ },
293
+ options: {refreshKeys: false, validateFields: true},
294
+ }),
295
+ ).toEqual({
296
+ _type: 'block',
297
+ _key: 'k0',
298
+ children: [
299
+ {
300
+ _key: 'k1',
301
+ _type: 'span',
302
+ text: '',
303
+ marks: [],
304
+ },
305
+ ],
306
+ markDefs: [],
307
+ style: 'normal',
308
+ })
309
+ })
310
+
311
+ test('field defined', () => {
312
+ expect(
313
+ parseBlock({
314
+ block: {_type: 'block', map: {}},
315
+ context: {
316
+ keyGenerator: createTestKeyGenerator(),
317
+ schema: compileSchema(
318
+ defineSchema({
319
+ block: {fields: [{name: 'map', type: 'object'}]},
320
+ }),
321
+ ),
322
+ },
323
+ options: {refreshKeys: false, validateFields: true},
324
+ }),
325
+ ).toEqual({
326
+ _type: 'block',
327
+ _key: 'k0',
328
+ map: {},
329
+ children: [
330
+ {
331
+ _key: 'k1',
332
+ _type: 'span',
333
+ text: '',
334
+ marks: [],
335
+ },
336
+ ],
337
+ markDefs: [],
338
+ style: 'normal',
339
+ })
340
+ })
341
+
342
+ test('different field defined', () => {
343
+ expect(
344
+ parseBlock({
345
+ block: {_type: 'block', foo: {}},
346
+ context: {
347
+ keyGenerator: createTestKeyGenerator(),
348
+ schema: compileSchema(
349
+ defineSchema({
350
+ block: {fields: [{name: 'map', type: 'object'}]},
351
+ }),
352
+ ),
353
+ },
354
+ options: {refreshKeys: false, validateFields: true},
355
+ }),
356
+ ).toEqual({
357
+ _type: 'block',
358
+ _key: 'k0',
359
+ children: [
360
+ {
361
+ _key: 'k1',
362
+ _type: 'span',
363
+ text: '',
364
+ marks: [],
365
+ },
366
+ ],
367
+ markDefs: [],
368
+ style: 'normal',
369
+ })
370
+ })
371
+ })
283
372
  })
284
373
  })
285
374
 
@@ -129,14 +129,22 @@ export function parseTextBlock({
129
129
 
130
130
  for (const key of Object.keys(block)) {
131
131
  if (
132
- key !== '_type' &&
133
- key !== '_key' &&
134
- key !== 'children' &&
135
- key !== 'markDefs' &&
136
- key !== 'style' &&
137
- key !== 'listItem' &&
138
- key !== 'level'
132
+ key === '_type' ||
133
+ key === '_key' ||
134
+ key === 'children' ||
135
+ key === 'markDefs' ||
136
+ key === 'style' ||
137
+ key === 'listItem' ||
138
+ key === 'level'
139
139
  ) {
140
+ continue
141
+ }
142
+
143
+ if (options.validateFields) {
144
+ if (context.schema.block.fields?.some((field) => field.name === key)) {
145
+ customFields[key] = block[key]
146
+ }
147
+ } else {
140
148
  customFields[key] = block[key]
141
149
  }
142
150
  }
@@ -219,7 +227,7 @@ export function parseTextBlock({
219
227
  },
220
228
  ],
221
229
  markDefs,
222
- ...(options.validateFields ? {} : customFields),
230
+ ...customFields,
223
231
  }
224
232
 
225
233
  if (