@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.
- package/LICENSE +21 -0
- package/README.md +3 -0
- package/lib/index.d.mts +911 -0
- package/lib/index.d.ts +911 -0
- package/lib/index.esm.js +4896 -0
- package/lib/index.esm.js.map +1 -0
- package/lib/index.js +4874 -0
- package/lib/index.js.map +1 -0
- package/lib/index.mjs +4896 -0
- package/lib/index.mjs.map +1 -0
- package/package.json +119 -0
- package/src/editor/Editable.tsx +683 -0
- package/src/editor/PortableTextEditor.tsx +308 -0
- package/src/editor/__tests__/PortableTextEditor.test.tsx +386 -0
- package/src/editor/__tests__/PortableTextEditorTester.tsx +116 -0
- package/src/editor/__tests__/RangeDecorations.test.tsx +115 -0
- package/src/editor/__tests__/handleClick.test.tsx +218 -0
- package/src/editor/__tests__/pteWarningsSelfSolving.test.tsx +389 -0
- package/src/editor/__tests__/utils.ts +39 -0
- package/src/editor/components/DraggableBlock.tsx +287 -0
- package/src/editor/components/Element.tsx +279 -0
- package/src/editor/components/Leaf.tsx +288 -0
- package/src/editor/components/SlateContainer.tsx +81 -0
- package/src/editor/components/Synchronizer.tsx +190 -0
- package/src/editor/hooks/usePortableTextEditor.ts +23 -0
- package/src/editor/hooks/usePortableTextEditorKeyGenerator.ts +24 -0
- package/src/editor/hooks/usePortableTextEditorSelection.ts +22 -0
- package/src/editor/hooks/usePortableTextEditorValue.ts +16 -0
- package/src/editor/hooks/usePortableTextReadOnly.ts +20 -0
- package/src/editor/hooks/useSyncValue.test.tsx +125 -0
- package/src/editor/hooks/useSyncValue.ts +372 -0
- package/src/editor/nodes/DefaultAnnotation.tsx +16 -0
- package/src/editor/nodes/DefaultObject.tsx +15 -0
- package/src/editor/nodes/index.ts +189 -0
- package/src/editor/plugins/__tests__/withEditableAPIDelete.test.tsx +244 -0
- package/src/editor/plugins/__tests__/withEditableAPIGetFragment.test.tsx +142 -0
- package/src/editor/plugins/__tests__/withEditableAPIInsert.test.tsx +346 -0
- package/src/editor/plugins/__tests__/withEditableAPISelectionsOverlapping.test.tsx +162 -0
- package/src/editor/plugins/__tests__/withHotkeys.test.tsx +212 -0
- package/src/editor/plugins/__tests__/withInsertBreak.test.tsx +204 -0
- package/src/editor/plugins/__tests__/withPlaceholderBlock.test.tsx +133 -0
- package/src/editor/plugins/__tests__/withPortableTextLists.test.tsx +65 -0
- package/src/editor/plugins/__tests__/withPortableTextMarkModel.test.tsx +1377 -0
- package/src/editor/plugins/__tests__/withPortableTextSelections.test.tsx +91 -0
- package/src/editor/plugins/__tests__/withUndoRedo.test.tsx +115 -0
- package/src/editor/plugins/createWithEditableAPI.ts +573 -0
- package/src/editor/plugins/createWithHotKeys.ts +304 -0
- package/src/editor/plugins/createWithInsertBreak.ts +45 -0
- package/src/editor/plugins/createWithInsertData.ts +359 -0
- package/src/editor/plugins/createWithMaxBlocks.ts +24 -0
- package/src/editor/plugins/createWithObjectKeys.ts +63 -0
- package/src/editor/plugins/createWithPatches.ts +274 -0
- package/src/editor/plugins/createWithPlaceholderBlock.ts +36 -0
- package/src/editor/plugins/createWithPortableTextBlockStyle.ts +91 -0
- package/src/editor/plugins/createWithPortableTextLists.ts +160 -0
- package/src/editor/plugins/createWithPortableTextMarkModel.ts +441 -0
- package/src/editor/plugins/createWithPortableTextSelections.ts +65 -0
- package/src/editor/plugins/createWithSchemaTypes.ts +76 -0
- package/src/editor/plugins/createWithUndoRedo.ts +494 -0
- package/src/editor/plugins/createWithUtils.ts +81 -0
- package/src/editor/plugins/index.ts +155 -0
- package/src/index.ts +11 -0
- package/src/patch/PatchEvent.ts +33 -0
- package/src/patch/applyPatch.ts +29 -0
- package/src/patch/array.ts +89 -0
- package/src/patch/arrayInsert.ts +27 -0
- package/src/patch/object.ts +39 -0
- package/src/patch/patches.ts +53 -0
- package/src/patch/primitive.ts +43 -0
- package/src/patch/string.ts +51 -0
- package/src/types/editor.ts +576 -0
- package/src/types/options.ts +17 -0
- package/src/types/patch.ts +65 -0
- package/src/types/slate.ts +25 -0
- package/src/utils/__tests__/dmpToOperations.test.ts +181 -0
- package/src/utils/__tests__/operationToPatches.test.ts +421 -0
- package/src/utils/__tests__/patchToOperations.test.ts +293 -0
- package/src/utils/__tests__/ranges.test.ts +18 -0
- package/src/utils/__tests__/valueNormalization.test.tsx +62 -0
- package/src/utils/__tests__/values.test.ts +253 -0
- package/src/utils/applyPatch.ts +407 -0
- package/src/utils/bufferUntil.ts +15 -0
- package/src/utils/debug.ts +12 -0
- package/src/utils/getPortableTextMemberSchemaTypes.ts +100 -0
- package/src/utils/operationToPatches.ts +357 -0
- package/src/utils/patches.ts +36 -0
- package/src/utils/paths.ts +60 -0
- package/src/utils/ranges.ts +77 -0
- package/src/utils/schema.ts +8 -0
- package/src/utils/selection.ts +65 -0
- package/src/utils/ucs2Indices.ts +67 -0
- package/src/utils/validateValue.ts +394 -0
- package/src/utils/values.ts +208 -0
- package/src/utils/weakMaps.ts +24 -0
- package/src/utils/withChanges.ts +25 -0
- package/src/utils/withPreserveKeys.ts +14 -0
- 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
|
+
})
|