]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts
f7230595a43d7abae8b81bdd9f52d6b36ebb439e
[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.setFormat(serializedNode.format);
133     node.setIndent(serializedNode.indent);
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.setFormat(serializedNode.format);
199     node.setIndent(serializedNode.indent);
200     node.setDirection(serializedNode.direction);
201     return node;
202   }
203
204   exportJSON(): SerializedTestInlineElementNode {
205     return {
206       ...super.exportJSON(),
207       type: 'test_inline_block',
208       version: 1,
209     };
210   }
211
212   createDOM() {
213     return document.createElement('a');
214   }
215
216   updateDOM() {
217     return false;
218   }
219
220   isInline() {
221     return true;
222   }
223 }
224
225 export function $createTestInlineElementNode(): TestInlineElementNode {
226   return new TestInlineElementNode();
227 }
228
229 export type SerializedTestShadowRootNode = SerializedElementNode;
230
231 export class TestShadowRootNode extends ElementNode {
232   static getType(): string {
233     return 'test_shadow_root';
234   }
235
236   static clone(node: TestShadowRootNode) {
237     return new TestElementNode(node.__key);
238   }
239
240   static importJSON(
241     serializedNode: SerializedTestShadowRootNode,
242   ): TestShadowRootNode {
243     const node = $createTestShadowRootNode();
244     node.setFormat(serializedNode.format);
245     node.setIndent(serializedNode.indent);
246     node.setDirection(serializedNode.direction);
247     return node;
248   }
249
250   exportJSON(): SerializedTestShadowRootNode {
251     return {
252       ...super.exportJSON(),
253       type: 'test_block',
254       version: 1,
255     };
256   }
257
258   createDOM() {
259     return document.createElement('div');
260   }
261
262   updateDOM() {
263     return false;
264   }
265
266   isShadowRoot() {
267     return true;
268   }
269 }
270
271 export function $createTestShadowRootNode(): TestShadowRootNode {
272   return new TestShadowRootNode();
273 }
274
275 export type SerializedTestSegmentedNode = SerializedTextNode;
276
277 export class TestSegmentedNode extends TextNode {
278   static getType(): string {
279     return 'test_segmented';
280   }
281
282   static clone(node: TestSegmentedNode): TestSegmentedNode {
283     return new TestSegmentedNode(node.__text, node.__key);
284   }
285
286   static importJSON(
287     serializedNode: SerializedTestSegmentedNode,
288   ): TestSegmentedNode {
289     const node = $createTestSegmentedNode(serializedNode.text);
290     node.setFormat(serializedNode.format);
291     node.setDetail(serializedNode.detail);
292     node.setMode(serializedNode.mode);
293     node.setStyle(serializedNode.style);
294     return node;
295   }
296
297   exportJSON(): SerializedTestSegmentedNode {
298     return {
299       ...super.exportJSON(),
300       type: 'test_segmented',
301       version: 1,
302     };
303   }
304 }
305
306 export function $createTestSegmentedNode(text: string): TestSegmentedNode {
307   return new TestSegmentedNode(text).setMode('segmented');
308 }
309
310 export type SerializedTestExcludeFromCopyElementNode = SerializedElementNode;
311
312 export class TestExcludeFromCopyElementNode extends ElementNode {
313   static getType(): string {
314     return 'test_exclude_from_copy_block';
315   }
316
317   static clone(node: TestExcludeFromCopyElementNode) {
318     return new TestExcludeFromCopyElementNode(node.__key);
319   }
320
321   static importJSON(
322     serializedNode: SerializedTestExcludeFromCopyElementNode,
323   ): TestExcludeFromCopyElementNode {
324     const node = $createTestExcludeFromCopyElementNode();
325     node.setFormat(serializedNode.format);
326     node.setIndent(serializedNode.indent);
327     node.setDirection(serializedNode.direction);
328     return node;
329   }
330
331   exportJSON(): SerializedTestExcludeFromCopyElementNode {
332     return {
333       ...super.exportJSON(),
334       type: 'test_exclude_from_copy_block',
335       version: 1,
336     };
337   }
338
339   createDOM() {
340     return document.createElement('div');
341   }
342
343   updateDOM() {
344     return false;
345   }
346
347   excludeFromCopy() {
348     return true;
349   }
350 }
351
352 export function $createTestExcludeFromCopyElementNode(): TestExcludeFromCopyElementNode {
353   return new TestExcludeFromCopyElementNode();
354 }
355
356 export type SerializedTestDecoratorNode = SerializedLexicalNode;
357
358 export class TestDecoratorNode extends DecoratorNode<HTMLElement> {
359   static getType(): string {
360     return 'test_decorator';
361   }
362
363   static clone(node: TestDecoratorNode) {
364     return new TestDecoratorNode(node.__key);
365   }
366
367   static importJSON(
368     serializedNode: SerializedTestDecoratorNode,
369   ): TestDecoratorNode {
370     return $createTestDecoratorNode();
371   }
372
373   exportJSON(): SerializedTestDecoratorNode {
374     return {
375       ...super.exportJSON(),
376       type: 'test_decorator',
377       version: 1,
378     };
379   }
380
381   static importDOM() {
382     return {
383       'test-decorator': (domNode: HTMLElement) => {
384         return {
385           conversion: () => ({node: $createTestDecoratorNode()}),
386         };
387       },
388     };
389   }
390
391   exportDOM() {
392     return {
393       element: document.createElement('test-decorator'),
394     };
395   }
396
397   getTextContent() {
398     return 'Hello world';
399   }
400
401   createDOM() {
402     return document.createElement('span');
403   }
404
405   updateDOM() {
406     return false;
407   }
408
409   decorate() {
410     const decorator = document.createElement('span');
411     decorator.textContent = 'Hello world';
412     return decorator;
413   }
414 }
415
416 export function $createTestDecoratorNode(): TestDecoratorNode {
417   return new TestDecoratorNode();
418 }
419
420 const DEFAULT_NODES: NonNullable<ReadonlyArray<Klass<LexicalNode> | LexicalNodeReplacement>> = [
421   HeadingNode,
422   ListNode,
423   ListItemNode,
424   QuoteNode,
425   TableNode,
426   TableCellNode,
427   TableRowNode,
428   AutoLinkNode,
429   LinkNode,
430   TestElementNode,
431   TestSegmentedNode,
432   TestExcludeFromCopyElementNode,
433   TestDecoratorNode,
434   TestInlineElementNode,
435   TestShadowRootNode,
436   TestTextNode,
437 ];
438
439 export function createTestEditor(
440   config: {
441     namespace?: string;
442     editorState?: EditorState;
443     theme?: EditorThemeClasses;
444     parentEditor?: LexicalEditor;
445     nodes?: ReadonlyArray<Klass<LexicalNode> | LexicalNodeReplacement>;
446     onError?: (error: Error) => void;
447     disableEvents?: boolean;
448     readOnly?: boolean;
449     html?: HTMLConfig;
450   } = {},
451 ): LexicalEditor {
452   const customNodes = config.nodes || [];
453   const editor = createEditor({
454     namespace: config.namespace,
455     onError: (e) => {
456       throw e;
457     },
458     ...config,
459     nodes: DEFAULT_NODES.concat(customNodes),
460   });
461   return editor;
462 }
463
464 export function createTestHeadlessEditor(
465   editorState?: EditorState,
466 ): LexicalEditor {
467   return createHeadlessEditor({
468     editorState,
469     onError: (error) => {
470       throw error;
471     },
472   });
473 }
474
475 export function $assertRangeSelection(selection: unknown): RangeSelection {
476   if (!$isRangeSelection(selection)) {
477     throw new Error(`Expected RangeSelection, got ${selection}`);
478   }
479   return selection;
480 }
481
482 export function invariant(cond?: boolean, message?: string): asserts cond {
483   if (cond) {
484     return;
485   }
486   throw new Error(`Invariant: ${message}`);
487 }
488
489 export class ClipboardDataMock {
490   getData: jest.Mock<string, [string]>;
491   setData: jest.Mock<void, [string, string]>;
492
493   constructor() {
494     this.getData = jest.fn();
495     this.setData = jest.fn();
496   }
497 }
498
499 export class DataTransferMock implements DataTransfer {
500   _data: Map<string, string> = new Map();
501   get dropEffect(): DataTransfer['dropEffect'] {
502     throw new Error('Getter not implemented.');
503   }
504   get effectAllowed(): DataTransfer['effectAllowed'] {
505     throw new Error('Getter not implemented.');
506   }
507   get files(): FileList {
508     throw new Error('Getter not implemented.');
509   }
510   get items(): DataTransferItemList {
511     throw new Error('Getter not implemented.');
512   }
513   get types(): ReadonlyArray<string> {
514     return Array.from(this._data.keys());
515   }
516   clearData(dataType?: string): void {
517     //
518   }
519   getData(dataType: string): string {
520     return this._data.get(dataType) || '';
521   }
522   setData(dataType: string, data: string): void {
523     this._data.set(dataType, data);
524   }
525   setDragImage(image: Element, x: number, y: number): void {
526     //
527   }
528 }
529
530 export class EventMock implements Event {
531   get bubbles(): boolean {
532     throw new Error('Getter not implemented.');
533   }
534   get cancelBubble(): boolean {
535     throw new Error('Gettter not implemented.');
536   }
537   get cancelable(): boolean {
538     throw new Error('Gettter not implemented.');
539   }
540   get composed(): boolean {
541     throw new Error('Gettter not implemented.');
542   }
543   get currentTarget(): EventTarget | null {
544     throw new Error('Gettter not implemented.');
545   }
546   get defaultPrevented(): boolean {
547     throw new Error('Gettter not implemented.');
548   }
549   get eventPhase(): number {
550     throw new Error('Gettter not implemented.');
551   }
552   get isTrusted(): boolean {
553     throw new Error('Gettter not implemented.');
554   }
555   get returnValue(): boolean {
556     throw new Error('Gettter not implemented.');
557   }
558   get srcElement(): EventTarget | null {
559     throw new Error('Gettter not implemented.');
560   }
561   get target(): EventTarget | null {
562     throw new Error('Gettter not implemented.');
563   }
564   get timeStamp(): number {
565     throw new Error('Gettter not implemented.');
566   }
567   get type(): string {
568     throw new Error('Gettter not implemented.');
569   }
570   composedPath(): EventTarget[] {
571     throw new Error('Method not implemented.');
572   }
573   initEvent(
574     type: string,
575     bubbles?: boolean | undefined,
576     cancelable?: boolean | undefined,
577   ): void {
578     throw new Error('Method not implemented.');
579   }
580   stopImmediatePropagation(): void {
581     return;
582   }
583   stopPropagation(): void {
584     return;
585   }
586   NONE = 0 as const;
587   CAPTURING_PHASE = 1 as const;
588   AT_TARGET = 2 as const;
589   BUBBLING_PHASE = 3 as const;
590   preventDefault() {
591     return;
592   }
593 }
594
595 export class KeyboardEventMock extends EventMock implements KeyboardEvent {
596   altKey = false;
597   get charCode(): number {
598     throw new Error('Getter not implemented.');
599   }
600   get code(): string {
601     throw new Error('Getter not implemented.');
602   }
603   ctrlKey = false;
604   get isComposing(): boolean {
605     throw new Error('Getter not implemented.');
606   }
607   get key(): string {
608     throw new Error('Getter not implemented.');
609   }
610   get keyCode(): number {
611     throw new Error('Getter not implemented.');
612   }
613   get location(): number {
614     throw new Error('Getter not implemented.');
615   }
616   metaKey = false;
617   get repeat(): boolean {
618     throw new Error('Getter not implemented.');
619   }
620   shiftKey = false;
621   constructor(type: void | string) {
622     super();
623   }
624   getModifierState(keyArg: string): boolean {
625     throw new Error('Method not implemented.');
626   }
627   initKeyboardEvent(
628     typeArg: string,
629     bubblesArg?: boolean | undefined,
630     cancelableArg?: boolean | undefined,
631     viewArg?: Window | null | undefined,
632     keyArg?: string | undefined,
633     locationArg?: number | undefined,
634     ctrlKey?: boolean | undefined,
635     altKey?: boolean | undefined,
636     shiftKey?: boolean | undefined,
637     metaKey?: boolean | undefined,
638   ): void {
639     throw new Error('Method not implemented.');
640   }
641   DOM_KEY_LOCATION_STANDARD = 0 as const;
642   DOM_KEY_LOCATION_LEFT = 1 as const;
643   DOM_KEY_LOCATION_RIGHT = 2 as const;
644   DOM_KEY_LOCATION_NUMPAD = 3 as const;
645   get detail(): number {
646     throw new Error('Getter not implemented.');
647   }
648   get view(): Window | null {
649     throw new Error('Getter not implemented.');
650   }
651   get which(): number {
652     throw new Error('Getter not implemented.');
653   }
654   initUIEvent(
655     typeArg: string,
656     bubblesArg?: boolean | undefined,
657     cancelableArg?: boolean | undefined,
658     viewArg?: Window | null | undefined,
659     detailArg?: number | undefined,
660   ): void {
661     throw new Error('Method not implemented.');
662   }
663 }
664
665 export function tabKeyboardEvent() {
666   return new KeyboardEventMock('keydown');
667 }
668
669 export function shiftTabKeyboardEvent() {
670   const keyboardEvent = new KeyboardEventMock('keydown');
671   keyboardEvent.shiftKey = true;
672   return keyboardEvent;
673 }
674
675 export function generatePermutations<T>(
676   values: T[],
677   maxLength = values.length,
678 ): T[][] {
679   if (maxLength > values.length) {
680     throw new Error('maxLength over values.length');
681   }
682   const result: T[][] = [];
683   const current: T[] = [];
684   const seen = new Set();
685   (function permutationsImpl() {
686     if (current.length > maxLength) {
687       return;
688     }
689     result.push(current.slice());
690     for (let i = 0; i < values.length; i++) {
691       const key = values[i];
692       if (seen.has(key)) {
693         continue;
694       }
695       seen.add(key);
696       current.push(key);
697       permutationsImpl();
698       seen.delete(key);
699       current.pop();
700     }
701   })();
702   return result;
703 }
704
705 // This tag function is just used to trigger prettier auto-formatting.
706 // (https://prettier.io/blog/2020/08/24/2.1.0.html#api)
707 export function html(
708   partials: TemplateStringsArray,
709   ...params: string[]
710 ): string {
711   let output = '';
712   for (let i = 0; i < partials.length; i++) {
713     output += partials[i];
714     if (i < partials.length - 1) {
715       output += params[i];
716     }
717   }
718   return output;
719 }
720
721 export function expectHtmlToBeEqual(expected: string, actual: string): void {
722   expect(formatHtml(expected)).toBe(formatHtml(actual));
723 }
724
725 function formatHtml(s: string): string {
726   return s.replace(/>\s+</g, '><').replace(/\s*\n\s*/g, ' ').trim();
727 }