@lexical/history 0.37.1-nightly.20251027.0 → 0.38.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/LexicalHistory.js CHANGED
@@ -6,6 +6,6 @@
6
6
  *
7
7
  */
8
8
 
9
- 'use strict'
10
- const LexicalHistory = process.env.NODE_ENV !== 'production' ? require('./LexicalHistory.dev.js') : require('./LexicalHistory.prod.js');
11
- module.exports = LexicalHistory;
9
+ 'use strict';
10
+
11
+ module.exports = require('./dist/LexicalHistory.js');
package/package.json CHANGED
@@ -8,13 +8,13 @@
8
8
  "history"
9
9
  ],
10
10
  "license": "MIT",
11
- "version": "0.37.1-nightly.20251027.0",
11
+ "version": "0.38.0",
12
12
  "main": "LexicalHistory.js",
13
13
  "types": "index.d.ts",
14
14
  "dependencies": {
15
- "@lexical/extension": "0.37.1-nightly.20251027.0",
16
- "@lexical/utils": "0.37.1-nightly.20251027.0",
17
- "lexical": "0.37.1-nightly.20251027.0"
15
+ "@lexical/extension": "0.38.0",
16
+ "@lexical/utils": "0.38.0",
17
+ "lexical": "0.38.0"
18
18
  },
19
19
  "repository": {
20
20
  "type": "git",
@@ -0,0 +1,452 @@
1
+ /**
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ */
8
+
9
+ import type {JSX} from 'react';
10
+
11
+ import {createEmptyHistoryState, registerHistory} from '@lexical/history';
12
+ import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
13
+ import {ContentEditable} from '@lexical/react/LexicalContentEditable';
14
+ import {LexicalErrorBoundary} from '@lexical/react/LexicalErrorBoundary';
15
+ import {HistoryPlugin} from '@lexical/react/LexicalHistoryPlugin';
16
+ import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin';
17
+ import {$createQuoteNode} from '@lexical/rich-text';
18
+ import {$setBlocksType} from '@lexical/selection';
19
+ import {$restoreEditorState} from '@lexical/utils';
20
+ import {
21
+ $applyNodeReplacement,
22
+ $createNodeSelection,
23
+ $createParagraphNode,
24
+ $createRangeSelection,
25
+ $createTextNode,
26
+ $getRoot,
27
+ $isNodeSelection,
28
+ $setSelection,
29
+ CAN_REDO_COMMAND,
30
+ CAN_UNDO_COMMAND,
31
+ CLEAR_HISTORY_COMMAND,
32
+ COMMAND_PRIORITY_CRITICAL,
33
+ HISTORY_MERGE_TAG,
34
+ type KlassConstructor,
35
+ LexicalEditor,
36
+ LexicalNode,
37
+ type NodeKey,
38
+ REDO_COMMAND,
39
+ SerializedElementNode,
40
+ type SerializedTextNode,
41
+ type Spread,
42
+ TextNode,
43
+ UNDO_COMMAND,
44
+ } from 'lexical';
45
+ import {createTestEditor, TestComposer} from 'lexical/src/__tests__/utils';
46
+ import {createRoot, Root} from 'react-dom/client';
47
+ import * as ReactTestUtils from 'shared/react-test-utils';
48
+ import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest';
49
+
50
+ type SerializedCustomTextNode = Spread<
51
+ {type: string; classes: string[]},
52
+ SerializedTextNode
53
+ >;
54
+
55
+ class CustomTextNode extends TextNode {
56
+ /** @internal */
57
+ declare ['constructor']: KlassConstructor<typeof CustomTextNode>;
58
+
59
+ __classes: Set<string>;
60
+ constructor(text: string, classes: Iterable<string>, key?: NodeKey) {
61
+ super(text, key);
62
+ this.__classes = new Set(classes);
63
+ }
64
+ static getType(): 'custom-text' {
65
+ return 'custom-text';
66
+ }
67
+ static clone(node: CustomTextNode): CustomTextNode {
68
+ return new CustomTextNode(node.__text, node.__classes, node.__key);
69
+ }
70
+ addClass(className: string): this {
71
+ const self = this.getWritable();
72
+ self.__classes.add(className);
73
+ return self;
74
+ }
75
+ removeClass(className: string): this {
76
+ const self = this.getWritable();
77
+ self.__classes.delete(className);
78
+ return self;
79
+ }
80
+ setClasses(classes: Iterable<string>): this {
81
+ const self = this.getWritable();
82
+ self.__classes = new Set(classes);
83
+ return self;
84
+ }
85
+ getClasses(): ReadonlySet<string> {
86
+ return this.getLatest().__classes;
87
+ }
88
+ static importJSON({text, classes}: SerializedCustomTextNode): CustomTextNode {
89
+ return $createCustomTextNode(text, classes);
90
+ }
91
+ exportJSON(): SerializedCustomTextNode {
92
+ return {
93
+ ...super.exportJSON(),
94
+ classes: Array.from(this.getClasses()),
95
+ };
96
+ }
97
+ }
98
+ function $createCustomTextNode(
99
+ text: string,
100
+ classes: string[] = [],
101
+ ): CustomTextNode {
102
+ return $applyNodeReplacement(new CustomTextNode(text, classes));
103
+ }
104
+ function $isCustomTextNode(
105
+ node: LexicalNode | null | undefined,
106
+ ): node is CustomTextNode {
107
+ return node instanceof CustomTextNode;
108
+ }
109
+
110
+ describe('LexicalHistory tests', () => {
111
+ let container: HTMLDivElement | null = null;
112
+ let reactRoot: Root;
113
+
114
+ beforeEach(() => {
115
+ container = document.createElement('div');
116
+ reactRoot = createRoot(container);
117
+ document.body.appendChild(container);
118
+ });
119
+
120
+ afterEach(() => {
121
+ if (container !== null) {
122
+ document.body.removeChild(container);
123
+ }
124
+ container = null;
125
+
126
+ vi.restoreAllMocks();
127
+ });
128
+
129
+ // Shared instance across tests
130
+ let editor: LexicalEditor;
131
+
132
+ function TestPlugin() {
133
+ // Plugin used just to get our hands on the Editor object
134
+ [editor] = useLexicalComposerContext();
135
+ return null;
136
+ }
137
+ function Test(): JSX.Element {
138
+ return (
139
+ <TestComposer>
140
+ <RichTextPlugin
141
+ contentEditable={<ContentEditable />}
142
+ placeholder={
143
+ <div className="editor-placeholder">Enter some text...</div>
144
+ }
145
+ ErrorBoundary={LexicalErrorBoundary}
146
+ />
147
+ <TestPlugin />
148
+ <HistoryPlugin />
149
+ </TestComposer>
150
+ );
151
+ }
152
+
153
+ test('LexicalHistory after clearing', async () => {
154
+ let canRedo = true;
155
+ let canUndo = true;
156
+
157
+ ReactTestUtils.act(() => {
158
+ reactRoot.render(<Test key="smth" />);
159
+ });
160
+
161
+ editor.registerCommand<boolean>(
162
+ CAN_REDO_COMMAND,
163
+ (payload) => {
164
+ canRedo = payload;
165
+ return false;
166
+ },
167
+ COMMAND_PRIORITY_CRITICAL,
168
+ );
169
+
170
+ editor.registerCommand<boolean>(
171
+ CAN_UNDO_COMMAND,
172
+ (payload) => {
173
+ canUndo = payload;
174
+ return false;
175
+ },
176
+ COMMAND_PRIORITY_CRITICAL,
177
+ );
178
+
179
+ await Promise.resolve().then();
180
+
181
+ await ReactTestUtils.act(async () => {
182
+ editor.dispatchCommand(CLEAR_HISTORY_COMMAND, undefined);
183
+ });
184
+
185
+ expect(canRedo).toBe(false);
186
+ expect(canUndo).toBe(false);
187
+ });
188
+
189
+ test('LexicalHistory.Redo after Quote Node', async () => {
190
+ ReactTestUtils.act(() => {
191
+ reactRoot.render(<Test key="smth" />);
192
+ });
193
+
194
+ // Wait for update to complete
195
+ await Promise.resolve().then();
196
+
197
+ await ReactTestUtils.act(async () => {
198
+ await editor.update(() => {
199
+ const root = $getRoot();
200
+ const paragraph1 = $createParagraphNodeWithText('AAA');
201
+ const paragraph2 = $createParagraphNodeWithText('BBB');
202
+
203
+ // The editor has one child that is an empty
204
+ // paragraph Node.
205
+ root.getChildAtIndex(0)?.replace(paragraph1);
206
+ root.append(paragraph2);
207
+ });
208
+ });
209
+
210
+ const initialJSONState = editor.getEditorState().toJSON();
211
+
212
+ await ReactTestUtils.act(async () => {
213
+ await editor.update(() => {
214
+ const root = $getRoot();
215
+ const selection = $createRangeSelection();
216
+
217
+ const firstTextNode = root.getAllTextNodes()[0];
218
+ selection.anchor.set(firstTextNode.getKey(), 0, 'text');
219
+ selection.focus.set(firstTextNode.getKey(), 3, 'text');
220
+
221
+ $setSelection(selection);
222
+ $setBlocksType(selection, () => $createQuoteNode());
223
+ });
224
+ });
225
+
226
+ const afterQuoteInsertionJSONState = editor.getEditorState().toJSON();
227
+ expect(afterQuoteInsertionJSONState.root.children.length).toBe(2);
228
+ expect(afterQuoteInsertionJSONState.root.children[0].type).toBe('quote');
229
+
230
+ expect(
231
+ (afterQuoteInsertionJSONState.root.children as SerializedElementNode[])[0]
232
+ .children.length,
233
+ ).toBe(1);
234
+ expect(
235
+ (afterQuoteInsertionJSONState.root.children as SerializedElementNode[])[0]
236
+ .children[0].type,
237
+ ).toBe('text');
238
+ expect(
239
+ (
240
+ (
241
+ afterQuoteInsertionJSONState.root.children as SerializedElementNode[]
242
+ )[0].children[0] as SerializedTextNode
243
+ ).text,
244
+ ).toBe('AAA');
245
+
246
+ await ReactTestUtils.act(async () => {
247
+ await editor.update(() => {
248
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
249
+ });
250
+ });
251
+
252
+ expect(JSON.stringify(initialJSONState)).toBe(
253
+ JSON.stringify(editor.getEditorState().toJSON()),
254
+ );
255
+ });
256
+
257
+ test('LexicalHistory in sequence: change, undo, redo, undo, change', async () => {
258
+ let canRedo = false;
259
+ let canUndo = false;
260
+
261
+ ReactTestUtils.act(() => {
262
+ reactRoot.render(<Test key="smth" />);
263
+ });
264
+
265
+ editor.registerCommand<boolean>(
266
+ CAN_REDO_COMMAND,
267
+ (payload) => {
268
+ canRedo = payload;
269
+ return false;
270
+ },
271
+ COMMAND_PRIORITY_CRITICAL,
272
+ );
273
+
274
+ editor.registerCommand<boolean>(
275
+ CAN_UNDO_COMMAND,
276
+ (payload) => {
277
+ canUndo = payload;
278
+ return false;
279
+ },
280
+ COMMAND_PRIORITY_CRITICAL,
281
+ );
282
+
283
+ // focus (needs the focus to initialize)
284
+ await ReactTestUtils.act(async () => {
285
+ editor.focus();
286
+ });
287
+
288
+ expect(canRedo).toBe(false);
289
+ expect(canUndo).toBe(false);
290
+
291
+ // change
292
+ await ReactTestUtils.act(async () => {
293
+ await editor.update(() => {
294
+ const root = $getRoot();
295
+ const paragraph = $createParagraphNodeWithText('foo');
296
+ root.append(paragraph);
297
+ });
298
+ });
299
+ expect(canRedo).toBe(false);
300
+ expect(canUndo).toBe(true);
301
+
302
+ // undo
303
+ await ReactTestUtils.act(async () => {
304
+ await editor.update(() => {
305
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
306
+ });
307
+ });
308
+ expect(canRedo).toBe(true);
309
+ expect(canUndo).toBe(false);
310
+
311
+ // redo
312
+ await ReactTestUtils.act(async () => {
313
+ await editor.update(() => {
314
+ editor.dispatchCommand(REDO_COMMAND, undefined);
315
+ });
316
+ });
317
+ expect(canRedo).toBe(false);
318
+ expect(canUndo).toBe(true);
319
+
320
+ // undo
321
+ await ReactTestUtils.act(async () => {
322
+ await editor.update(() => {
323
+ editor.dispatchCommand(UNDO_COMMAND, undefined);
324
+ });
325
+ });
326
+ expect(canRedo).toBe(true);
327
+ expect(canUndo).toBe(false);
328
+
329
+ // change
330
+ await ReactTestUtils.act(async () => {
331
+ await editor.update(() => {
332
+ const root = $getRoot();
333
+ const paragraph = $createParagraphNodeWithText('foo');
334
+ root.append(paragraph);
335
+ });
336
+ });
337
+
338
+ expect(canRedo).toBe(false);
339
+ expect(canUndo).toBe(true);
340
+ });
341
+
342
+ test('undoStack selection points to the same editor', async () => {
343
+ const editor_ = createTestEditor({namespace: 'parent'});
344
+ const sharedHistory = createEmptyHistoryState();
345
+ registerHistory(editor_, sharedHistory, 1000);
346
+ await editor_.update(() => {
347
+ const root = $getRoot();
348
+ const paragraph = $createParagraphNode();
349
+ root.append(paragraph);
350
+ });
351
+ await editor_.update(() => {
352
+ const root = $getRoot();
353
+ const paragraph = $createParagraphNode();
354
+ root.append(paragraph);
355
+ const nodeSelection = $createNodeSelection();
356
+ nodeSelection.add(paragraph.getKey());
357
+ $setSelection(nodeSelection);
358
+ });
359
+ const nestedEditor = createTestEditor({namespace: 'nested'});
360
+ await nestedEditor.update(
361
+ () => {
362
+ const root = $getRoot();
363
+ const paragraph = $createParagraphNode();
364
+ root.append(paragraph);
365
+ paragraph.selectEnd();
366
+ },
367
+ {
368
+ tag: HISTORY_MERGE_TAG,
369
+ },
370
+ );
371
+ nestedEditor._parentEditor = editor_;
372
+ registerHistory(nestedEditor, sharedHistory, 1000);
373
+
374
+ await nestedEditor.update(() => {
375
+ const root = $getRoot();
376
+ const paragraph = $createParagraphNode();
377
+ root.append(paragraph);
378
+ paragraph.selectEnd();
379
+ });
380
+
381
+ expect(sharedHistory.undoStack.length).toBe(2);
382
+ await editor_.dispatchCommand(UNDO_COMMAND, undefined);
383
+ expect($isNodeSelection(editor_.getEditorState()._selection)).toBe(true);
384
+ });
385
+
386
+ test('Changes to TextNode leaf are detected properly #6409', async () => {
387
+ editor = createTestEditor({
388
+ nodes: [CustomTextNode],
389
+ });
390
+ const sharedHistory = createEmptyHistoryState();
391
+ registerHistory(editor, sharedHistory, 0);
392
+ editor.update(
393
+ () => {
394
+ $getRoot()
395
+ .clear()
396
+ .append(
397
+ $createParagraphNode().append(
398
+ $createCustomTextNode('Initial text'),
399
+ ),
400
+ );
401
+ },
402
+ {discrete: true},
403
+ );
404
+ expect(sharedHistory.undoStack).toHaveLength(0);
405
+
406
+ editor.update(
407
+ () => {
408
+ // Mark dirty with no changes
409
+ for (const node of $getRoot().getAllTextNodes()) {
410
+ node.getWritable();
411
+ }
412
+ // Restore the editor state and ensure the history did not change
413
+ $restoreEditorState(editor, editor.getEditorState());
414
+ },
415
+ {discrete: true},
416
+ );
417
+ expect(sharedHistory.undoStack).toHaveLength(0);
418
+ editor.update(
419
+ () => {
420
+ // Mark dirty with text change
421
+ for (const node of $getRoot().getAllTextNodes()) {
422
+ if ($isCustomTextNode(node)) {
423
+ node.setTextContent(node.getTextContent() + '!');
424
+ }
425
+ }
426
+ },
427
+ {discrete: true},
428
+ );
429
+ expect(sharedHistory.undoStack).toHaveLength(1);
430
+
431
+ editor.update(
432
+ () => {
433
+ // Mark dirty with only a change to the class
434
+ for (const node of $getRoot().getAllTextNodes()) {
435
+ if ($isCustomTextNode(node)) {
436
+ node.addClass('updated');
437
+ }
438
+ }
439
+ },
440
+ {discrete: true},
441
+ );
442
+ expect(sharedHistory.undoStack).toHaveLength(2);
443
+ });
444
+ });
445
+
446
+ const $createParagraphNodeWithText = (text: string) => {
447
+ const paragraph = $createParagraphNode();
448
+ const textNode = $createTextNode(text);
449
+
450
+ paragraph.append(textNode);
451
+ return paragraph;
452
+ };