]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/history/index.ts
Opensearch: Fixed XML declaration when php short tags enabled
[bookstack] / resources / js / wysiwyg / lexical / history / index.ts
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 {EditorState, LexicalEditor, LexicalNode, NodeKey} from 'lexical';
10
11 import {mergeRegister} from '@lexical/utils';
12 import {
13   $isRangeSelection,
14   $isRootNode,
15   $isTextNode,
16   CAN_REDO_COMMAND,
17   CAN_UNDO_COMMAND,
18   CLEAR_EDITOR_COMMAND,
19   CLEAR_HISTORY_COMMAND,
20   COMMAND_PRIORITY_EDITOR,
21   REDO_COMMAND,
22   UNDO_COMMAND,
23 } from 'lexical';
24
25 type MergeAction = 0 | 1 | 2;
26 const HISTORY_MERGE = 0;
27 const HISTORY_PUSH = 1;
28 const DISCARD_HISTORY_CANDIDATE = 2;
29
30 type ChangeType = 0 | 1 | 2 | 3 | 4;
31 const OTHER = 0;
32 const COMPOSING_CHARACTER = 1;
33 const INSERT_CHARACTER_AFTER_SELECTION = 2;
34 const DELETE_CHARACTER_BEFORE_SELECTION = 3;
35 const DELETE_CHARACTER_AFTER_SELECTION = 4;
36
37 export type HistoryStateEntry = {
38   editor: LexicalEditor;
39   editorState: EditorState;
40 };
41 export type HistoryState = {
42   current: null | HistoryStateEntry;
43   redoStack: Array<HistoryStateEntry>;
44   undoStack: Array<HistoryStateEntry>;
45 };
46
47 type IntentionallyMarkedAsDirtyElement = boolean;
48
49 function getDirtyNodes(
50   editorState: EditorState,
51   dirtyLeaves: Set<NodeKey>,
52   dirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>,
53 ): Array<LexicalNode> {
54   const nodeMap = editorState._nodeMap;
55   const nodes = [];
56
57   for (const dirtyLeafKey of dirtyLeaves) {
58     const dirtyLeaf = nodeMap.get(dirtyLeafKey);
59
60     if (dirtyLeaf !== undefined) {
61       nodes.push(dirtyLeaf);
62     }
63   }
64
65   for (const [dirtyElementKey, intentionallyMarkedAsDirty] of dirtyElements) {
66     if (!intentionallyMarkedAsDirty) {
67       continue;
68     }
69
70     const dirtyElement = nodeMap.get(dirtyElementKey);
71
72     if (dirtyElement !== undefined && !$isRootNode(dirtyElement)) {
73       nodes.push(dirtyElement);
74     }
75   }
76
77   return nodes;
78 }
79
80 function getChangeType(
81   prevEditorState: null | EditorState,
82   nextEditorState: EditorState,
83   dirtyLeavesSet: Set<NodeKey>,
84   dirtyElementsSet: Map<NodeKey, IntentionallyMarkedAsDirtyElement>,
85   isComposing: boolean,
86 ): ChangeType {
87   if (
88     prevEditorState === null ||
89     (dirtyLeavesSet.size === 0 && dirtyElementsSet.size === 0 && !isComposing)
90   ) {
91     return OTHER;
92   }
93
94   const nextSelection = nextEditorState._selection;
95   const prevSelection = prevEditorState._selection;
96
97   if (isComposing) {
98     return COMPOSING_CHARACTER;
99   }
100
101   if (
102     !$isRangeSelection(nextSelection) ||
103     !$isRangeSelection(prevSelection) ||
104     !prevSelection.isCollapsed() ||
105     !nextSelection.isCollapsed()
106   ) {
107     return OTHER;
108   }
109
110   const dirtyNodes = getDirtyNodes(
111     nextEditorState,
112     dirtyLeavesSet,
113     dirtyElementsSet,
114   );
115
116   if (dirtyNodes.length === 0) {
117     return OTHER;
118   }
119
120   // Catching the case when inserting new text node into an element (e.g. first char in paragraph/list),
121   // or after existing node.
122   if (dirtyNodes.length > 1) {
123     const nextNodeMap = nextEditorState._nodeMap;
124     const nextAnchorNode = nextNodeMap.get(nextSelection.anchor.key);
125     const prevAnchorNode = nextNodeMap.get(prevSelection.anchor.key);
126
127     if (
128       nextAnchorNode &&
129       prevAnchorNode &&
130       !prevEditorState._nodeMap.has(nextAnchorNode.__key) &&
131       $isTextNode(nextAnchorNode) &&
132       nextAnchorNode.__text.length === 1 &&
133       nextSelection.anchor.offset === 1
134     ) {
135       return INSERT_CHARACTER_AFTER_SELECTION;
136     }
137
138     return OTHER;
139   }
140
141   const nextDirtyNode = dirtyNodes[0];
142
143   const prevDirtyNode = prevEditorState._nodeMap.get(nextDirtyNode.__key);
144
145   if (
146     !$isTextNode(prevDirtyNode) ||
147     !$isTextNode(nextDirtyNode) ||
148     prevDirtyNode.__mode !== nextDirtyNode.__mode
149   ) {
150     return OTHER;
151   }
152
153   const prevText = prevDirtyNode.__text;
154   const nextText = nextDirtyNode.__text;
155
156   if (prevText === nextText) {
157     return OTHER;
158   }
159
160   const nextAnchor = nextSelection.anchor;
161   const prevAnchor = prevSelection.anchor;
162
163   if (nextAnchor.key !== prevAnchor.key || nextAnchor.type !== 'text') {
164     return OTHER;
165   }
166
167   const nextAnchorOffset = nextAnchor.offset;
168   const prevAnchorOffset = prevAnchor.offset;
169   const textDiff = nextText.length - prevText.length;
170
171   if (textDiff === 1 && prevAnchorOffset === nextAnchorOffset - 1) {
172     return INSERT_CHARACTER_AFTER_SELECTION;
173   }
174
175   if (textDiff === -1 && prevAnchorOffset === nextAnchorOffset + 1) {
176     return DELETE_CHARACTER_BEFORE_SELECTION;
177   }
178
179   if (textDiff === -1 && prevAnchorOffset === nextAnchorOffset) {
180     return DELETE_CHARACTER_AFTER_SELECTION;
181   }
182
183   return OTHER;
184 }
185
186 function isTextNodeUnchanged(
187   key: NodeKey,
188   prevEditorState: EditorState,
189   nextEditorState: EditorState,
190 ): boolean {
191   const prevNode = prevEditorState._nodeMap.get(key);
192   const nextNode = nextEditorState._nodeMap.get(key);
193
194   const prevSelection = prevEditorState._selection;
195   const nextSelection = nextEditorState._selection;
196   const isDeletingLine =
197     $isRangeSelection(prevSelection) &&
198     $isRangeSelection(nextSelection) &&
199     prevSelection.anchor.type === 'element' &&
200     prevSelection.focus.type === 'element' &&
201     nextSelection.anchor.type === 'text' &&
202     nextSelection.focus.type === 'text';
203
204   if (
205     !isDeletingLine &&
206     $isTextNode(prevNode) &&
207     $isTextNode(nextNode) &&
208     prevNode.__parent === nextNode.__parent
209   ) {
210     // This has the assumption that object key order won't change if the
211     // content did not change, which should normally be safe given
212     // the manner in which nodes and exportJSON are typically implemented.
213     return (
214       JSON.stringify(prevEditorState.read(() => prevNode.exportJSON())) ===
215       JSON.stringify(nextEditorState.read(() => nextNode.exportJSON()))
216     );
217   }
218   return false;
219 }
220
221 function createMergeActionGetter(
222   editor: LexicalEditor,
223   delay: number,
224 ): (
225   prevEditorState: null | EditorState,
226   nextEditorState: EditorState,
227   currentHistoryEntry: null | HistoryStateEntry,
228   dirtyLeaves: Set<NodeKey>,
229   dirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>,
230   tags: Set<string>,
231 ) => MergeAction {
232   let prevChangeTime = Date.now();
233   let prevChangeType = OTHER;
234
235   return (
236     prevEditorState,
237     nextEditorState,
238     currentHistoryEntry,
239     dirtyLeaves,
240     dirtyElements,
241     tags,
242   ) => {
243     const changeTime = Date.now();
244
245     // If applying changes from history stack there's no need
246     // to run history logic again, as history entries already calculated
247     if (tags.has('historic')) {
248       prevChangeType = OTHER;
249       prevChangeTime = changeTime;
250       return DISCARD_HISTORY_CANDIDATE;
251     }
252
253     const changeType = getChangeType(
254       prevEditorState,
255       nextEditorState,
256       dirtyLeaves,
257       dirtyElements,
258       editor.isComposing(),
259     );
260
261     const mergeAction = (() => {
262       const isSameEditor =
263         currentHistoryEntry === null || currentHistoryEntry.editor === editor;
264       const shouldPushHistory = tags.has('history-push');
265       const shouldMergeHistory =
266         !shouldPushHistory && isSameEditor && tags.has('history-merge');
267
268       if (shouldMergeHistory) {
269         return HISTORY_MERGE;
270       }
271
272       if (prevEditorState === null) {
273         return HISTORY_PUSH;
274       }
275
276       const selection = nextEditorState._selection;
277       const hasDirtyNodes = dirtyLeaves.size > 0 || dirtyElements.size > 0;
278
279       if (!hasDirtyNodes) {
280         if (selection !== null) {
281           return HISTORY_MERGE;
282         }
283
284         return DISCARD_HISTORY_CANDIDATE;
285       }
286
287       if (
288         shouldPushHistory === false &&
289         changeType !== OTHER &&
290         changeType === prevChangeType &&
291         changeTime < prevChangeTime + delay &&
292         isSameEditor
293       ) {
294         return HISTORY_MERGE;
295       }
296
297       // A single node might have been marked as dirty, but not have changed
298       // due to some node transform reverting the change.
299       if (dirtyLeaves.size === 1) {
300         const dirtyLeafKey = Array.from(dirtyLeaves)[0];
301         if (
302           isTextNodeUnchanged(dirtyLeafKey, prevEditorState, nextEditorState)
303         ) {
304           return HISTORY_MERGE;
305         }
306       }
307
308       return HISTORY_PUSH;
309     })();
310
311     prevChangeTime = changeTime;
312     prevChangeType = changeType;
313
314     return mergeAction;
315   };
316 }
317
318 function redo(editor: LexicalEditor, historyState: HistoryState): void {
319   const redoStack = historyState.redoStack;
320   const undoStack = historyState.undoStack;
321
322   if (redoStack.length !== 0) {
323     const current = historyState.current;
324
325     if (current !== null) {
326       undoStack.push(current);
327       editor.dispatchCommand(CAN_UNDO_COMMAND, true);
328     }
329
330     const historyStateEntry = redoStack.pop();
331
332     if (redoStack.length === 0) {
333       editor.dispatchCommand(CAN_REDO_COMMAND, false);
334     }
335
336     historyState.current = historyStateEntry || null;
337
338     if (historyStateEntry) {
339       historyStateEntry.editor.setEditorState(historyStateEntry.editorState, {
340         tag: 'historic',
341       });
342     }
343   }
344 }
345
346 function undo(editor: LexicalEditor, historyState: HistoryState): void {
347   const redoStack = historyState.redoStack;
348   const undoStack = historyState.undoStack;
349   const undoStackLength = undoStack.length;
350
351   if (undoStackLength !== 0) {
352     const current = historyState.current;
353     const historyStateEntry = undoStack.pop();
354
355     if (current !== null) {
356       redoStack.push(current);
357       editor.dispatchCommand(CAN_REDO_COMMAND, true);
358     }
359
360     if (undoStack.length === 0) {
361       editor.dispatchCommand(CAN_UNDO_COMMAND, false);
362     }
363
364     historyState.current = historyStateEntry || null;
365
366     if (historyStateEntry) {
367       historyStateEntry.editor.setEditorState(historyStateEntry.editorState, {
368         tag: 'historic',
369       });
370     }
371   }
372 }
373
374 function clearHistory(historyState: HistoryState) {
375   historyState.undoStack = [];
376   historyState.redoStack = [];
377   historyState.current = null;
378 }
379
380 /**
381  * Registers necessary listeners to manage undo/redo history stack and related editor commands.
382  * It returns `unregister` callback that cleans up all listeners and should be called on editor unmount.
383  * @param editor - The lexical editor.
384  * @param historyState - The history state, containing the current state and the undo/redo stack.
385  * @param delay - The time (in milliseconds) the editor should delay generating a new history stack,
386  * instead of merging the current changes with the current stack.
387  * @returns The listeners cleanup callback function.
388  */
389 export function registerHistory(
390   editor: LexicalEditor,
391   historyState: HistoryState,
392   delay: number,
393 ): () => void {
394   const getMergeAction = createMergeActionGetter(editor, delay);
395
396   const applyChange = ({
397     editorState,
398     prevEditorState,
399     dirtyLeaves,
400     dirtyElements,
401     tags,
402   }: {
403     editorState: EditorState;
404     prevEditorState: EditorState;
405     dirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>;
406     dirtyLeaves: Set<NodeKey>;
407     tags: Set<string>;
408   }): void => {
409     const current = historyState.current;
410     const redoStack = historyState.redoStack;
411     const undoStack = historyState.undoStack;
412     const currentEditorState = current === null ? null : current.editorState;
413
414     if (current !== null && editorState === currentEditorState) {
415       return;
416     }
417
418     const mergeAction = getMergeAction(
419       prevEditorState,
420       editorState,
421       current,
422       dirtyLeaves,
423       dirtyElements,
424       tags,
425     );
426
427     if (mergeAction === HISTORY_PUSH) {
428       if (redoStack.length !== 0) {
429         historyState.redoStack = [];
430         editor.dispatchCommand(CAN_REDO_COMMAND, false);
431       }
432
433       if (current !== null) {
434         undoStack.push({
435           ...current,
436         });
437         editor.dispatchCommand(CAN_UNDO_COMMAND, true);
438       }
439     } else if (mergeAction === DISCARD_HISTORY_CANDIDATE) {
440       return;
441     }
442
443     // Else we merge
444     historyState.current = {
445       editor,
446       editorState,
447     };
448   };
449
450   const unregister = mergeRegister(
451     editor.registerCommand(
452       UNDO_COMMAND,
453       () => {
454         undo(editor, historyState);
455         return true;
456       },
457       COMMAND_PRIORITY_EDITOR,
458     ),
459     editor.registerCommand(
460       REDO_COMMAND,
461       () => {
462         redo(editor, historyState);
463         return true;
464       },
465       COMMAND_PRIORITY_EDITOR,
466     ),
467     editor.registerCommand(
468       CLEAR_EDITOR_COMMAND,
469       () => {
470         clearHistory(historyState);
471         return false;
472       },
473       COMMAND_PRIORITY_EDITOR,
474     ),
475     editor.registerCommand(
476       CLEAR_HISTORY_COMMAND,
477       () => {
478         clearHistory(historyState);
479         editor.dispatchCommand(CAN_REDO_COMMAND, false);
480         editor.dispatchCommand(CAN_UNDO_COMMAND, false);
481         return true;
482       },
483       COMMAND_PRIORITY_EDITOR,
484     ),
485     editor.registerUpdateListener(applyChange),
486   );
487
488   return unregister;
489 }
490
491 /**
492  * Creates an empty history state.
493  * @returns - The empty history state, as an object.
494  */
495 export function createEmptyHistoryState(): HistoryState {
496   return {
497     current: null,
498     redoStack: [],
499     undoStack: [],
500   };
501 }