@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.
- package/lib/_chunks-cjs/selector.is-selecting-entire-blocks.cjs +1 -1
- package/lib/_chunks-cjs/selector.is-selecting-entire-blocks.cjs.map +1 -1
- package/lib/_chunks-dts/behavior.types.action.d.cts +25 -10
- package/lib/_chunks-dts/behavior.types.action.d.ts +25 -10
- package/lib/_chunks-es/selector.is-selecting-entire-blocks.js +1 -1
- package/lib/_chunks-es/selector.is-selecting-entire-blocks.js.map +1 -1
- package/lib/index.cjs +214 -140
- package/lib/index.cjs.map +1 -1
- package/lib/index.js +215 -141
- package/lib/index.js.map +1 -1
- package/lib/plugins/index.d.ts +3 -3
- package/package.json +9 -9
- package/src/editor/Editable.tsx +90 -103
- package/src/selectors/selector.is-overlapping-selection.test.ts +17 -0
- package/src/selectors/selector.is-overlapping-selection.ts +13 -0
package/lib/plugins/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Behavior, Editor, EditorEmittedEvent, EditorSchema } from "../_chunks-dts/behavior.types.action.js";
|
|
2
|
-
import * as
|
|
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
|
-
}):
|
|
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():
|
|
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.
|
|
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": "^
|
|
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.
|
|
84
|
-
"@portabletext/
|
|
85
|
-
"@portabletext/
|
|
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.
|
|
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.
|
|
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"
|
package/src/editor/Editable.tsx
CHANGED
|
@@ -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
|
}
|