or run

npx @tessl/cli init
Log in

Version

Tile

Overview

Evals

Files

Files

docs

dom-manipulation.mdeditor-state.mdfile-handling.mdindex.mdspecialized-utilities.mdtree-traversal.md
tile.json

specialized-utilities.mddocs/

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

```