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