@portabletext/editor 2.13.1 → 2.13.3

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.
@@ -127,6 +127,9 @@ declare function getListIndex({
127
127
  }: {
128
128
  path: BlockPath;
129
129
  }): EditorSelector<number | undefined>;
130
+ /**
131
+ * @beta
132
+ */
130
133
  type MarkState = {
131
134
  state: 'unchanged';
132
135
  marks: Array<string>;
@@ -319,4 +322,4 @@ declare const isSelectionCollapsed: EditorSelector<boolean>;
319
322
  * @public
320
323
  */
321
324
  declare const isSelectionExpanded: EditorSelector<boolean>;
322
- export { getActiveAnnotations, getActiveListItem, getActiveStyle, getAnchorBlock, getAnchorChild, getAnchorSpan, getAnchorTextBlock, getBlockOffsets, getBlockTextBefore, getCaretWordSelection, getFirstBlock, getFocusBlock, getFocusBlockObject, getFocusChild, getFocusInlineObject, getFocusListBlock, getFocusSpan, getFocusTextBlock, getLastBlock, getListIndex, getMarkState, getNextBlock, getNextInlineObject, getPreviousBlock, getPreviousInlineObject, getSelectedBlocks, getSelectedSlice, getSelectedSpans, getSelectedTextBlocks, getSelectedValue, getSelection, getSelectionEndBlock, getSelectionEndChild, getSelectionEndPoint, getSelectionStartBlock, getSelectionStartChild, getSelectionStartPoint, getSelectionText, getTrimmedSelection, getValue, isActiveAnnotation, isActiveDecorator, isActiveListItem, isActiveStyle, isAtTheEndOfBlock, isAtTheStartOfBlock, isOverlappingSelection, isPointAfterSelection, isPointBeforeSelection, isSelectingEntireBlocks, isSelectionCollapsed, isSelectionExpanded };
325
+ export { MarkState, getActiveAnnotations, getActiveListItem, getActiveStyle, getAnchorBlock, getAnchorChild, getAnchorSpan, getAnchorTextBlock, getBlockOffsets, getBlockTextBefore, getCaretWordSelection, getFirstBlock, getFocusBlock, getFocusBlockObject, getFocusChild, getFocusInlineObject, getFocusListBlock, getFocusSpan, getFocusTextBlock, getLastBlock, getListIndex, getMarkState, getNextBlock, getNextInlineObject, getPreviousBlock, getPreviousInlineObject, getSelectedBlocks, getSelectedSlice, getSelectedSpans, getSelectedTextBlocks, getSelectedValue, getSelection, getSelectionEndBlock, getSelectionEndChild, getSelectionEndPoint, getSelectionStartBlock, getSelectionStartChild, getSelectionStartPoint, getSelectionText, getTrimmedSelection, getValue, isActiveAnnotation, isActiveDecorator, isActiveListItem, isActiveStyle, isAtTheEndOfBlock, isAtTheStartOfBlock, isOverlappingSelection, isPointAfterSelection, isPointBeforeSelection, isSelectingEntireBlocks, isSelectionCollapsed, isSelectionExpanded };
@@ -127,6 +127,9 @@ declare function getListIndex({
127
127
  }: {
128
128
  path: BlockPath;
129
129
  }): EditorSelector<number | undefined>;
130
+ /**
131
+ * @beta
132
+ */
130
133
  type MarkState = {
131
134
  state: 'unchanged';
132
135
  marks: Array<string>;
@@ -319,4 +322,4 @@ declare const isSelectionCollapsed: EditorSelector<boolean>;
319
322
  * @public
320
323
  */
321
324
  declare const isSelectionExpanded: EditorSelector<boolean>;
322
- export { getActiveAnnotations, getActiveListItem, getActiveStyle, getAnchorBlock, getAnchorChild, getAnchorSpan, getAnchorTextBlock, getBlockOffsets, getBlockTextBefore, getCaretWordSelection, getFirstBlock, getFocusBlock, getFocusBlockObject, getFocusChild, getFocusInlineObject, getFocusListBlock, getFocusSpan, getFocusTextBlock, getLastBlock, getListIndex, getMarkState, getNextBlock, getNextInlineObject, getPreviousBlock, getPreviousInlineObject, getSelectedBlocks, getSelectedSlice, getSelectedSpans, getSelectedTextBlocks, getSelectedValue, getSelection, getSelectionEndBlock, getSelectionEndChild, getSelectionEndPoint, getSelectionStartBlock, getSelectionStartChild, getSelectionStartPoint, getSelectionText, getTrimmedSelection, getValue, isActiveAnnotation, isActiveDecorator, isActiveListItem, isActiveStyle, isAtTheEndOfBlock, isAtTheStartOfBlock, isOverlappingSelection, isPointAfterSelection, isPointBeforeSelection, isSelectingEntireBlocks, isSelectionCollapsed, isSelectionExpanded };
325
+ export { MarkState, getActiveAnnotations, getActiveListItem, getActiveStyle, getAnchorBlock, getAnchorChild, getAnchorSpan, getAnchorTextBlock, getBlockOffsets, getBlockTextBefore, getCaretWordSelection, getFirstBlock, getFocusBlock, getFocusBlockObject, getFocusChild, getFocusInlineObject, getFocusListBlock, getFocusSpan, getFocusTextBlock, getLastBlock, getListIndex, getMarkState, getNextBlock, getNextInlineObject, getPreviousBlock, getPreviousInlineObject, getSelectedBlocks, getSelectedSlice, getSelectedSpans, getSelectedTextBlocks, getSelectedValue, getSelection, getSelectionEndBlock, getSelectionEndChild, getSelectionEndPoint, getSelectionStartBlock, getSelectionStartChild, getSelectionStartPoint, getSelectionText, getTrimmedSelection, getValue, isActiveAnnotation, isActiveDecorator, isActiveListItem, isActiveStyle, isAtTheEndOfBlock, isAtTheStartOfBlock, isOverlappingSelection, isPointAfterSelection, isPointBeforeSelection, isSelectingEntireBlocks, isSelectionCollapsed, isSelectionExpanded };
@@ -1,5 +1,5 @@
1
1
  import { BlockOffset, BlockPath, ChildPath, EditorContext, EditorSelection, EditorSelectionPoint } from "../_chunks-dts/behavior.types.action.cjs";
2
- import * as _sanity_types8 from "@sanity/types";
2
+ import * as _sanity_types9 from "@sanity/types";
3
3
  import { KeyedSegment, PortableTextBlock, PortableTextTextBlock } from "@sanity/types";
4
4
  import { isSpan, isTextBlock } from "@portabletext/schema";
5
5
  /**
@@ -143,7 +143,7 @@ declare function mergeTextBlocks({
143
143
  context: Pick<EditorContext, 'keyGenerator' | 'schema'>;
144
144
  targetBlock: PortableTextTextBlock;
145
145
  incomingBlock: PortableTextTextBlock;
146
- }): PortableTextTextBlock<_sanity_types8.PortableTextObject | _sanity_types8.PortableTextSpan>;
146
+ }): PortableTextTextBlock<_sanity_types9.PortableTextObject | _sanity_types9.PortableTextSpan>;
147
147
  /**
148
148
  * @public
149
149
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@portabletext/editor",
3
- "version": "2.13.1",
3
+ "version": "2.13.3",
4
4
  "description": "Portable Text Editor made in React",
5
5
  "keywords": [
6
6
  "sanity",
@@ -85,16 +85,16 @@
85
85
  "slate-dom": "^0.118.1",
86
86
  "slate-react": "0.117.4",
87
87
  "xstate": "^5.22.0",
88
- "@portabletext/block-tools": "^3.5.7",
89
- "@portabletext/keyboard-shortcuts": "^1.1.1",
88
+ "@portabletext/block-tools": "^3.5.8",
90
89
  "@portabletext/patches": "^1.1.8",
91
- "@portabletext/schema": "^1.2.0"
90
+ "@portabletext/schema": "^1.2.0",
91
+ "@portabletext/keyboard-shortcuts": "^1.1.1"
92
92
  },
93
93
  "devDependencies": {
94
94
  "@sanity/diff-match-patch": "^3.2.0",
95
95
  "@sanity/pkg-utils": "^8.1.14",
96
- "@sanity/schema": "^4.9.0",
97
- "@sanity/types": "^4.9.0",
96
+ "@sanity/schema": "^4.10.1",
97
+ "@sanity/types": "^4.10.1",
98
98
  "@types/debug": "^4.1.12",
99
99
  "@types/lodash": "^4.17.20",
100
100
  "@types/lodash.startcase": "^4.4.9",
@@ -116,14 +116,14 @@
116
116
  "vite": "^7.1.7",
117
117
  "vitest": "^3.2.4",
118
118
  "vitest-browser-react": "^1.0.1",
119
- "@portabletext/sanity-bridge": "1.1.11",
120
- "racejar": "1.3.0",
121
- "@portabletext/test": "^0.0.0"
119
+ "@portabletext/sanity-bridge": "1.1.12",
120
+ "@portabletext/test": "^0.0.0",
121
+ "racejar": "1.3.0"
122
122
  },
123
123
  "peerDependencies": {
124
- "@portabletext/sanity-bridge": "^1.1.11",
125
- "@sanity/schema": "^4.9.0",
126
- "@sanity/types": "^4.9.0",
124
+ "@portabletext/sanity-bridge": "^1.1.12",
125
+ "@sanity/schema": "^4.10.1",
126
+ "@sanity/types": "^4.10.1",
127
127
  "react": "^18.3 || ^19",
128
128
  "rxjs": "^7.8.2"
129
129
  },
@@ -51,6 +51,7 @@ import {usePortableTextEditor} from './hooks/usePortableTextEditor'
51
51
  import {createWithHotkeys} from './plugins/createWithHotKeys'
52
52
  import {rangeDecorationsMachine} from './range-decorations-machine'
53
53
  import {RelayActorContext} from './relay-actor-context'
54
+ import {validateSelectionMachine} from './validate-selection-machine'
54
55
 
55
56
  const debug = debugWithName('component:Editable')
56
57
 
@@ -142,6 +143,11 @@ export const PortableTextEditable = forwardRef<
142
143
  s.matches({'edit mode': 'read only'}),
143
144
  )
144
145
  const slateEditor = useSlate()
146
+ const validateSelectionActor = useActorRef(validateSelectionMachine, {
147
+ input: {
148
+ slateEditor,
149
+ },
150
+ })
145
151
 
146
152
  const rangeDecorationsActor = useActorRef(rangeDecorationsMachine, {
147
153
  input: {
@@ -936,14 +942,37 @@ export const PortableTextEditable = forwardRef<
936
942
  )
937
943
 
938
944
  const callbackRef = useCallback(
939
- (node: HTMLDivElement | null) => {
945
+ (editorElement: HTMLDivElement | null) => {
940
946
  if (typeof forwardedRef === 'function') {
941
- forwardedRef(node)
947
+ forwardedRef(editorElement)
942
948
  } else if (forwardedRef) {
943
- forwardedRef.current = node
949
+ forwardedRef.current = editorElement
950
+ }
951
+
952
+ if (editorElement) {
953
+ // Observe mutations (child list and subtree) to this component's DOM,
954
+ // and make sure the editor selection is valid when that happens.
955
+ const mutationObserver = new MutationObserver(() => {
956
+ validateSelectionActor.send({
957
+ type: 'validate selection',
958
+ editorElement,
959
+ })
960
+ })
961
+
962
+ mutationObserver.observe(editorElement, {
963
+ attributeOldValue: false,
964
+ attributes: false,
965
+ characterData: false,
966
+ childList: true,
967
+ subtree: true,
968
+ })
969
+
970
+ return () => {
971
+ mutationObserver.disconnect()
972
+ }
944
973
  }
945
974
  },
946
- [forwardedRef],
975
+ [forwardedRef, validateSelectionActor],
947
976
  )
948
977
 
949
978
  if (!portableTextEditor) {
@@ -0,0 +1,47 @@
1
+ import {getTersePt} from '@portabletext/test'
2
+ import {userEvent} from '@vitest/browser/context'
3
+ import {describe, expect, test, vi} from 'vitest'
4
+ import {getSelectionAfterText} from '../internal-utils/text-selection'
5
+ import {createTestEditor} from '../test/vitest'
6
+ import {validateSelectionMachine} from './validate-selection-machine'
7
+
8
+ describe(validateSelectionMachine.id, () => {
9
+ test('Scenario: Does not validate selection while Slate has pending operations', async () => {
10
+ const {editor, locator} = await createTestEditor()
11
+
12
+ await userEvent.click(locator)
13
+
14
+ editor.send({type: 'insert.text', text: 'foo'})
15
+
16
+ await vi.waitFor(() => {
17
+ expect(getTersePt(editor.getSnapshot().context)).toEqual(['foo'])
18
+ expect(editor.getSnapshot().context.selection).toEqual(
19
+ getSelectionAfterText(editor.getSnapshot().context, 'foo'),
20
+ )
21
+ })
22
+
23
+ // This event is being sent in before "foo" has been inserted in the DOM
24
+ // This means that when the MutationObserver is finally triggered for the
25
+ // "foo" insertion, "bar" will be in the Slate state but not in the DOM.
26
+ // This causes the selection to be out of sync and this is why we need to
27
+ // make sure the selection is not validated before Slate has committed all
28
+ // pending operations.
29
+ editor.send({type: 'insert.text', text: 'bar'})
30
+
31
+ await vi.waitFor(() => {
32
+ expect(getTersePt(editor.getSnapshot().context)).toEqual(['foobar'])
33
+ expect(editor.getSnapshot().context.selection).toEqual(
34
+ getSelectionAfterText(editor.getSnapshot().context, 'foobar'),
35
+ )
36
+ })
37
+
38
+ editor.send({type: 'delete.backward', unit: 'character'})
39
+
40
+ await vi.waitFor(() => {
41
+ expect(getTersePt(editor.getSnapshot().context)).toEqual(['fooba'])
42
+ expect(editor.getSnapshot().context.selection).toEqual(
43
+ getSelectionAfterText(editor.getSnapshot().context, 'fooba'),
44
+ )
45
+ })
46
+ })
47
+ })
@@ -0,0 +1,149 @@
1
+ import {Editor, Transforms} from 'slate'
2
+ import {ReactEditor} from 'slate-react'
3
+ import {setup} from 'xstate'
4
+ import {debugWithName} from '../internal-utils/debug'
5
+ import type {PortableTextSlateEditor} from '../types/editor'
6
+
7
+ const debug = debugWithName('validate selection machine')
8
+
9
+ const validateSelectionSetup = setup({
10
+ types: {
11
+ context: {} as {
12
+ slateEditor: PortableTextSlateEditor
13
+ },
14
+ input: {} as {
15
+ slateEditor: PortableTextSlateEditor
16
+ },
17
+ events: {} as {
18
+ type: 'validate selection'
19
+ editorElement: HTMLDivElement
20
+ },
21
+ },
22
+ guards: {
23
+ 'pending operations': ({context}) =>
24
+ context.slateEditor.operations.length > 0,
25
+ },
26
+ })
27
+
28
+ const validateSelectionAction = validateSelectionSetup.createAction(
29
+ ({context, event}) => {
30
+ validateSelection(context.slateEditor, event.editorElement)
31
+ },
32
+ )
33
+
34
+ export const validateSelectionMachine = validateSelectionSetup.createMachine({
35
+ id: 'validate selection',
36
+ context: ({input}) => ({
37
+ slateEditor: input.slateEditor,
38
+ }),
39
+ initial: 'idle',
40
+ states: {
41
+ idle: {
42
+ on: {
43
+ 'validate selection': [
44
+ {
45
+ guard: 'pending operations',
46
+ target: 'waiting',
47
+ },
48
+ {
49
+ actions: [validateSelectionAction],
50
+ target: 'idle',
51
+ },
52
+ ],
53
+ },
54
+ },
55
+ waiting: {
56
+ after: {
57
+ 0: [
58
+ {
59
+ guard: 'pending operations',
60
+ target: '.',
61
+ reenter: true,
62
+ },
63
+ {
64
+ target: 'idle',
65
+ actions: [validateSelectionAction],
66
+ },
67
+ ],
68
+ },
69
+ on: {
70
+ 'validate selection': {
71
+ target: '.',
72
+ reenter: true,
73
+ },
74
+ },
75
+ },
76
+ },
77
+ })
78
+
79
+ // This function will handle unexpected DOM changes inside the Editable rendering,
80
+ // and make sure that we can maintain a stable slateEditor.selection when that happens.
81
+ //
82
+ // For example, if this Editable is rendered inside something that might re-render
83
+ // this component (hidden contexts) while the user is still actively changing the
84
+ // contentEditable, this could interfere with the intermediate DOM selection,
85
+ // which again could be picked up by ReactEditor's event listeners.
86
+ // If that range is invalid at that point, the slate.editorSelection could be
87
+ // set either wrong, or invalid, to which slateEditor will throw exceptions
88
+ // that are impossible to recover properly from or result in a wrong selection.
89
+ //
90
+ // Also the other way around, when the ReactEditor will try to create a DOM Range
91
+ // from the current slateEditor.selection, it may throw unrecoverable errors
92
+ // if the current editor.selection is invalid according to the DOM.
93
+ // If this is the case, default to selecting the top of the document, if the
94
+ // user already had a selection.
95
+ function validateSelection(
96
+ slateEditor: PortableTextSlateEditor,
97
+ editorElement: HTMLDivElement,
98
+ ) {
99
+ if (!slateEditor.selection) {
100
+ return
101
+ }
102
+
103
+ let root: Document | ShadowRoot | undefined
104
+
105
+ try {
106
+ root = ReactEditor.findDocumentOrShadowRoot(slateEditor)
107
+ } catch {}
108
+
109
+ if (!root) {
110
+ // The editor has most likely been unmounted
111
+ return
112
+ }
113
+
114
+ // Return if the editor isn't the active element
115
+ if (editorElement !== root.activeElement) {
116
+ return
117
+ }
118
+ const window = ReactEditor.getWindow(slateEditor)
119
+ const domSelection = window.getSelection()
120
+ if (!domSelection || domSelection.rangeCount === 0) {
121
+ return
122
+ }
123
+ const existingDOMRange = domSelection.getRangeAt(0)
124
+ try {
125
+ const newDOMRange = ReactEditor.toDOMRange(
126
+ slateEditor,
127
+ slateEditor.selection,
128
+ )
129
+ if (
130
+ newDOMRange.startOffset !== existingDOMRange.startOffset ||
131
+ newDOMRange.endOffset !== existingDOMRange.endOffset
132
+ ) {
133
+ debug('DOM range out of sync, validating selection')
134
+ // Remove all ranges temporary
135
+ domSelection?.removeAllRanges()
136
+ // Set the correct range
137
+ domSelection.addRange(newDOMRange)
138
+ }
139
+ } catch {
140
+ debug(`Could not resolve selection, selecting top document`)
141
+ // Deselect the editor
142
+ Transforms.deselect(slateEditor)
143
+ // Select top document if there is a top block to select
144
+ if (slateEditor.children.length > 0) {
145
+ Transforms.select(slateEditor, Editor.start(slateEditor, []))
146
+ }
147
+ slateEditor.onChange()
148
+ }
149
+ }
@@ -17,7 +17,7 @@ export {getFocusSpan} from './selector.get-focus-span'
17
17
  export {getFocusTextBlock} from './selector.get-focus-text-block'
18
18
  export {getLastBlock} from './selector.get-last-block'
19
19
  export {getListIndex} from './selector.get-list-state'
20
- export {getMarkState} from './selector.get-mark-state'
20
+ export {getMarkState, type MarkState} from './selector.get-mark-state'
21
21
  export {getNextBlock} from './selector.get-next-block'
22
22
  export {getNextInlineObject} from './selector.get-next-inline-object'
23
23
  export {getPreviousBlock} from './selector.get-previous-block'
@@ -8,6 +8,9 @@ import {getNextSpan} from './selector.get-next-span'
8
8
  import {getPreviousSpan} from './selector.get-previous-span'
9
9
  import {getSelectedSpans} from './selector.get-selected-spans'
10
10
 
11
+ /**
12
+ * @beta
13
+ */
11
14
  export type MarkState =
12
15
  | {
13
16
  state: 'unchanged'
@@ -70,7 +70,7 @@ const parameterType = {
70
70
  }),
71
71
  tersePt: createParameterType<Array<string>>({
72
72
  name: 'terse-pt',
73
- matcher: /"([a-z-,#>:\\n \d|{}'"‘’“”?—]*)"/u,
73
+ matcher: /"([A-Za-z-,#>:\\n \d|{}()'"‘’“”?—.…→©]*)"/u,
74
74
  type: Array,
75
75
  transform: parseTersePtString,
76
76
  }),