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