]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts
b13bba6977e882a2fc7970687e9715779c027809
[bookstack] / resources / js / wysiwyg / lexical / core / __tests__ / utils / 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 {createHeadlessEditor} from '@lexical/headless';
10 import {AutoLinkNode, LinkNode} from '@lexical/link';
11 import {ListItemNode, ListNode} from '@lexical/list';
12
13 import {TableCellNode, TableNode, TableRowNode} from '@lexical/table';
14
15 import {
16   $getSelection,
17   $isRangeSelection,
18   createEditor,
19   DecoratorNode,
20   EditorState,
21   EditorThemeClasses,
22   ElementNode,
23   Klass,
24   LexicalEditor,
25   LexicalNode,
26   RangeSelection,
27   SerializedElementNode,
28   SerializedLexicalNode,
29   SerializedTextNode,
30   TextNode,
31 } from 'lexical';
32
33 import {CreateEditorArgs, HTMLConfig, LexicalNodeReplacement,} from '../../LexicalEditor';
34 import {resetRandomKey} from '../../LexicalUtils';
35 import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode";
36 import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode";
37 import {DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
38 import {EditorUiContext} from "../../../../ui/framework/core";
39 import {EditorUIManager} from "../../../../ui/framework/manager";
40 import {turtle} from "@codemirror/legacy-modes/mode/turtle";
41
42
43 type TestEnv = {
44   readonly container: HTMLDivElement;
45   readonly editor: LexicalEditor;
46   readonly outerHTML: string;
47   readonly innerHTML: string;
48 };
49
50 export function initializeUnitTest(
51   runTests: (testEnv: TestEnv) => void,
52   editorConfig: CreateEditorArgs = {namespace: 'test', theme: {}},
53 ) {
54   const testEnv = {
55     _container: null as HTMLDivElement | null,
56     _editor: null as LexicalEditor | null,
57     get container() {
58       if (!this._container) {
59         throw new Error('testEnv.container not initialized.');
60       }
61       return this._container;
62     },
63     set container(container) {
64       this._container = container;
65     },
66     get editor() {
67       if (!this._editor) {
68         throw new Error('testEnv.editor not initialized.');
69       }
70       return this._editor;
71     },
72     set editor(editor) {
73       this._editor = editor;
74     },
75     get innerHTML() {
76       return (this.container.firstChild as HTMLElement).innerHTML;
77     },
78     get outerHTML() {
79       return this.container.innerHTML;
80     },
81     reset() {
82       this._container = null;
83       this._editor = null;
84     },
85   };
86
87   beforeEach(async () => {
88     resetRandomKey();
89
90     testEnv.container = document.createElement('div');
91     document.body.appendChild(testEnv.container);
92
93     const editorEl = document.createElement('div');
94     editorEl.setAttribute('contenteditable', 'true');
95     testEnv.container.append(editorEl);
96
97     const lexicalEditor = createTestEditor(editorConfig);
98     lexicalEditor.setRootElement(editorEl);
99     testEnv.editor = lexicalEditor;
100   });
101
102   afterEach(() => {
103     document.body.removeChild(testEnv.container);
104     testEnv.reset();
105   });
106
107   runTests(testEnv);
108 }
109
110 export function initializeClipboard() {
111   Object.defineProperty(window, 'DragEvent', {
112     value: class DragEvent {},
113   });
114   Object.defineProperty(window, 'ClipboardEvent', {
115     value: class ClipboardEvent {},
116   });
117 }
118
119 export type SerializedTestElementNode = SerializedElementNode;
120
121 export class TestElementNode extends ElementNode {
122   static getType(): string {
123     return 'test_block';
124   }
125
126   static clone(node: TestElementNode) {
127     return new TestElementNode(node.__key);
128   }
129
130   static importJSON(
131     serializedNode: SerializedTestElementNode,
132   ): TestInlineElementNode {
133     const node = $createTestInlineElementNode();
134     node.setDirection(serializedNode.direction);
135     return node;
136   }
137
138   exportJSON(): SerializedTestElementNode {
139     return {
140       ...super.exportJSON(),
141       type: 'test_block',
142       version: 1,
143     };
144   }
145
146   createDOM() {
147     return document.createElement('div');
148   }
149
150   updateDOM() {
151     return false;
152   }
153 }
154
155 export function $createTestElementNode(): TestElementNode {
156   return new TestElementNode();
157 }
158
159 type SerializedTestTextNode = SerializedTextNode;
160
161 export class TestTextNode extends TextNode {
162   static getType() {
163     return 'test_text';
164   }
165
166   static clone(node: TestTextNode): TestTextNode {
167     return new TestTextNode(node.__text, node.__key);
168   }
169
170   static importJSON(serializedNode: SerializedTestTextNode): TestTextNode {
171     return new TestTextNode(serializedNode.text);
172   }
173
174   exportJSON(): SerializedTestTextNode {
175     return {
176       ...super.exportJSON(),
177       type: 'test_text',
178       version: 1,
179     };
180   }
181 }
182
183 export type SerializedTestInlineElementNode = SerializedElementNode;
184
185 export class TestInlineElementNode extends ElementNode {
186   static getType(): string {
187     return 'test_inline_block';
188   }
189
190   static clone(node: TestInlineElementNode) {
191     return new TestInlineElementNode(node.__key);
192   }
193
194   static importJSON(
195     serializedNode: SerializedTestInlineElementNode,
196   ): TestInlineElementNode {
197     const node = $createTestInlineElementNode();
198     node.setDirection(serializedNode.direction);
199     return node;
200   }
201
202   exportJSON(): SerializedTestInlineElementNode {
203     return {
204       ...super.exportJSON(),
205       type: 'test_inline_block',
206       version: 1,
207     };
208   }
209
210   createDOM() {
211     return document.createElement('a');
212   }
213
214   updateDOM() {
215     return false;
216   }
217
218   isInline() {
219     return true;
220   }
221 }
222
223 export function $createTestInlineElementNode(): TestInlineElementNode {
224   return new TestInlineElementNode();
225 }
226
227 export type SerializedTestShadowRootNode = SerializedElementNode;
228
229 export class TestShadowRootNode extends ElementNode {
230   static getType(): string {
231     return 'test_shadow_root';
232   }
233
234   static clone(node: TestShadowRootNode) {
235     return new TestElementNode(node.__key);
236   }
237
238   static importJSON(
239     serializedNode: SerializedTestShadowRootNode,
240   ): TestShadowRootNode {
241     const node = $createTestShadowRootNode();
242     node.setDirection(serializedNode.direction);
243     return node;
244   }
245
246   exportJSON(): SerializedTestShadowRootNode {
247     return {
248       ...super.exportJSON(),
249       type: 'test_block',
250       version: 1,
251     };
252   }
253
254   createDOM() {
255     return document.createElement('div');
256   }
257
258   updateDOM() {
259     return false;
260   }
261
262   isShadowRoot() {
263     return true;
264   }
265 }
266
267 export function $createTestShadowRootNode(): TestShadowRootNode {
268   return new TestShadowRootNode();
269 }
270
271 export type SerializedTestSegmentedNode = SerializedTextNode;
272
273 export class TestSegmentedNode extends TextNode {
274   static getType(): string {
275     return 'test_segmented';
276   }
277
278   static clone(node: TestSegmentedNode): TestSegmentedNode {
279     return new TestSegmentedNode(node.__text, node.__key);
280   }
281
282   static importJSON(
283     serializedNode: SerializedTestSegmentedNode,
284   ): TestSegmentedNode {
285     const node = $createTestSegmentedNode(serializedNode.text);
286     node.setFormat(serializedNode.format);
287     node.setDetail(serializedNode.detail);
288     node.setMode(serializedNode.mode);
289     node.setStyle(serializedNode.style);
290     return node;
291   }
292
293   exportJSON(): SerializedTestSegmentedNode {
294     return {
295       ...super.exportJSON(),
296       type: 'test_segmented',
297       version: 1,
298     };
299   }
300 }
301
302 export function $createTestSegmentedNode(text: string): TestSegmentedNode {
303   return new TestSegmentedNode(text).setMode('segmented');
304 }
305
306 export type SerializedTestExcludeFromCopyElementNode = SerializedElementNode;
307
308 export class TestExcludeFromCopyElementNode extends ElementNode {
309   static getType(): string {
310     return 'test_exclude_from_copy_block';
311   }
312
313   static clone(node: TestExcludeFromCopyElementNode) {
314     return new TestExcludeFromCopyElementNode(node.__key);
315   }
316
317   static importJSON(
318     serializedNode: SerializedTestExcludeFromCopyElementNode,
319   ): TestExcludeFromCopyElementNode {
320     const node = $createTestExcludeFromCopyElementNode();
321     node.setDirection(serializedNode.direction);
322     return node;
323   }
324
325   exportJSON(): SerializedTestExcludeFromCopyElementNode {
326     return {
327       ...super.exportJSON(),
328       type: 'test_exclude_from_copy_block',
329       version: 1,
330     };
331   }
332
333   createDOM() {
334     return document.createElement('div');
335   }
336
337   updateDOM() {
338     return false;
339   }
340
341   excludeFromCopy() {
342     return true;
343   }
344 }
345
346 export function $createTestExcludeFromCopyElementNode(): TestExcludeFromCopyElementNode {
347   return new TestExcludeFromCopyElementNode();
348 }
349
350 export type SerializedTestDecoratorNode = SerializedLexicalNode;
351
352 export class TestDecoratorNode extends DecoratorNode<HTMLElement> {
353   static getType(): string {
354     return 'test_decorator';
355   }
356
357   static clone(node: TestDecoratorNode) {
358     return new TestDecoratorNode(node.__key);
359   }
360
361   static importJSON(
362     serializedNode: SerializedTestDecoratorNode,
363   ): TestDecoratorNode {
364     return $createTestDecoratorNode();
365   }
366
367   exportJSON(): SerializedTestDecoratorNode {
368     return {
369       ...super.exportJSON(),
370       type: 'test_decorator',
371       version: 1,
372     };
373   }
374
375   static importDOM() {
376     return {
377       'test-decorator': (domNode: HTMLElement) => {
378         return {
379           conversion: () => ({node: $createTestDecoratorNode()}),
380         };
381       },
382     };
383   }
384
385   exportDOM() {
386     return {
387       element: document.createElement('test-decorator'),
388     };
389   }
390
391   getTextContent() {
392     return 'Hello world';
393   }
394
395   createDOM() {
396     return document.createElement('span');
397   }
398
399   updateDOM() {
400     return false;
401   }
402
403   decorate() {
404     const decorator = document.createElement('span');
405     decorator.textContent = 'Hello world';
406     return decorator;
407   }
408 }
409
410 export function $createTestDecoratorNode(): TestDecoratorNode {
411   return new TestDecoratorNode();
412 }
413
414 const DEFAULT_NODES: NonNullable<ReadonlyArray<Klass<LexicalNode> | LexicalNodeReplacement>> = [
415   HeadingNode,
416   ListNode,
417   ListItemNode,
418   QuoteNode,
419   TableNode,
420   TableCellNode,
421   TableRowNode,
422   AutoLinkNode,
423   LinkNode,
424   DetailsNode,
425   TestElementNode,
426   TestSegmentedNode,
427   TestExcludeFromCopyElementNode,
428   TestDecoratorNode,
429   TestInlineElementNode,
430   TestShadowRootNode,
431   TestTextNode,
432 ];
433
434 export function createTestEditor(
435   config: {
436     namespace?: string;
437     editorState?: EditorState;
438     theme?: EditorThemeClasses;
439     parentEditor?: LexicalEditor;
440     nodes?: ReadonlyArray<Klass<LexicalNode> | LexicalNodeReplacement>;
441     onError?: (error: Error) => void;
442     disableEvents?: boolean;
443     readOnly?: boolean;
444     html?: HTMLConfig;
445   } = {},
446 ): LexicalEditor {
447   const customNodes = config.nodes || [];
448   const editor = createEditor({
449     namespace: config.namespace,
450     onError: (e) => {
451       throw e;
452     },
453     ...config,
454     nodes: DEFAULT_NODES.concat(customNodes),
455   });
456
457   return editor;
458 }
459
460 export function createTestHeadlessEditor(
461   editorState?: EditorState,
462 ): LexicalEditor {
463   return createHeadlessEditor({
464     editorState,
465     onError: (error) => {
466       throw error;
467     },
468   });
469 }
470
471 export function createTestContext(): EditorUiContext {
472
473   const container = document.createElement('div');
474   document.body.appendChild(container);
475
476   const scrollWrap = document.createElement('div');
477   const editorDOM = document.createElement('div');
478   editorDOM.setAttribute('contenteditable', 'true');
479
480   scrollWrap.append(editorDOM);
481   container.append(scrollWrap);
482
483   const editor = createTestEditor({
484     namespace: 'testing',
485     theme: {},
486   });
487
488   editor.setRootElement(editorDOM);
489
490   const context = {
491     containerDOM: container,
492     editor: editor,
493     editorDOM: editorDOM,
494     error(text: string | Error): void {
495     },
496     manager: new EditorUIManager(),
497     options: {},
498     scrollDOM: scrollWrap,
499     translate(text: string): string {
500       return "";
501     }
502   };
503
504   context.manager.setContext(context);
505
506   return context;
507 }
508
509 export function destroyFromContext(context: EditorUiContext) {
510   context.containerDOM.remove();
511 }
512
513 export function $assertRangeSelection(selection: unknown): RangeSelection {
514   if (!$isRangeSelection(selection)) {
515     throw new Error(`Expected RangeSelection, got ${selection}`);
516   }
517   return selection;
518 }
519
520 export function invariant(cond?: boolean, message?: string): asserts cond {
521   if (cond) {
522     return;
523   }
524   throw new Error(`Invariant: ${message}`);
525 }
526
527 export class ClipboardDataMock {
528   getData: jest.Mock<string, [string]>;
529   setData: jest.Mock<void, [string, string]>;
530
531   constructor() {
532     this.getData = jest.fn();
533     this.setData = jest.fn();
534   }
535 }
536
537 export class DataTransferMock implements DataTransfer {
538   _data: Map<string, string> = new Map();
539   get dropEffect(): DataTransfer['dropEffect'] {
540     throw new Error('Getter not implemented.');
541   }
542   get effectAllowed(): DataTransfer['effectAllowed'] {
543     throw new Error('Getter not implemented.');
544   }
545   get files(): FileList {
546     throw new Error('Getter not implemented.');
547   }
548   get items(): DataTransferItemList {
549     throw new Error('Getter not implemented.');
550   }
551   get types(): ReadonlyArray<string> {
552     return Array.from(this._data.keys());
553   }
554   clearData(dataType?: string): void {
555     //
556   }
557   getData(dataType: string): string {
558     return this._data.get(dataType) || '';
559   }
560   setData(dataType: string, data: string): void {
561     this._data.set(dataType, data);
562   }
563   setDragImage(image: Element, x: number, y: number): void {
564     //
565   }
566 }
567
568 export class EventMock implements Event {
569   get bubbles(): boolean {
570     throw new Error('Getter not implemented.');
571   }
572   get cancelBubble(): boolean {
573     throw new Error('Gettter not implemented.');
574   }
575   get cancelable(): boolean {
576     throw new Error('Gettter not implemented.');
577   }
578   get composed(): boolean {
579     throw new Error('Gettter not implemented.');
580   }
581   get currentTarget(): EventTarget | null {
582     throw new Error('Gettter not implemented.');
583   }
584   get defaultPrevented(): boolean {
585     throw new Error('Gettter not implemented.');
586   }
587   get eventPhase(): number {
588     throw new Error('Gettter not implemented.');
589   }
590   get isTrusted(): boolean {
591     throw new Error('Gettter not implemented.');
592   }
593   get returnValue(): boolean {
594     throw new Error('Gettter not implemented.');
595   }
596   get srcElement(): EventTarget | null {
597     throw new Error('Gettter not implemented.');
598   }
599   get target(): EventTarget | null {
600     throw new Error('Gettter not implemented.');
601   }
602   get timeStamp(): number {
603     throw new Error('Gettter not implemented.');
604   }
605   get type(): string {
606     throw new Error('Gettter not implemented.');
607   }
608   composedPath(): EventTarget[] {
609     throw new Error('Method not implemented.');
610   }
611   initEvent(
612     type: string,
613     bubbles?: boolean | undefined,
614     cancelable?: boolean | undefined,
615   ): void {
616     throw new Error('Method not implemented.');
617   }
618   stopImmediatePropagation(): void {
619     return;
620   }
621   stopPropagation(): void {
622     return;
623   }
624   NONE = 0 as const;
625   CAPTURING_PHASE = 1 as const;
626   AT_TARGET = 2 as const;
627   BUBBLING_PHASE = 3 as const;
628   preventDefault() {
629     return;
630   }
631 }
632
633 export class KeyboardEventMock extends EventMock implements KeyboardEvent {
634   altKey = false;
635   get charCode(): number {
636     throw new Error('Getter not implemented.');
637   }
638   get code(): string {
639     throw new Error('Getter not implemented.');
640   }
641   ctrlKey = false;
642   get isComposing(): boolean {
643     throw new Error('Getter not implemented.');
644   }
645   get key(): string {
646     throw new Error('Getter not implemented.');
647   }
648   get keyCode(): number {
649     throw new Error('Getter not implemented.');
650   }
651   get location(): number {
652     throw new Error('Getter not implemented.');
653   }
654   metaKey = false;
655   get repeat(): boolean {
656     throw new Error('Getter not implemented.');
657   }
658   shiftKey = false;
659   constructor(type: void | string) {
660     super();
661   }
662   getModifierState(keyArg: string): boolean {
663     throw new Error('Method not implemented.');
664   }
665   initKeyboardEvent(
666     typeArg: string,
667     bubblesArg?: boolean | undefined,
668     cancelableArg?: boolean | undefined,
669     viewArg?: Window | null | undefined,
670     keyArg?: string | undefined,
671     locationArg?: number | undefined,
672     ctrlKey?: boolean | undefined,
673     altKey?: boolean | undefined,
674     shiftKey?: boolean | undefined,
675     metaKey?: boolean | undefined,
676   ): void {
677     throw new Error('Method not implemented.');
678   }
679   DOM_KEY_LOCATION_STANDARD = 0 as const;
680   DOM_KEY_LOCATION_LEFT = 1 as const;
681   DOM_KEY_LOCATION_RIGHT = 2 as const;
682   DOM_KEY_LOCATION_NUMPAD = 3 as const;
683   get detail(): number {
684     throw new Error('Getter not implemented.');
685   }
686   get view(): Window | null {
687     throw new Error('Getter not implemented.');
688   }
689   get which(): number {
690     throw new Error('Getter not implemented.');
691   }
692   initUIEvent(
693     typeArg: string,
694     bubblesArg?: boolean | undefined,
695     cancelableArg?: boolean | undefined,
696     viewArg?: Window | null | undefined,
697     detailArg?: number | undefined,
698   ): void {
699     throw new Error('Method not implemented.');
700   }
701 }
702
703 export function tabKeyboardEvent() {
704   return new KeyboardEventMock('keydown');
705 }
706
707 export function shiftTabKeyboardEvent() {
708   const keyboardEvent = new KeyboardEventMock('keydown');
709   keyboardEvent.shiftKey = true;
710   return keyboardEvent;
711 }
712
713 export function generatePermutations<T>(
714   values: T[],
715   maxLength = values.length,
716 ): T[][] {
717   if (maxLength > values.length) {
718     throw new Error('maxLength over values.length');
719   }
720   const result: T[][] = [];
721   const current: T[] = [];
722   const seen = new Set();
723   (function permutationsImpl() {
724     if (current.length > maxLength) {
725       return;
726     }
727     result.push(current.slice());
728     for (let i = 0; i < values.length; i++) {
729       const key = values[i];
730       if (seen.has(key)) {
731         continue;
732       }
733       seen.add(key);
734       current.push(key);
735       permutationsImpl();
736       seen.delete(key);
737       current.pop();
738     }
739   })();
740   return result;
741 }
742
743 // This tag function is just used to trigger prettier auto-formatting.
744 // (https://prettier.io/blog/2020/08/24/2.1.0.html#api)
745 export function html(
746   partials: TemplateStringsArray,
747   ...params: string[]
748 ): string {
749   let output = '';
750   for (let i = 0; i < partials.length; i++) {
751     output += partials[i];
752     if (i < partials.length - 1) {
753       output += params[i];
754     }
755   }
756   return output;
757 }
758
759 export function expectHtmlToBeEqual(expected: string, actual: string): void {
760   expect(formatHtml(expected)).toBe(formatHtml(actual));
761 }
762
763 type nodeTextShape = {
764   text: string;
765 };
766
767 type nodeShape = {
768   type: string;
769   children?: (nodeShape|nodeTextShape)[];
770 }
771
772 export function getNodeShape(node: SerializedLexicalNode): nodeShape|nodeTextShape {
773   // @ts-ignore
774   const children: SerializedLexicalNode[] = (node.children || []);
775
776   const shape: nodeShape = {
777     type: node.type,
778   };
779
780   if (shape.type === 'text') {
781     // @ts-ignore
782     return  {text: node.text}
783   }
784
785   if (children.length > 0) {
786     shape.children = children.map(c => getNodeShape(c));
787   }
788
789   return shape;
790 }
791
792 export function expectNodeShapeToMatch(editor: LexicalEditor, expected: nodeShape[]) {
793   const json = editor.getEditorState().toJSON();
794   const shape = getNodeShape(json.root) as nodeShape;
795   expect(shape.children).toMatchObject(expected);
796 }
797
798 function formatHtml(s: string): string {
799   return s.replace(/>\s+</g, '><').replace(/\s*\n\s*/g, ' ').trim();
800 }
801
802 export function dispatchKeydownEventForNode(node: LexicalNode, editor: LexicalEditor, key: string) {
803   const nodeDomEl = editor.getElementByKey(node.getKey());
804   const event = new KeyboardEvent('keydown', {
805     bubbles: true,
806     cancelable: true,
807     key,
808   });
809   nodeDomEl?.dispatchEvent(event);
810   editor.commitUpdates();
811 }
812
813 export function dispatchKeydownEventForSelectedNode(editor: LexicalEditor, key: string) {
814   editor.getEditorState().read((): void => {
815     const node = $getSelection()?.getNodes()[0] || null;
816     if (node) {
817       dispatchKeydownEventForNode(node, editor, key);
818     }
819   });
820 }