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