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