From c54101c603a3e8e06c372de673a811d41a1f1f9d Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 25 Jul 2025 13:58:48 +0100 Subject: [PATCH] Lexical: Updated URL handling, added mouse handling - 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 | 2 + .../lexical/core/__tests__/utils/index.ts | 16 +++++ resources/js/wysiwyg/lexical/link/index.ts | 23 +------ .../services/__tests__/mouse-handling.test.ts | 51 +++++++++++++++ .../js/wysiwyg/services/mouse-handling.ts | 63 +++++++++++++++++++ 5 files changed, 133 insertions(+), 22 deletions(-) create mode 100644 resources/js/wysiwyg/services/__tests__/mouse-handling.test.ts create mode 100644 resources/js/wysiwyg/services/mouse-handling.ts diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index f572f9de5..e01b4e8f4 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -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), diff --git a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts index e18ef9756..fd87877ee 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts @@ -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 diff --git a/resources/js/wysiwyg/lexical/link/index.ts b/resources/js/wysiwyg/lexical/link/index.ts index 884fe9153..336bb1546 100644 --- a/resources/js/wysiwyg/lexical/link/index.ts +++ b/resources/js/wysiwyg/lexical/link/index.ts @@ -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 index 000000000..a3da35206 --- /dev/null +++ b/resources/js/wysiwyg/services/__tests__/mouse-handling.test.ts @@ -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 index 000000000..058efc8d2 --- /dev/null +++ b/resources/js/wysiwyg/services/mouse-handling.ts @@ -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