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