@portabletext/editor 2.4.2 → 2.5.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.js";
2
- import * as react11 from "react";
2
+ import * as react21 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
- }): react11.JSX.Element;
184
+ }): react21.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(): react11.JSX.Element;
195
+ declare function OneLinePlugin(): react21.JSX.Element;
196
196
  export { BehaviorPlugin, DecoratorShortcutPlugin, EditorRefPlugin, EventListenerPlugin, MarkdownPlugin, type MarkdownPluginConfig, OneLinePlugin };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@portabletext/editor",
3
- "version": "2.4.2",
3
+ "version": "2.5.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.15",
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",
@@ -80,9 +80,9 @@
80
80
  "slate-react": "0.117.4",
81
81
  "xstate": "^5.20.2",
82
82
  "@portabletext/block-tools": "^3.3.1",
83
- "@portabletext/patches": "^1.1.7",
84
- "@portabletext/schema": "^1.0.1",
85
- "@portabletext/keyboard-shortcuts": "^1.1.1"
83
+ "@portabletext/patches": "^1.1.8",
84
+ "@portabletext/keyboard-shortcuts": "^1.1.1",
85
+ "@portabletext/schema": "^1.0.1"
86
86
  },
87
87
  "devDependencies": {
88
88
  "@sanity/diff-match-patch": "^3.2.0",
@@ -93,7 +93,7 @@
93
93
  "@types/debug": "^4.1.12",
94
94
  "@types/lodash": "^4.17.20",
95
95
  "@types/lodash.startcase": "^4.4.9",
96
- "@types/react": "^19.1.9",
96
+ "@types/react": "^19.1.10",
97
97
  "@types/react-dom": "^19.1.7",
98
98
  "@typescript-eslint/eslint-plugin": "^8.39.1",
99
99
  "@typescript-eslint/parser": "^8.39.1",
@@ -108,18 +108,18 @@
108
108
  "react-dom": "^19.1.1",
109
109
  "rxjs": "^7.8.2",
110
110
  "typescript": "5.9.2",
111
- "vite": "^7.0.6",
111
+ "vite": "^7.1.3",
112
112
  "vitest": "^3.2.4",
113
113
  "vitest-browser-react": "^1.0.1",
114
114
  "@portabletext/sanity-bridge": "1.1.3",
115
115
  "racejar": "1.2.14"
116
116
  },
117
117
  "peerDependencies": {
118
+ "@portabletext/sanity-bridge": "^1.0.0",
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
+ }
@@ -1,4 +1,5 @@
1
1
  import {describe, expect, test} from 'vitest'
2
+ import {defaultKeyGenerator} from '../editor/key-generator'
2
3
  import {createTestSnapshot} from '../internal-utils/create-test-snapshot'
3
4
  import type {EditorSelection} from '../types/editor'
4
5
  import {isOverlappingSelection} from './selector.is-overlapping-selection'
@@ -168,4 +169,20 @@ describe(isOverlappingSelection.name, () => {
168
169
  ),
169
170
  ).toBe(false)
170
171
  })
172
+
173
+ test('unknown block', () => {
174
+ const unknownBlockKey = defaultKeyGenerator()
175
+
176
+ expect(
177
+ isOverlappingSelection({
178
+ anchor: {path: [{_key: unknownBlockKey}], offset: 0},
179
+ focus: {path: [{_key: unknownBlockKey}], offset: 0},
180
+ })(
181
+ snapshot({
182
+ anchor: {path: [{_key: 'k0'}], offset: 0},
183
+ focus: {path: [{_key: 'k0'}], offset: 0},
184
+ }),
185
+ ),
186
+ ).toBe(false)
187
+ })
171
188
  })
@@ -112,6 +112,19 @@ export function isOverlappingSelection(
112
112
  originalSelectionEndPoint,
113
113
  )
114
114
 
115
+ // If all checks fail then we can deduce that the selection does not exist
116
+ // and there doesn't overlap with the snapshot selection
117
+ if (
118
+ !endPointEqualToOriginalStartPoint &&
119
+ !startPointEqualToOriginalEndPoint &&
120
+ !originalStartPointBeforeStartPoint &&
121
+ !originalStartPointAfterStartPoint &&
122
+ !originalEndPointBeforeEndPoint &&
123
+ !originalEndPointAfterEndPoint
124
+ ) {
125
+ return false
126
+ }
127
+
115
128
  if (endPointBeforeSelection && !endPointEqualToOriginalStartPoint) {
116
129
  return false
117
130
  }