@@ -15,7 +15,15 @@ import {
15
15
useMemo ,
16
16
useState ,
17
17
} from 'react'
18
- import { type BaseRange , Editor , type Text , Transforms } from 'slate'
18
+ import {
19
+ type BaseRange ,
20
+ Editor ,
21
+ type NodeEntry ,
22
+ type Operation ,
23
+ Range as SlateRange ,
24
+ type Text ,
25
+ Transforms ,
26
+ } from 'slate'
19
27
import {
20
28
Editable as SlateEditable ,
21
29
ReactEditor ,
@@ -30,6 +38,7 @@ import {
30
38
type OnCopyFn ,
31
39
type OnPasteFn ,
32
40
type OnPasteResult ,
41
+ type RangeDecoration ,
33
42
type RenderAnnotationFunction ,
34
43
type RenderBlockFunction ,
35
44
type RenderChildFunction ,
@@ -40,7 +49,7 @@ import {
40
49
} from '../types/editor'
41
50
import { type HotkeyOptions } from '../types/options'
42
51
import { debugWithName } from '../utils/debug'
43
- import { toPortableTextRange , toSlateRange } from '../utils/ranges'
52
+ import { moveRangeByOperation , toPortableTextRange , toSlateRange } from '../utils/ranges'
44
53
import { normalizeSelection } from '../utils/selection'
45
54
import { fromSlateValue , isEqualToEmptyEditor , toSlateValue } from '../utils/values'
46
55
import { Element } from './components/Element'
@@ -62,7 +71,11 @@ const PLACEHOLDER_STYLE: CSSProperties = {
62
71
right : 0 ,
63
72
}
64
73
65
- const EMPTY_DECORATORS : BaseRange [ ] = [ ]
74
+ interface BaseRangeWithDecoration extends BaseRange {
75
+ rangeDecoration : RangeDecoration
76
+ }
77
+
78
+ const EMPTY_DECORATORS : BaseRangeWithDecoration [ ] = [ ]
66
79
67
80
/**
68
81
* @public
@@ -75,6 +88,7 @@ export type PortableTextEditableProps = Omit<
75
88
onBeforeInput ?: ( event : InputEvent ) => void
76
89
onPaste ?: OnPasteFn
77
90
onCopy ?: OnCopyFn
91
+ rangeDecorations ?: RangeDecoration [ ]
78
92
renderAnnotation ?: RenderAnnotationFunction
79
93
renderBlock ?: RenderBlockFunction
80
94
renderChild ?: RenderChildFunction
@@ -102,6 +116,7 @@ export const PortableTextEditable = forwardRef(function PortableTextEditable(
102
116
onBeforeInput,
103
117
onPaste,
104
118
onCopy,
119
+ rangeDecorations,
105
120
renderAnnotation,
106
121
renderBlock,
107
122
renderChild,
@@ -121,6 +136,8 @@ export const PortableTextEditable = forwardRef(function PortableTextEditable(
121
136
const ref = useForwardedRef ( forwardedRef )
122
137
const [ editableElement , setEditableElement ] = useState < HTMLDivElement | null > ( null )
123
138
const [ hasInvalidValue , setHasInvalidValue ] = useState ( false )
139
+ const [ rangeDecorationState , setRangeDecorationsState ] =
140
+ useState < BaseRangeWithDecoration [ ] > ( EMPTY_DECORATORS )
124
141
125
142
const { change$, schemaTypes} = portableTextEditor
126
143
const slateEditor = useSlate ( )
@@ -166,28 +183,39 @@ export const PortableTextEditable = forwardRef(function PortableTextEditable(
166
183
)
167
184
168
185
const renderLeaf = useCallback (
169
- ( lProps : RenderLeafProps & { leaf : Text & { placeholder ?: boolean } } ) => {
170
- const rendered = (
171
- < Leaf
172
- { ...lProps }
173
- schemaTypes = { schemaTypes }
174
- renderAnnotation = { renderAnnotation }
175
- renderChild = { renderChild }
176
- renderDecorator = { renderDecorator }
177
- readOnly = { readOnly }
178
- />
179
- )
180
- if ( renderPlaceholder && lProps . leaf . placeholder && lProps . text . text === '' ) {
181
- return (
182
- < >
183
- < span style = { PLACEHOLDER_STYLE } contentEditable = { false } >
184
- { renderPlaceholder ( ) }
185
- </ span >
186
- { rendered }
187
- </ >
186
+ (
187
+ lProps : RenderLeafProps & {
188
+ leaf : Text & { placeholder ?: boolean ; rangeDecoration ?: RangeDecoration }
189
+ } ,
190
+ ) => {
191
+ if ( lProps . leaf . _type === 'span' ) {
192
+ let rendered = (
193
+ < Leaf
194
+ { ...lProps }
195
+ schemaTypes = { schemaTypes }
196
+ renderAnnotation = { renderAnnotation }
197
+ renderChild = { renderChild }
198
+ renderDecorator = { renderDecorator }
199
+ readOnly = { readOnly }
200
+ />
188
201
)
202
+ if ( renderPlaceholder && lProps . leaf . placeholder && lProps . text . text === '' ) {
203
+ return (
204
+ < >
205
+ < span style = { PLACEHOLDER_STYLE } contentEditable = { false } >
206
+ { renderPlaceholder ( ) }
207
+ </ span >
208
+ { rendered }
209
+ </ >
210
+ )
211
+ }
212
+ const decoration = lProps . leaf . rangeDecoration
213
+ if ( decoration ) {
214
+ rendered = decoration . component ( { children : rendered } )
215
+ }
216
+ return rendered
189
217
}
190
- return rendered
218
+ return lProps . children
191
219
} ,
192
220
[ readOnly , renderAnnotation , renderChild , renderDecorator , renderPlaceholder , schemaTypes ] ,
193
221
)
@@ -215,9 +243,58 @@ export const PortableTextEditable = forwardRef(function PortableTextEditable(
215
243
}
216
244
} , [ propsSelection , slateEditor , blockTypeName , change$ ] )
217
245
246
+ const syncRangeDecorations = useCallback (
247
+ ( operation ?: Operation ) => {
248
+ if ( rangeDecorations && rangeDecorations . length > 0 ) {
249
+ const newSlateRanges : BaseRangeWithDecoration [ ] = [ ]
250
+ rangeDecorations . forEach ( ( rangeDecorationItem ) => {
251
+ const slateRange = toSlateRange ( rangeDecorationItem . selection , slateEditor )
252
+ if ( ! SlateRange . isRange ( slateRange ) || ! SlateRange . isExpanded ( slateRange ) ) {
253
+ if ( rangeDecorationItem . onMoved ) {
254
+ rangeDecorationItem . onMoved ( {
255
+ newSelection : null ,
256
+ rangeDecoration : rangeDecorationItem ,
257
+ origin : 'local' ,
258
+ } )
259
+ }
260
+ return
261
+ }
262
+ let newRange : BaseRange | null | undefined
263
+ if ( operation ) {
264
+ newRange = moveRangeByOperation ( slateRange , operation )
265
+ if ( ( newRange && newRange !== slateRange ) || ( newRange === null && slateRange ) ) {
266
+ const value = PortableTextEditor . getValue ( portableTextEditor )
267
+ const newRangeSelection = toPortableTextRange ( value , newRange , schemaTypes )
268
+ if ( rangeDecorationItem . onMoved ) {
269
+ rangeDecorationItem . onMoved ( {
270
+ newSelection : newRangeSelection ,
271
+ rangeDecoration : rangeDecorationItem ,
272
+ origin : 'local' ,
273
+ } )
274
+ }
275
+ // Temporarily set the range decoration to the new range (it will however be overwritten by props at any moment)
276
+ rangeDecorationItem . selection = newRangeSelection
277
+ }
278
+ }
279
+ // If the newRange is null, it means that the range is not valid anymore and should be removed
280
+ // If it's undefined, it means that the slateRange is still valid and should be kept
281
+ if ( newRange !== null ) {
282
+ newSlateRanges . push ( { ...( newRange || slateRange ) , rangeDecoration : rangeDecorationItem } )
283
+ }
284
+ } )
285
+ if ( newSlateRanges . length > 0 ) {
286
+ setRangeDecorationsState ( newSlateRanges )
287
+ return
288
+ }
289
+ }
290
+ setRangeDecorationsState ( EMPTY_DECORATORS )
291
+ } ,
292
+ [ portableTextEditor , rangeDecorations , schemaTypes , slateEditor ] ,
293
+ )
294
+
218
295
// Subscribe to change$ and restore selection from props when the editor has been initialized properly with it's value
219
296
useEffect ( ( ) => {
220
- debug ( 'Subscribing to editor changes$' )
297
+ // debug('Subscribing to editor changes$')
221
298
const sub = change$ . subscribe ( ( next : EditorChange ) : void => {
222
299
switch ( next . type ) {
223
300
case 'ready' :
@@ -233,10 +310,10 @@ export const PortableTextEditable = forwardRef(function PortableTextEditable(
233
310
}
234
311
} )
235
312
return ( ) => {
236
- debug ( 'Unsubscribing to changes$' )
313
+ // debug('Unsubscribing to changes$')
237
314
sub . unsubscribe ( )
238
315
}
239
- } , [ change$ , restoreSelectionFromProps ] )
316
+ } , [ change$ , restoreSelectionFromProps , syncRangeDecorations ] )
240
317
241
318
// Restore selection from props when it changes
242
319
useEffect ( ( ) => {
@@ -245,6 +322,26 @@ export const PortableTextEditable = forwardRef(function PortableTextEditable(
245
322
}
246
323
} , [ hasInvalidValue , propsSelection , restoreSelectionFromProps ] )
247
324
325
+ // Store reference to original apply function (see below for usage in useEffect)
326
+ const originalApply = useMemo ( ( ) => slateEditor . apply , [ slateEditor ] )
327
+
328
+ useEffect ( ( ) => {
329
+ syncRangeDecorations ( )
330
+ } , [ rangeDecorations , syncRangeDecorations ] )
331
+
332
+ // Sync range decorations before an operation is applied
333
+ useEffect ( ( ) => {
334
+ slateEditor . apply = ( op : Operation ) => {
335
+ originalApply ( op )
336
+ if ( op . type !== 'set_selection' ) {
337
+ syncRangeDecorations ( op )
338
+ }
339
+ }
340
+ return ( ) => {
341
+ slateEditor . apply = originalApply
342
+ }
343
+ } , [ originalApply , slateEditor , syncRangeDecorations ] )
344
+
248
345
// Handle from props onCopy function
249
346
const handleCopy = useCallback (
250
347
( event : ClipboardEvent < HTMLDivElement > ) : void | ReactEditor => {
@@ -460,24 +557,33 @@ export const PortableTextEditable = forwardRef(function PortableTextEditable(
460
557
}
461
558
} , [ portableTextEditor , scrollSelectionIntoView ] )
462
559
463
- const decorate = useCallback ( ( ) => {
464
- if ( isEqualToEmptyEditor ( slateEditor . children , schemaTypes ) ) {
465
- return [
466
- {
467
- anchor : {
468
- path : [ 0 , 0 ] ,
469
- offset : 0 ,
470
- } ,
471
- focus : {
472
- path : [ 0 , 0 ] ,
473
- offset : 0 ,
560
+ const decorate : ( entry : NodeEntry ) => BaseRange [ ] = useCallback (
561
+ ( [ , path ] ) => {
562
+ if ( isEqualToEmptyEditor ( slateEditor . children , schemaTypes ) ) {
563
+ return [
564
+ {
565
+ anchor : {
566
+ path : [ 0 , 0 ] ,
567
+ offset : 0 ,
568
+ } ,
569
+ focus : {
570
+ path : [ 0 , 0 ] ,
571
+ offset : 0 ,
572
+ } ,
573
+ placeholder : true ,
474
574
} ,
475
- placeholder : true ,
476
- } ,
477
- ]
478
- }
479
- return EMPTY_DECORATORS
480
- } , [ schemaTypes , slateEditor ] )
575
+ ]
576
+ }
577
+ const result = rangeDecorationState . filter (
578
+ ( item ) => path . length > 1 && SlateRange . includes ( item , path ) ,
579
+ )
580
+ if ( result . length > 0 ) {
581
+ return result
582
+ }
583
+ return EMPTY_DECORATORS
584
+ } ,
585
+ [ slateEditor , schemaTypes , rangeDecorationState ] ,
586
+ )
481
587
482
588
// Set the forwarded ref to be the Slate editable DOM element
483
589
// Also set the editable element in a state so that the MutationObserver
0 commit comments