1
1
'use client'
2
2
3
- import type { DecoratorNode } from 'lexical'
3
+ import type { DecoratorNode , ElementNode , LexicalNode } from 'lexical'
4
4
5
5
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
6
- import { mergeRegister } from '@lexical/utils'
6
+ import { $findMatchingParent , mergeRegister } from '@lexical/utils'
7
7
import {
8
8
$createNodeSelection ,
9
+ $getEditor ,
9
10
$getNearestNodeFromDOMNode ,
10
11
$getSelection ,
11
12
$isDecoratorNode ,
13
+ $isElementNode ,
14
+ $isLineBreakNode ,
12
15
$isNodeSelection ,
16
+ $isRangeSelection ,
17
+ $isRootOrShadowRoot ,
18
+ $isTextNode ,
13
19
$setSelection ,
14
20
CLICK_COMMAND ,
15
21
COMMAND_PRIORITY_LOW ,
22
+ KEY_ARROW_DOWN_COMMAND ,
23
+ KEY_ARROW_UP_COMMAND ,
16
24
KEY_BACKSPACE_COMMAND ,
17
25
KEY_DELETE_COMMAND ,
26
+ SELECTION_CHANGE_COMMAND ,
18
27
} from 'lexical'
19
28
import { useEffect } from 'react'
20
29
@@ -43,11 +52,10 @@ export function DecoratorPlugin() {
43
52
CLICK_COMMAND ,
44
53
( event ) => {
45
54
document . querySelector ( '.decorator-selected' ) ?. classList . remove ( 'decorator-selected' )
46
- const decorator = $getDecorator ( event )
55
+ const decorator = $getDecoratorByMouseEvent ( event )
47
56
if ( ! decorator ) {
48
57
return true
49
58
}
50
- const { decoratorElement, decoratorNode } = decorator
51
59
const { target } = event
52
60
const isInteractive =
53
61
! ( target instanceof HTMLElement ) ||
@@ -58,33 +66,239 @@ export function DecoratorPlugin() {
58
66
if ( isInteractive ) {
59
67
$setSelection ( null )
60
68
} else {
61
- const selection = $createNodeSelection ( )
62
- selection . add ( decoratorNode . getKey ( ) )
63
- $setSelection ( selection )
64
- decoratorElement . classList . add ( 'decorator-selected' )
69
+ $selectDecorator ( decorator )
65
70
}
66
71
return true
67
72
} ,
68
73
COMMAND_PRIORITY_LOW ,
69
74
) ,
70
75
editor . registerCommand ( KEY_DELETE_COMMAND , $onDelete , COMMAND_PRIORITY_LOW ) ,
71
76
editor . registerCommand ( KEY_BACKSPACE_COMMAND , $onDelete , COMMAND_PRIORITY_LOW ) ,
77
+ editor . registerCommand (
78
+ SELECTION_CHANGE_COMMAND ,
79
+ ( ) => {
80
+ const decorator = $getSelectedDecorator ( )
81
+ document . querySelector ( '.decorator-selected' ) ?. classList . remove ( 'decorator-selected' )
82
+ if ( decorator ) {
83
+ decorator . element ?. classList . add ( 'decorator-selected' )
84
+ return true
85
+ }
86
+ return false
87
+ } ,
88
+ COMMAND_PRIORITY_LOW ,
89
+ ) ,
90
+ editor . registerCommand (
91
+ KEY_ARROW_UP_COMMAND ,
92
+ ( event ) => {
93
+ // CASE 1: Node selection
94
+ const selection = $getSelection ( )
95
+ if ( $isNodeSelection ( selection ) ) {
96
+ const prevSibling = selection . getNodes ( ) [ 0 ] ?. getPreviousSibling ( )
97
+ if ( $isDecoratorNode ( prevSibling ) ) {
98
+ const element = $getEditor ( ) . getElementByKey ( prevSibling . getKey ( ) )
99
+ if ( element ) {
100
+ $selectDecorator ( { element, node : prevSibling } )
101
+ event . preventDefault ( )
102
+ return true
103
+ }
104
+ return false
105
+ }
106
+ if ( ! $isElementNode ( prevSibling ) ) {
107
+ return false
108
+ }
109
+ const lastDescendant = prevSibling . getLastDescendant ( ) ?? prevSibling
110
+ if ( ! lastDescendant ) {
111
+ return false
112
+ }
113
+ const block = $findMatchingParent ( lastDescendant , INTERNAL_$isBlock )
114
+ block ?. selectStart ( )
115
+ event . preventDefault ( )
116
+ return true
117
+ }
118
+ if ( ! $isRangeSelection ( selection ) ) {
119
+ return false
120
+ }
121
+
122
+ // CASE 2: Range selection
123
+ // Get first selected block
124
+ const firstPoint = selection . isBackward ( ) ? selection . anchor : selection . focus
125
+ const firstNode = firstPoint . getNode ( )
126
+ const firstSelectedBlock = $findMatchingParent ( firstNode , ( node ) => {
127
+ return findFirstSiblingBlock ( node ) !== null
128
+ } )
129
+ const prevBlock = firstSelectedBlock ?. getPreviousSibling ( )
130
+ if ( ! firstSelectedBlock || prevBlock !== findFirstSiblingBlock ( firstSelectedBlock ) ) {
131
+ return false
132
+ }
133
+
134
+ if ( $isDecoratorNode ( prevBlock ) ) {
135
+ const prevBlockElement = $getEditor ( ) . getElementByKey ( prevBlock . getKey ( ) )
136
+ if ( prevBlockElement ) {
137
+ $selectDecorator ( { element : prevBlockElement , node : prevBlock } )
138
+ event . preventDefault ( )
139
+ return true
140
+ }
141
+ }
142
+
143
+ return false
144
+ } ,
145
+ COMMAND_PRIORITY_LOW ,
146
+ ) ,
147
+ editor . registerCommand (
148
+ KEY_ARROW_DOWN_COMMAND ,
149
+ ( event ) => {
150
+ // CASE 1: Node selection
151
+ const selection = $getSelection ( )
152
+ if ( $isNodeSelection ( selection ) ) {
153
+ event . preventDefault ( )
154
+ const nextSibling = selection . getNodes ( ) [ 0 ] ?. getNextSibling ( )
155
+ if ( $isDecoratorNode ( nextSibling ) ) {
156
+ const element = $getEditor ( ) . getElementByKey ( nextSibling . getKey ( ) )
157
+ if ( element ) {
158
+ $selectDecorator ( { element, node : nextSibling } )
159
+ }
160
+ return true
161
+ }
162
+ if ( ! $isElementNode ( nextSibling ) ) {
163
+ return true
164
+ }
165
+ const firstDescendant = nextSibling . getFirstDescendant ( ) ?? nextSibling
166
+ if ( ! firstDescendant ) {
167
+ return true
168
+ }
169
+ const block = $findMatchingParent ( firstDescendant , INTERNAL_$isBlock )
170
+ block ?. selectEnd ( )
171
+ event . preventDefault ( )
172
+ return true
173
+ }
174
+ if ( ! $isRangeSelection ( selection ) ) {
175
+ return false
176
+ }
177
+
178
+ // CASE 2: Range selection
179
+ // Get last selected block
180
+ const lastPoint = selection . isBackward ( ) ? selection . anchor : selection . focus
181
+ const lastNode = lastPoint . getNode ( )
182
+ const lastSelectedBlock = $findMatchingParent ( lastNode , ( node ) => {
183
+ return findLaterSiblingBlock ( node ) !== null
184
+ } )
185
+ const nextBlock = lastSelectedBlock ?. getNextSibling ( )
186
+ if ( ! lastSelectedBlock || nextBlock !== findLaterSiblingBlock ( lastSelectedBlock ) ) {
187
+ return false
188
+ }
189
+
190
+ if ( $isDecoratorNode ( nextBlock ) ) {
191
+ const nextBlockElement = $getEditor ( ) . getElementByKey ( nextBlock . getKey ( ) )
192
+ if ( nextBlockElement ) {
193
+ $selectDecorator ( { element : nextBlockElement , node : nextBlock } )
194
+ event . preventDefault ( )
195
+ return true
196
+ }
197
+ }
198
+
199
+ return false
200
+ } ,
201
+ COMMAND_PRIORITY_LOW ,
202
+ ) ,
72
203
)
73
204
} , [ editor ] )
74
205
75
206
return null
76
207
}
77
208
78
- function $getDecorator (
209
+ function $getDecoratorByMouseEvent (
79
210
event : MouseEvent ,
80
- ) : { decoratorElement : Element ; decoratorNode : DecoratorNode < unknown > } | undefined {
81
- if ( ! ( event . target instanceof Element ) ) {
211
+ ) : { element : HTMLElement ; node : DecoratorNode < unknown > } | undefined {
212
+ if ( ! ( event . target instanceof HTMLElement ) ) {
82
213
return undefined
83
214
}
84
- const decoratorElement = event . target . closest ( '[data-lexical-decorator="true"]' )
85
- if ( ! decoratorElement ) {
215
+ const element = event . target . closest ( '[data-lexical-decorator="true"]' )
216
+ if ( ! ( element instanceof HTMLElement ) ) {
217
+ return undefined
218
+ }
219
+ const node = $getNearestNodeFromDOMNode ( element )
220
+ return $isDecoratorNode ( node ) ? { element, node } : undefined
221
+ }
222
+
223
+ function $getSelectedDecorator ( ) {
224
+ const selection = $getSelection ( )
225
+ if ( ! $isNodeSelection ( selection ) ) {
86
226
return undefined
87
227
}
88
- const node = $getNearestNodeFromDOMNode ( decoratorElement )
89
- return $isDecoratorNode ( node ) ? { decoratorElement, decoratorNode : node } : undefined
228
+ const nodes = selection . getNodes ( )
229
+ if ( nodes . length !== 1 ) {
230
+ return undefined
231
+ }
232
+ const node = nodes [ 0 ]
233
+ return $isDecoratorNode ( node )
234
+ ? {
235
+ decorator : node ,
236
+ element : $getEditor ( ) . getElementByKey ( node . getKey ( ) ) ,
237
+ }
238
+ : undefined
239
+ }
240
+
241
+ function $selectDecorator ( {
242
+ element,
243
+ node,
244
+ } : {
245
+ element : HTMLElement
246
+ node : DecoratorNode < unknown >
247
+ } ) {
248
+ document . querySelector ( '.decorator-selected' ) ?. classList . remove ( 'decorator-selected' )
249
+ const selection = $createNodeSelection ( )
250
+ selection . add ( node . getKey ( ) )
251
+ $setSelection ( selection )
252
+ element . scrollIntoView ( { behavior : 'smooth' , block : 'nearest' } )
253
+ element . classList . add ( 'decorator-selected' )
254
+ }
255
+
256
+ /**
257
+ * Copied from https://github.com/facebook/lexical/blob/main/packages/lexical/src/LexicalUtils.ts
258
+ *
259
+ * This function returns true for a DecoratorNode that is not inline OR
260
+ * an ElementNode that is:
261
+ * - not a root or shadow root
262
+ * - not inline
263
+ * - can't be empty
264
+ * - has no children or an inline first child
265
+ */
266
+ export function INTERNAL_$isBlock ( node : LexicalNode ) : node is DecoratorNode < unknown > | ElementNode {
267
+ if ( $isDecoratorNode ( node ) && ! node . isInline ( ) ) {
268
+ return true
269
+ }
270
+ if ( ! $isElementNode ( node ) || $isRootOrShadowRoot ( node ) ) {
271
+ return false
272
+ }
273
+
274
+ const firstChild = node . getFirstChild ( )
275
+ const isLeafElement =
276
+ firstChild === null ||
277
+ $isLineBreakNode ( firstChild ) ||
278
+ $isTextNode ( firstChild ) ||
279
+ firstChild . isInline ( )
280
+
281
+ return ! node . isInline ( ) && node . canBeEmpty ( ) !== false && isLeafElement
282
+ }
283
+
284
+ function findLaterSiblingBlock ( node : LexicalNode ) : LexicalNode | null {
285
+ let current = node . getNextSibling ( )
286
+ while ( current !== null ) {
287
+ if ( INTERNAL_$isBlock ( current ) ) {
288
+ return current
289
+ }
290
+ current = current . getNextSibling ( )
291
+ }
292
+ return null
293
+ }
294
+
295
+ function findFirstSiblingBlock ( node : LexicalNode ) : LexicalNode | null {
296
+ let current = node . getPreviousSibling ( )
297
+ while ( current !== null ) {
298
+ if ( INTERNAL_$isBlock ( current ) ) {
299
+ return current
300
+ }
301
+ current = current . getPreviousSibling ( )
302
+ }
303
+ return null
90
304
}
0 commit comments