]> BookStack Code Mirror - bookstack/commitdiff
Lexical: Added testing for some added shortcuts
authorDan Brown <redacted>
Mon, 16 Dec 2024 16:24:47 +0000 (16:24 +0000)
committerDan Brown <redacted>
Mon, 16 Dec 2024 16:27:44 +0000 (16:27 +0000)
Also:
- Added svg loading support (dummy stub) for jest.
- Updated headless test case due to node changes.
- Split out editor change detected to where appropriate.
- Added functions to help with testing, like mocking our context.

dev/build/svg-blank-transform.js [new file with mode: 0644]
jest.config.ts
resources/js/wysiwyg/index.ts
resources/js/wysiwyg/lexical/core/LexicalEditor.ts
resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts
resources/js/wysiwyg/lexical/headless/__tests__/unit/LexicalHeadlessEditor.test.ts
resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalDetailsNode.test.ts [new file with mode: 0644]
resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts [new file with mode: 0644]
resources/js/wysiwyg/services/common-events.ts
resources/js/wysiwyg/ui/framework/manager.ts

diff --git a/dev/build/svg-blank-transform.js b/dev/build/svg-blank-transform.js
new file mode 100644 (file)
index 0000000..5183014
--- /dev/null
@@ -0,0 +1,14 @@
+// This is a basic transformer stub to help jest handle SVG files.
+// Essentially blanks them since we don't really need to involve them
+// in our tests (yet).
+module.exports = {
+    process() {
+        return {
+            code: 'module.exports = \'\';',
+        };
+    },
+    getCacheKey() {
+        // The output is always the same.
+        return 'svgTransform';
+    },
+};
index 3c04f05b218362510b2721cfca6c8d00b965e2a0..53bfceb053e44ce6cf6e9e201d6a8520c6784f17 100644 (file)
@@ -185,6 +185,7 @@ const config: Config = {
   // A map from regular expressions to paths to transformers
   transform: {
     "^.+.tsx?$": ["ts-jest",{}],
+    "^.+.svg$": ["<rootDir>/dev/build/svg-blank-transform.js",{}],
   },
 
   // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
index 510ab1f923085782bb476ac423c1bb1eaf761c62..ffdc7d7e82cffbab217f0800341278950a740033 100644 (file)
@@ -75,38 +75,12 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st
     const debugView = document.getElementById('lexical-debug');
     if (debugView) {
         debugView.hidden = true;
-    }
-
-    let changeFromLoading = true;
-    editor.registerUpdateListener(({dirtyElements, dirtyLeaves, editorState, prevEditorState}) => {
-        // Watch for selection changes to update the UI on change
-        // Used to be done via SELECTION_CHANGE_COMMAND but this would not always emit
-        // for all selection changes, so this proved more reliable.
-        const selectionChange = !(prevEditorState._selection?.is(editorState._selection) || false);
-        if (selectionChange) {
-            editor.update(() => {
-                const selection = $getSelection();
-                context.manager.triggerStateUpdate({
-                    editor, selection,
-                });
-            });
-        }
-
-        // Emit change event to component system (for draft detection) on actual user content change
-        if (dirtyElements.size > 0 || dirtyLeaves.size > 0) {
-            if (changeFromLoading) {
-                changeFromLoading = false;
-            } else {
-                window.$events.emit('editor-html-change', '');
-            }
-        }
-
-        // Debug logic
-        // console.log('editorState', editorState.toJSON());
-        if (debugView) {
+        editor.registerUpdateListener(({dirtyElements, dirtyLeaves, editorState, prevEditorState}) => {
+            // Debug logic
+            // console.log('editorState', editorState.toJSON());
             debugView.textContent = JSON.stringify(editorState.toJSON(), null, 2);
-        }
-    });
+        });
+    }
 
     // @ts-ignore
     window.debugEditorState = () => {
index 092429156bec118195cdd841cba9c194aa10c5f3..364f6c6b7c3e70d23304b4316d10cf20dfe26768 100644 (file)
@@ -1188,6 +1188,14 @@ export class LexicalEditor {
     updateEditor(this, updateFn, options);
   }
 
+  /**
+   * Helper to run the update and commitUpdates methods in a single call.
+   */
+  updateAndCommit(updateFn: () => void, options?: EditorUpdateOptions): void {
+    this.update(updateFn, options);
+    this.commitUpdates();
+  }
+
   /**
    * Focuses the editor
    * @param callbackFn - A function to run after the editor is focused.
index e9d14ef1139c94292fd87f10af129940dd9aaf5b..2fc57315b7c7a41abc6691cdad88078a76c1d094 100644 (file)
@@ -13,6 +13,7 @@ import {ListItemNode, ListNode} from '@lexical/list';
 import {TableCellNode, TableNode, TableRowNode} from '@lexical/table';
 
 import {
+  $getSelection,
   $isRangeSelection,
   createEditor,
   DecoratorNode,
@@ -37,6 +38,10 @@ import {
 import {resetRandomKey} from '../../LexicalUtils';
 import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode";
 import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode";
+import {DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
+import {EditorUiContext} from "../../../../ui/framework/core";
+import {EditorUIManager} from "../../../../ui/framework/manager";
+import {registerRichText} from "@lexical/rich-text";
 
 
 type TestEnv = {
@@ -420,6 +425,7 @@ const DEFAULT_NODES: NonNullable<ReadonlyArray<Klass<LexicalNode> | LexicalNodeR
   TableRowNode,
   AutoLinkNode,
   LinkNode,
+  DetailsNode,
   TestElementNode,
   TestSegmentedNode,
   TestExcludeFromCopyElementNode,
@@ -451,6 +457,7 @@ export function createTestEditor(
     ...config,
     nodes: DEFAULT_NODES.concat(customNodes),
   });
+
   return editor;
 }
 
@@ -465,6 +472,26 @@ export function createTestHeadlessEditor(
   });
 }
 
+export function createTestContext(env: TestEnv): EditorUiContext {
+  const context = {
+    containerDOM: document.createElement('div'),
+    editor: env.editor,
+    editorDOM: document.createElement('div'),
+    error(text: string | Error): void {
+    },
+    manager: new EditorUIManager(),
+    options: {},
+    scrollDOM: document.createElement('div'),
+    translate(text: string): string {
+      return "";
+    }
+  };
+
+  context.manager.setContext(context);
+
+  return context;
+}
+
 export function $assertRangeSelection(selection: unknown): RangeSelection {
   if (!$isRangeSelection(selection)) {
     throw new Error(`Expected RangeSelection, got ${selection}`);
@@ -717,4 +744,23 @@ export function expectHtmlToBeEqual(expected: string, actual: string): void {
 
 function formatHtml(s: string): string {
   return s.replace(/>\s+</g, '><').replace(/\s*\n\s*/g, ' ').trim();
+}
+
+export function dispatchKeydownEventForNode(node: LexicalNode, editor: LexicalEditor, key: string) {
+  const nodeDomEl = editor.getElementByKey(node.getKey());
+  const event = new KeyboardEvent('keydown', {
+    bubbles: true,
+    cancelable: true,
+    key,
+  });
+  nodeDomEl?.dispatchEvent(event);
+}
+
+export function dispatchKeydownEventForSelectedNode(editor: LexicalEditor, key: string) {
+  editor.getEditorState().read((): void => {
+    const node = $getSelection()?.getNodes()[0] || null;
+    if (node) {
+      dispatchKeydownEventForNode(node, editor, key);
+    }
+  });
 }
\ No newline at end of file
index 122516d45b67575cb08bee29a0662135e835df2b..c03f1bdb2fb13ff47ab4f2b7abb075d04590176a 100644 (file)
@@ -62,7 +62,6 @@ describe('LexicalHeadlessEditor', () => {
   it('should be headless environment', async () => {
     expect(typeof window === 'undefined').toBe(true);
     expect(typeof document === 'undefined').toBe(true);
-    expect(typeof navigator === 'undefined').toBe(true);
   });
 
   it('can update editor', async () => {
diff --git a/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalDetailsNode.test.ts b/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalDetailsNode.test.ts
new file mode 100644 (file)
index 0000000..faa31d8
--- /dev/null
@@ -0,0 +1,40 @@
+import {dispatchKeydownEventForNode, initializeUnitTest} from "lexical/__tests__/utils";
+import {$createDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
+import {$createParagraphNode, $getRoot, LexicalNode, ParagraphNode} from "lexical";
+
+const editorConfig = Object.freeze({
+    namespace: '',
+    theme: {
+    },
+});
+
+describe('LexicalDetailsNode tests', () => {
+    initializeUnitTest((testEnv) => {
+
+        test('createDOM()', () => {
+            const {editor} = testEnv;
+            let html!: string;
+
+            editor.updateAndCommit(() => {
+                const details = $createDetailsNode();
+                html = details.createDOM(editorConfig, editor).outerHTML;
+            });
+
+            expect(html).toBe(`<details><summary contenteditable="false"></summary></details>`);
+        });
+
+        test('exportDOM()', () => {
+            const {editor} = testEnv;
+            let html!: string;
+
+            editor.updateAndCommit(() => {
+                const details = $createDetailsNode();
+                html = (details.exportDOM(editor).element as HTMLElement).outerHTML;
+            });
+
+            expect(html).toBe(`<details><summary></summary></details>`);
+        });
+
+
+    });
+})
\ No newline at end of file
diff --git a/resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts b/resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts
new file mode 100644 (file)
index 0000000..14a1ea9
--- /dev/null
@@ -0,0 +1,95 @@
+import {
+    createTestContext,
+    dispatchKeydownEventForNode,
+    dispatchKeydownEventForSelectedNode,
+    initializeUnitTest
+} from "lexical/__tests__/utils";
+import {
+    $createParagraphNode, $createTextNode,
+    $getRoot, LexicalNode,
+    ParagraphNode,
+} from "lexical";
+import {$createDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
+import {registerKeyboardHandling} from "../keyboard-handling";
+import {registerRichText} from "@lexical/rich-text";
+
+describe('Keyboard-handling service tests', () => {
+    initializeUnitTest((testEnv) => {
+
+        test('Details: down key on last lines creates new sibling node', () => {
+            const {editor} = testEnv;
+
+            registerRichText(editor);
+            registerKeyboardHandling(createTestContext(testEnv));
+
+            let lastRootChild!: LexicalNode|null;
+            let detailsPara!: ParagraphNode;
+
+            editor.updateAndCommit(() => {
+                const root = $getRoot()
+                const details = $createDetailsNode();
+                detailsPara = $createParagraphNode();
+                details.append(detailsPara);
+                $getRoot().append(details);
+                detailsPara.select();
+
+                lastRootChild = root.getLastChild();
+            });
+
+            expect(lastRootChild).toBeInstanceOf(DetailsNode);
+
+            dispatchKeydownEventForNode(detailsPara, editor, 'ArrowDown');
+            editor.commitUpdates();
+
+            editor.getEditorState().read(() => {
+                lastRootChild = $getRoot().getLastChild();
+            });
+
+            expect(lastRootChild).toBeInstanceOf(ParagraphNode);
+        });
+
+        test('Details: enter on last empy block creates new sibling node', () => {
+            const {editor} = testEnv;
+
+            registerRichText(editor);
+            registerKeyboardHandling(createTestContext(testEnv));
+
+            let lastRootChild!: LexicalNode|null;
+            let detailsPara!: ParagraphNode;
+
+            editor.updateAndCommit(() => {
+                const root = $getRoot()
+                const details = $createDetailsNode();
+                const text = $createTextNode('Hello!');
+                detailsPara = $createParagraphNode();
+                detailsPara.append(text);
+                details.append(detailsPara);
+                $getRoot().append(details);
+                text.selectEnd();
+
+                lastRootChild = root.getLastChild();
+            });
+
+            expect(lastRootChild).toBeInstanceOf(DetailsNode);
+
+            dispatchKeydownEventForNode(detailsPara, editor, 'Enter');
+            editor.commitUpdates();
+
+            dispatchKeydownEventForSelectedNode(editor, 'Enter');
+            editor.commitUpdates();
+
+            let detailsChildren!: LexicalNode[];
+            let lastDetailsText!: string;
+
+            editor.getEditorState().read(() => {
+                detailsChildren = (lastRootChild as DetailsNode).getChildren();
+                lastRootChild = $getRoot().getLastChild();
+                lastDetailsText = detailsChildren[0].getTextContent();
+            });
+
+            expect(lastRootChild).toBeInstanceOf(ParagraphNode);
+            expect(detailsChildren).toHaveLength(1);
+            expect(lastDetailsText).toBe('Hello!');
+        });
+    });
+});
\ No newline at end of file
index 16522d66b713ad0e475d7c90cb67a4e76f1ce243..2ffa722e40c3415a5914da20422100e40ba9cda1 100644 (file)
@@ -1,4 +1,4 @@
-import {LexicalEditor} from "lexical";
+import {$getSelection, LexicalEditor} from "lexical";
 import {
     appendHtmlToEditor,
     focusEditor,
@@ -40,4 +40,16 @@ export function listen(editor: LexicalEditor): void {
     window.$events.listen<EditorEventContent>('editor::focus', () => {
         focusEditor(editor);
     });
+
+    let changeFromLoading = true;
+    editor.registerUpdateListener(({dirtyElements, dirtyLeaves, editorState, prevEditorState}) => {
+        // Emit change event to component system (for draft detection) on actual user content change
+        if (dirtyElements.size > 0 || dirtyLeaves.size > 0) {
+            if (changeFromLoading) {
+                changeFromLoading = false;
+            } else {
+                window.$events.emit('editor-html-change', '');
+            }
+        }
+    });
 }
index 185cd5dccd05db5ac723a5c5a7b02c8ae0f188d8..0f501d9faae713063f4dcbbaa23d40e468c53225 100644 (file)
@@ -1,7 +1,7 @@
 import {EditorFormModal, EditorFormModalDefinition} from "./modals";
 import {EditorContainerUiElement, EditorUiContext, EditorUiElement, EditorUiStateUpdate} from "./core";
 import {EditorDecorator, EditorDecoratorAdapter} from "./decorator";
-import {BaseSelection, LexicalEditor} from "lexical";
+import {$getSelection, BaseSelection, LexicalEditor} from "lexical";
 import {DecoratorListener} from "lexical/LexicalEditor";
 import type {NodeKey} from "lexical/LexicalNode";
 import {EditorContextToolbar, EditorContextToolbarDefinition} from "./toolbars";
@@ -231,6 +231,22 @@ export class EditorUIManager {
             });
         }
         editor.registerDecoratorListener(domDecorateListener);
+
+        // Watch for changes to update local state
+        editor.registerUpdateListener(({editorState, prevEditorState}) => {
+            // Watch for selection changes to update the UI on change
+            // Used to be done via SELECTION_CHANGE_COMMAND but this would not always emit
+            // for all selection changes, so this proved more reliable.
+            const selectionChange = !(prevEditorState._selection?.is(editorState._selection) || false);
+            if (selectionChange) {
+                editor.update(() => {
+                    const selection = $getSelection();
+                    this.triggerStateUpdate({
+                        editor, selection,
+                    });
+                });
+            }
+        });
     }
 
     protected setupEventListeners(context: EditorUiContext) {