]> BookStack Code Mirror - bookstack/commitdiff
Lexical: Updated URL handling, added mouse handling
authorDan Brown <redacted>
Fri, 25 Jul 2025 12:58:48 +0000 (13:58 +0100)
committerDan Brown <redacted>
Fri, 25 Jul 2025 12:58:48 +0000 (13:58 +0100)
- Removed URL protocol allow-list to allow any as per old editor.
- Added mouse handling, so that clicks below many last hard-to-escape
  block types will add an empty new paragraph for easy escaping &
  editing.

resources/js/wysiwyg/index.ts
resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts
resources/js/wysiwyg/lexical/link/index.ts
resources/js/wysiwyg/services/__tests__/mouse-handling.test.ts [new file with mode: 0644]
resources/js/wysiwyg/services/mouse-handling.ts [new file with mode: 0644]

index f572f9de5ec9da58fc890b93b8efced42de8ab7d..e01b4e8f499feac530cd8f2d882edf66feb07838 100644 (file)
@@ -19,6 +19,7 @@ import {contextToolbars, getBasicEditorToolbar, getMainEditorFullToolbar} from "
 import {modals} from "./ui/defaults/modals";
 import {CodeBlockDecorator} from "./ui/decorators/code-block";
 import {DiagramDecorator} from "./ui/decorators/diagram";
+import {registerMouseHandling} from "./services/mouse-handling";
 
 const theme = {
     text: {
@@ -51,6 +52,7 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st
         registerHistory(editor, createEmptyHistoryState(), 300),
         registerShortcuts(context),
         registerKeyboardHandling(context),
+        registerMouseHandling(context),
         registerTableResizer(editor, context.scrollDOM),
         registerTableSelectionHandler(editor),
         registerTaskListHandler(editor, context.editorDOM),
index e18ef97560b797d154ae10c4882c1996d2253045..fd87877eefbdb6b4bb5fef661eabb82e0c26890e 100644 (file)
@@ -848,4 +848,20 @@ export function dispatchKeydownEventForSelectedNode(editor: LexicalEditor, key:
       dispatchKeydownEventForNode(node, editor, key);
     }
   });
+}
+
+export function dispatchEditorMouseClick(editor: LexicalEditor, clientX: number, clientY: number) {
+  const dom = editor.getRootElement();
+  if (!dom) {
+    return;
+  }
+
+  const event = new MouseEvent('click', {
+    clientX: clientX,
+    clientY: clientY,
+    bubbles: true,
+    cancelable: true,
+  });
+  dom?.dispatchEvent(event);
+  editor.commitUpdates();
 }
\ No newline at end of file
index 884fe9153a09aa48c7204391aee59a42e6098c00..336bb15467a878bcffda790eadb4af18c7c9df87 100644 (file)
@@ -48,14 +48,6 @@ export type SerializedLinkNode = Spread<
 
 type LinkHTMLElementType = HTMLAnchorElement | HTMLSpanElement;
 
-const SUPPORTED_URL_PROTOCOLS = new Set([
-  'http:',
-  'https:',
-  'mailto:',
-  'sms:',
-  'tel:',
-]);
-
 /** @noInheritDoc */
 export class LinkNode extends ElementNode {
   /** @internal */
@@ -90,7 +82,7 @@ export class LinkNode extends ElementNode {
 
   createDOM(config: EditorConfig): LinkHTMLElementType {
     const element = document.createElement('a');
-    element.href = this.sanitizeUrl(this.__url);
+    element.href = this.__url;
     if (this.__target !== null) {
       element.target = this.__target;
     }
@@ -166,19 +158,6 @@ export class LinkNode extends ElementNode {
     return node;
   }
 
-  sanitizeUrl(url: string): string {
-    try {
-      const parsedUrl = new URL(url);
-      // eslint-disable-next-line no-script-url
-      if (!SUPPORTED_URL_PROTOCOLS.has(parsedUrl.protocol)) {
-        return 'about:blank';
-      }
-    } catch {
-      return url;
-    }
-    return url;
-  }
-
   exportJSON(): SerializedLinkNode | SerializedAutoLinkNode {
     return {
       ...super.exportJSON(),
diff --git a/resources/js/wysiwyg/services/__tests__/mouse-handling.test.ts b/resources/js/wysiwyg/services/__tests__/mouse-handling.test.ts
new file mode 100644 (file)
index 0000000..a3da352
--- /dev/null
@@ -0,0 +1,51 @@
+import {
+    createTestContext, destroyFromContext, dispatchEditorMouseClick,
+} from "lexical/__tests__/utils";
+import {
+    $getRoot, LexicalEditor, LexicalNode,
+    ParagraphNode,
+} from "lexical";
+import {registerRichText} from "@lexical/rich-text";
+import {EditorUiContext} from "../../ui/framework/core";
+import {registerMouseHandling} from "../mouse-handling";
+import {$createTableNode, TableNode} from "@lexical/table";
+
+describe('Mouse-handling service tests', () => {
+
+    let context!: EditorUiContext;
+    let editor!: LexicalEditor;
+
+    beforeEach(() => {
+        context = createTestContext();
+        editor = context.editor;
+        registerRichText(editor);
+        registerMouseHandling(context);
+    });
+
+    afterEach(() => {
+        destroyFromContext(context);
+    });
+
+    test('Click below last table inserts new empty paragraph', () => {
+        let tableNode!: TableNode;
+        let lastRootChild!: LexicalNode|null;
+
+        editor.updateAndCommit(() => {
+            tableNode = $createTableNode();
+            $getRoot().append(tableNode);
+            lastRootChild = $getRoot().getLastChild();
+        });
+
+        expect(lastRootChild).toBeInstanceOf(TableNode);
+
+        const tableDOM = editor.getElementByKey(tableNode.getKey());
+        const rect = tableDOM?.getBoundingClientRect();
+        dispatchEditorMouseClick(editor, 0, (rect?.bottom || 0) + 1)
+
+        editor.getEditorState().read(() => {
+            lastRootChild = $getRoot().getLastChild();
+        });
+
+        expect(lastRootChild).toBeInstanceOf(ParagraphNode);
+    });
+});
\ No newline at end of file
diff --git a/resources/js/wysiwyg/services/mouse-handling.ts b/resources/js/wysiwyg/services/mouse-handling.ts
new file mode 100644 (file)
index 0000000..058efc8
--- /dev/null
@@ -0,0 +1,63 @@
+import {EditorUiContext} from "../ui/framework/core";
+import {
+    $createParagraphNode, $getRoot,
+    $getSelection,
+    $isDecoratorNode, CLICK_COMMAND,
+    COMMAND_PRIORITY_LOW, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND,
+    KEY_BACKSPACE_COMMAND,
+    KEY_DELETE_COMMAND,
+    KEY_ENTER_COMMAND, KEY_TAB_COMMAND,
+    LexicalEditor,
+    LexicalNode
+} from "lexical";
+import {$isImageNode} from "@lexical/rich-text/LexicalImageNode";
+import {$isMediaNode} from "@lexical/rich-text/LexicalMediaNode";
+import {getLastSelection} from "../utils/selection";
+import {$getNearestNodeBlockParent, $getParentOfType, $selectOrCreateAdjacent} from "../utils/nodes";
+import {$setInsetForSelection} from "../utils/lists";
+import {$isListItemNode} from "@lexical/list";
+import {$isDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
+import {$isDiagramNode} from "../utils/diagrams";
+import {$isTableNode} from "@lexical/table";
+
+function isHardToEscapeNode(node: LexicalNode): boolean {
+    return $isDecoratorNode(node) || $isImageNode(node) || $isMediaNode(node) || $isDiagramNode(node) || $isTableNode(node);
+}
+
+function insertBelowLastNode(context: EditorUiContext, event: MouseEvent): boolean {
+    const lastNode = $getRoot().getLastChild();
+    if (!lastNode || !isHardToEscapeNode(lastNode)) {
+        return false;
+    }
+
+    const lastNodeDom = context.editor.getElementByKey(lastNode.getKey());
+    if (!lastNodeDom) {
+        return false;
+    }
+
+    const nodeBounds = lastNodeDom.getBoundingClientRect();
+    const isClickBelow = event.clientY > nodeBounds.bottom;
+    if (isClickBelow) {
+        context.editor.update(() => {
+            const newNode = $createParagraphNode();
+            $getRoot().append(newNode);
+            newNode.select();
+        });
+        return true;
+    }
+
+    return false;
+}
+
+
+export function registerMouseHandling(context: EditorUiContext): () => void {
+    const unregisterClick = context.editor.registerCommand(CLICK_COMMAND, (event): boolean => {
+        insertBelowLastNode(context, event);
+        return false;
+    }, COMMAND_PRIORITY_LOW);
+
+
+    return () => {
+        unregisterClick();
+    };
+}
\ No newline at end of file