0
# Specialized Utilities
1
2
Focused utility modules providing selection marking, DOM node positioning, function merging, and CSS utilities. These are specialized tools exported as default exports from individual modules, each solving specific common problems in rich text editor development.
3
4
## Capabilities
5
6
### Function Merging
7
8
Combines multiple cleanup functions into a single function that executes them in reverse order (LIFO), commonly used with React's useEffect and Lexical's registration functions.
9
10
```typescript { .api }
11
/**
12
* Returns a function that will execute all functions passed when called. It is generally used
13
* to register multiple lexical listeners and then tear them down with a single function call, such
14
* as React's useEffect hook.
15
*
16
* The order of cleanup is the reverse of the argument order. Generally it is
17
* expected that the first "acquire" will be "released" last (LIFO order),
18
* because a later step may have some dependency on an earlier one.
19
*
20
* @param func - An array of cleanup functions meant to be executed by the returned function.
21
* @returns the function which executes all the passed cleanup functions.
22
*/
23
function mergeRegister(...func: Array<() => void>): () => void;
24
```
25
26
**Usage Examples:**
27
28
```typescript
29
import { mergeRegister } from "@lexical/utils";
30
31
// Basic usage with React useEffect
32
useEffect(() => {
33
return mergeRegister(
34
editor.registerCommand(SOME_COMMAND, commandHandler),
35
editor.registerUpdateListener(updateHandler),
36
editor.registerTextContentListener(textHandler)
37
);
38
}, [editor]);
39
40
// Manual cleanup management
41
const cleanupFunctions: Array<() => void> = [];
42
43
// Register various listeners
44
cleanupFunctions.push(editor.registerCommand('INSERT_TEXT', handleInsertText));
45
cleanupFunctions.push(editor.registerCommand('DELETE_TEXT', handleDeleteText));
46
cleanupFunctions.push(editor.registerNodeTransform(TextNode, handleTextTransform));
47
48
// Create single cleanup function
49
const cleanup = mergeRegister(...cleanupFunctions);
50
51
// Later, clean up everything at once
52
cleanup();
53
54
// Advanced pattern with conditional registration
55
function setupEditor(editor: LexicalEditor, features: EditorFeatures) {
56
const registrations: Array<() => void> = [];
57
58
// Always register core handlers
59
registrations.push(
60
editor.registerUpdateListener(handleUpdate),
61
editor.registerCommand('FOCUS', handleFocus)
62
);
63
64
// Conditionally register feature handlers
65
if (features.autoSave) {
66
registrations.push(
67
editor.registerTextContentListener(handleAutoSave)
68
);
69
}
70
71
if (features.collaboration) {
72
registrations.push(
73
editor.registerCommand('COLLAB_UPDATE', handleCollabUpdate),
74
editor.registerMutationListener(ElementNode, handleMutation)
75
);
76
}
77
78
if (features.spellCheck) {
79
registrations.push(
80
setupSpellChecker(editor)
81
);
82
}
83
84
return mergeRegister(...registrations);
85
}
86
87
// Plugin pattern
88
class CustomPlugin {
89
private cleanupFn: (() => void) | null = null;
90
91
initialize(editor: LexicalEditor) {
92
this.cleanupFn = mergeRegister(
93
this.registerCommands(editor),
94
this.registerTransforms(editor),
95
this.registerListeners(editor)
96
);
97
}
98
99
destroy() {
100
if (this.cleanupFn) {
101
this.cleanupFn();
102
this.cleanupFn = null;
103
}
104
}
105
106
private registerCommands(editor: LexicalEditor) {
107
return mergeRegister(
108
editor.registerCommand('CUSTOM_COMMAND_1', this.handleCommand1),
109
editor.registerCommand('CUSTOM_COMMAND_2', this.handleCommand2)
110
);
111
}
112
113
private registerTransforms(editor: LexicalEditor) {
114
return mergeRegister(
115
editor.registerNodeTransform(CustomNode, this.transformNode),
116
editor.registerNodeTransform(TextNode, this.transformText)
117
);
118
}
119
120
private registerListeners(editor: LexicalEditor) {
121
return editor.registerUpdateListener(this.handleUpdate);
122
}
123
}
124
```
125
126
### Selection Marking
127
128
Creates visual selection markers when the editor loses focus, maintaining selection visibility for user experience.
129
130
```typescript { .api }
131
/**
132
* Place one or multiple newly created Nodes at the current selection. Multiple
133
* nodes will only be created when the selection spans multiple lines (aka
134
* client rects).
135
*
136
* This function can come useful when you want to show the selection but the
137
* editor has been focused away.
138
*/
139
function markSelection(
140
editor: LexicalEditor,
141
onReposition?: (node: Array<HTMLElement>) => void
142
): () => void;
143
```
144
145
**Usage Examples:**
146
147
```typescript
148
import { markSelection } from "@lexical/utils";
149
150
// Basic selection marking
151
const removeSelectionMark = markSelection(editor);
152
153
// Custom repositioning handler
154
const removeSelectionMark = markSelection(editor, (domNodes) => {
155
domNodes.forEach(node => {
156
// Custom styling for selection markers
157
node.style.backgroundColor = 'rgba(0, 123, 255, 0.3)';
158
node.style.border = '2px solid #007bff';
159
node.style.borderRadius = '3px';
160
});
161
});
162
163
// Focus management with selection marking
164
class FocusManager {
165
private editor: LexicalEditor;
166
private removeSelectionMark: (() => void) | null = null;
167
168
constructor(editor: LexicalEditor) {
169
this.editor = editor;
170
this.setupFocusHandling();
171
}
172
173
private setupFocusHandling() {
174
const rootElement = this.editor.getRootElement();
175
if (!rootElement) return;
176
177
rootElement.addEventListener('blur', () => {
178
// Mark selection when editor loses focus
179
this.removeSelectionMark = markSelection(this.editor, (nodes) => {
180
nodes.forEach(node => {
181
node.style.backgroundColor = 'rgba(255, 235, 59, 0.3)';
182
node.classList.add('selection-marker');
183
});
184
});
185
});
186
187
rootElement.addEventListener('focus', () => {
188
// Remove selection marks when editor regains focus
189
if (this.removeSelectionMark) {
190
this.removeSelectionMark();
191
this.removeSelectionMark = null;
192
}
193
});
194
}
195
196
destroy() {
197
if (this.removeSelectionMark) {
198
this.removeSelectionMark();
199
}
200
}
201
}
202
203
// Integration with modal dialogs
204
function showModalWithSelectionPreserved(editor: LexicalEditor) {
205
// Mark selection before showing modal
206
const removeSelectionMark = markSelection(editor);
207
208
const modal = document.createElement('div');
209
modal.className = 'modal';
210
211
// Modal close handler
212
const closeModal = () => {
213
removeSelectionMark(); // Clean up selection markers
214
modal.remove();
215
};
216
217
modal.addEventListener('click', (e) => {
218
if (e.target === modal) {
219
closeModal();
220
}
221
});
222
223
document.body.appendChild(modal);
224
}
225
```
226
227
### DOM Node Positioning on Range
228
229
Positions DOM nodes at a Range's location with automatic repositioning when the DOM changes, useful for highlighting and overlays.
230
231
```typescript { .api }
232
/**
233
* Place one or multiple newly created Nodes at the passed Range's position.
234
* Multiple nodes will only be created when the Range spans multiple lines (aka
235
* client rects).
236
*
237
* This function can come particularly useful to highlight particular parts of
238
* the text without interfering with the EditorState, that will often replicate
239
* the state across collab and clipboard.
240
*
241
* This function accounts for DOM updates which can modify the passed Range.
242
* Hence, the function return to remove the listener.
243
*/
244
function positionNodeOnRange(
245
editor: LexicalEditor,
246
range: Range,
247
onReposition: (node: Array<HTMLElement>) => void
248
): () => void;
249
```
250
251
**Usage Examples:**
252
253
```typescript
254
import { positionNodeOnRange } from "@lexical/utils";
255
256
// Basic text highlighting
257
function highlightRange(editor: LexicalEditor, range: Range) {
258
const removeHighlight = positionNodeOnRange(editor, range, (nodes) => {
259
nodes.forEach(node => {
260
node.style.backgroundColor = 'yellow';
261
node.style.opacity = '0.5';
262
node.classList.add('highlight');
263
});
264
});
265
266
// Return cleanup function
267
return removeHighlight;
268
}
269
270
// Comment annotation system
271
class CommentSystem {
272
private activeComments = new Map<string, () => void>();
273
274
addComment(editor: LexicalEditor, range: Range, commentId: string, text: string) {
275
const removePositioning = positionNodeOnRange(editor, range, (nodes) => {
276
nodes.forEach(node => {
277
node.style.backgroundColor = 'rgba(255, 193, 7, 0.3)';
278
node.style.borderBottom = '2px solid #ffc107';
279
node.style.cursor = 'pointer';
280
node.title = text;
281
node.dataset.commentId = commentId;
282
283
// Click handler to show comment
284
node.addEventListener('click', () => this.showComment(commentId));
285
});
286
});
287
288
this.activeComments.set(commentId, removePositioning);
289
}
290
291
removeComment(commentId: string) {
292
const removePositioning = this.activeComments.get(commentId);
293
if (removePositioning) {
294
removePositioning();
295
this.activeComments.delete(commentId);
296
}
297
}
298
299
clearAllComments() {
300
this.activeComments.forEach(removePositioning => removePositioning());
301
this.activeComments.clear();
302
}
303
304
private showComment(commentId: string) {
305
// Show comment UI
306
console.log(`Show comment ${commentId}`);
307
}
308
}
309
310
// Search result highlighting
311
class SearchHighlighter {
312
private highlights: Array<() => void> = [];
313
314
highlightSearchResults(editor: LexicalEditor, searchTerm: string) {
315
this.clearHighlights();
316
317
const rootElement = editor.getRootElement();
318
if (!rootElement) return;
319
320
const walker = document.createTreeWalker(
321
rootElement,
322
NodeFilter.SHOW_TEXT
323
);
324
325
const ranges: Range[] = [];
326
let textNode: Text | null;
327
328
// Find all text nodes containing search term
329
while (textNode = walker.nextNode() as Text) {
330
const text = textNode.textContent || '';
331
const index = text.toLowerCase().indexOf(searchTerm.toLowerCase());
332
333
if (index !== -1) {
334
const range = document.createRange();
335
range.setStart(textNode, index);
336
range.setEnd(textNode, index + searchTerm.length);
337
ranges.push(range);
338
}
339
}
340
341
// Highlight each range
342
ranges.forEach((range, index) => {
343
const removeHighlight = positionNodeOnRange(editor, range, (nodes) => {
344
nodes.forEach(node => {
345
node.style.backgroundColor = '#ffeb3b';
346
node.style.color = '#000';
347
node.style.fontWeight = 'bold';
348
node.classList.add('search-highlight');
349
node.dataset.searchIndex = index.toString();
350
});
351
});
352
353
this.highlights.push(removeHighlight);
354
});
355
}
356
357
clearHighlights() {
358
this.highlights.forEach(removeHighlight => removeHighlight());
359
this.highlights = [];
360
}
361
}
362
363
// Spell check underlines
364
function addSpellCheckUnderline(editor: LexicalEditor, range: Range, suggestions: string[]) {
365
return positionNodeOnRange(editor, range, (nodes) => {
366
nodes.forEach(node => {
367
node.style.borderBottom = '2px wavy red';
368
node.style.cursor = 'pointer';
369
node.title = `Suggestions: ${suggestions.join(', ')}`;
370
371
// Right-click context menu
372
node.addEventListener('contextmenu', (e) => {
373
e.preventDefault();
374
showSpellCheckMenu(e.clientX, e.clientY, suggestions);
375
});
376
});
377
});
378
}
379
```
380
381
### Selection Always on Display
382
383
Maintains visible selection when the editor loses focus by automatically switching to selection marking.
384
385
```typescript { .api }
386
/**
387
* Maintains visible selection display even when editor loses focus
388
*/
389
function selectionAlwaysOnDisplay(
390
editor: LexicalEditor
391
): () => void;
392
```
393
394
**Usage Examples:**
395
396
```typescript
397
import { selectionAlwaysOnDisplay } from "@lexical/utils";
398
399
// Basic usage - always show selection
400
const removeAlwaysDisplay = selectionAlwaysOnDisplay(editor);
401
402
// Clean up when component unmounts
403
useEffect(() => {
404
const cleanup = selectionAlwaysOnDisplay(editor);
405
return cleanup;
406
}, [editor]);
407
408
// Conditional selection display
409
class EditorManager {
410
private removeSelectionDisplay: (() => void) | null = null;
411
412
constructor(private editor: LexicalEditor) {}
413
414
enablePersistentSelection() {
415
if (!this.removeSelectionDisplay) {
416
this.removeSelectionDisplay = selectionAlwaysOnDisplay(this.editor);
417
}
418
}
419
420
disablePersistentSelection() {
421
if (this.removeSelectionDisplay) {
422
this.removeSelectionDisplay();
423
this.removeSelectionDisplay = null;
424
}
425
}
426
427
destroy() {
428
this.disablePersistentSelection();
429
}
430
}
431
432
// Multi-editor setup with selective persistent selection
433
function setupMultipleEditors(editors: LexicalEditor[], persistentSelectionIndex: number) {
434
const cleanupFunctions: Array<() => void> = [];
435
436
editors.forEach((editor, index) => {
437
if (index === persistentSelectionIndex) {
438
// Only one editor maintains persistent selection
439
cleanupFunctions.push(selectionAlwaysOnDisplay(editor));
440
}
441
442
// Other setup for each editor
443
cleanupFunctions.push(
444
editor.registerUpdateListener(handleUpdate),
445
editor.registerCommand('FOCUS', () => {
446
// Switch persistent selection to focused editor
447
// Implementation would switch which editor has persistent selection
448
})
449
);
450
});
451
452
return mergeRegister(...cleanupFunctions);
453
}
454
```
455
456
457
## Integration Patterns
458
459
These specialized utilities are often used together in complex editor scenarios:
460
461
```typescript
462
import {
463
mergeRegister,
464
markSelection,
465
positionNodeOnRange,
466
selectionAlwaysOnDisplay
467
} from "@lexical/utils";
468
469
// Complete rich text editor setup
470
function setupAdvancedEditor(editor: LexicalEditor) {
471
const registrations: Array<() => void> = [];
472
473
// Persistent selection display
474
registrations.push(selectionAlwaysOnDisplay(editor));
475
476
// Selection-based tools
477
registrations.push(
478
editor.registerCommand('SHOW_TOOLTIP', (payload) => {
479
const selection = $getSelection();
480
if ($isRangeSelection(selection)) {
481
const range = createDOMRange(selection);
482
const removeTooltip = positionNodeOnRange(editor, range, (nodes) => {
483
const tooltip = document.createElement('div');
484
tooltip.textContent = payload.text;
485
tooltip.style.backgroundColor = '#333';
486
tooltip.style.color = 'white';
487
tooltip.style.padding = '8px';
488
tooltip.style.borderRadius = '4px';
489
tooltip.style.position = 'absolute';
490
tooltip.style.zIndex = '1000';
491
492
nodes[0]?.appendChild(tooltip);
493
});
494
495
// Auto-remove after delay
496
setTimeout(removeTooltip, 3000);
497
}
498
return true;
499
})
500
);
501
502
// Other editor features...
503
504
return mergeRegister(...registrations);
505
}
506
```