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