From 97b201f61f98aaccf779d08634c247c8cfbbfbb5 Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Sat, 14 Dec 2024 12:35:13 +0000
Subject: [PATCH] Lexical: Added auto links on enter/space

---
 resources/js/wysiwyg/index.ts                 |  2 +
 .../services/__tests__/auto-links.test.ts     | 91 +++++++++++++++++++
 resources/js/wysiwyg/services/auto-links.ts   | 74 +++++++++++++++
 resources/js/wysiwyg/utils/selection.ts       |  4 +
 4 files changed, 171 insertions(+)
 create mode 100644 resources/js/wysiwyg/services/__tests__/auto-links.test.ts
 create mode 100644 resources/js/wysiwyg/services/auto-links.ts

diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts
index 9066b402f..510ab1f92 100644
--- a/resources/js/wysiwyg/index.ts
+++ b/resources/js/wysiwyg/index.ts
@@ -15,6 +15,7 @@ import {el} from "./utils/dom";
 import {registerShortcuts} from "./services/shortcuts";
 import {registerNodeResizer} from "./ui/framework/helpers/node-resizer";
 import {registerKeyboardHandling} from "./services/keyboard-handling";
+import {registerAutoLinks} from "./services/auto-links";
 
 export function createPageEditorInstance(container: HTMLElement, htmlContent: string, options: Record<string, any> = {}): SimpleWysiwygEditorInterface {
     const config: CreateEditorArgs = {
@@ -64,6 +65,7 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st
         registerTaskListHandler(editor, editArea),
         registerDropPasteHandling(context),
         registerNodeResizer(context),
+        registerAutoLinks(editor),
     );
 
     listenToCommonEvents(editor);
diff --git a/resources/js/wysiwyg/services/__tests__/auto-links.test.ts b/resources/js/wysiwyg/services/__tests__/auto-links.test.ts
new file mode 100644
index 000000000..d3b120b70
--- /dev/null
+++ b/resources/js/wysiwyg/services/__tests__/auto-links.test.ts
@@ -0,0 +1,91 @@
+import {initializeUnitTest} from "lexical/__tests__/utils";
+import {SerializedLinkNode} from "@lexical/link";
+import {
+    $getRoot,
+    ParagraphNode,
+    SerializedParagraphNode,
+    SerializedTextNode,
+    TextNode
+} from "lexical";
+import {registerAutoLinks} from "../auto-links";
+
+describe('Auto-link service tests', () => {
+    initializeUnitTest((testEnv) => {
+
+        test('space after link in text', async () => {
+            const {editor} = testEnv;
+
+            registerAutoLinks(editor);
+            let pNode!: ParagraphNode;
+
+            editor.update(() => {
+                pNode = new ParagraphNode();
+                const text = new TextNode('Some https://example.com?test=true text');
+                pNode.append(text);
+                $getRoot().append(pNode);
+
+                text.select(35, 35);
+            });
+
+            editor.commitUpdates();
+
+            const pDomEl = editor.getElementByKey(pNode.getKey());
+            const event = new KeyboardEvent('keydown', {
+                bubbles: true,
+                cancelable: true,
+                key: ' ',
+                keyCode: 62,
+            });
+            pDomEl?.dispatchEvent(event);
+
+            editor.commitUpdates();
+
+            const paragraph = editor!.getEditorState().toJSON().root
+                .children[0] as SerializedParagraphNode;
+            expect(paragraph.children[1].type).toBe('link');
+
+            const link = paragraph.children[1] as SerializedLinkNode;
+            expect(link.url).toBe('https://example.com?test=true');
+            const linkText = link.children[0] as SerializedTextNode;
+            expect(linkText.text).toBe('https://example.com?test=true');
+        });
+
+        test('enter after link in text', async () => {
+            const {editor} = testEnv;
+
+            registerAutoLinks(editor);
+            let pNode!: ParagraphNode;
+
+            editor.update(() => {
+                pNode = new ParagraphNode();
+                const text = new TextNode('Some https://example.com?test=true text');
+                pNode.append(text);
+                $getRoot().append(pNode);
+
+                text.select(35, 35);
+            });
+
+            editor.commitUpdates();
+
+            const pDomEl = editor.getElementByKey(pNode.getKey());
+            const event = new KeyboardEvent('keydown', {
+                bubbles: true,
+                cancelable: true,
+                key: 'Enter',
+                keyCode: 66,
+            });
+            pDomEl?.dispatchEvent(event);
+
+            editor.commitUpdates();
+
+            const paragraph = editor!.getEditorState().toJSON().root
+                .children[0] as SerializedParagraphNode;
+            expect(paragraph.children[1].type).toBe('link');
+
+            const link = paragraph.children[1] as SerializedLinkNode;
+            expect(link.url).toBe('https://example.com?test=true');
+            const linkText = link.children[0] as SerializedTextNode;
+            expect(linkText.text).toBe('https://example.com?test=true');
+        });
+    });
+});
\ No newline at end of file
diff --git a/resources/js/wysiwyg/services/auto-links.ts b/resources/js/wysiwyg/services/auto-links.ts
new file mode 100644
index 000000000..44a78ec85
--- /dev/null
+++ b/resources/js/wysiwyg/services/auto-links.ts
@@ -0,0 +1,74 @@
+import {
+    $getSelection, BaseSelection,
+    COMMAND_PRIORITY_NORMAL,
+    KEY_ENTER_COMMAND,
+    KEY_SPACE_COMMAND,
+    LexicalEditor,
+    TextNode
+} from "lexical";
+import {$getTextNodeFromSelection} from "../utils/selection";
+import {$createLinkNode, LinkNode} from "@lexical/link";
+
+
+function isLinkText(text: string): boolean {
+    const lower = text.toLowerCase();
+    if (!lower.startsWith('http')) {
+        return false;
+    }
+
+    const linkRegex = /(http|https):\/\/(\S+)\.\S+$/;
+    return linkRegex.test(text);
+}
+
+
+function handlePotentialLinkEvent(node: TextNode, selection: BaseSelection, editor: LexicalEditor) {
+    const selectionRange = selection.getStartEndPoints();
+    if (!selectionRange) {
+        return;
+    }
+
+    const cursorPoint = selectionRange[0].offset - 1;
+    const nodeText = node.getTextContent();
+    const rTrimText = nodeText.slice(0, cursorPoint);
+    const priorSpaceIndex = rTrimText.lastIndexOf(' ');
+    const startIndex = priorSpaceIndex + 1;
+    const textSegment = nodeText.slice(startIndex, cursorPoint);
+
+    if (!isLinkText(textSegment)) {
+        return;
+    }
+
+    editor.update(() => {
+        const linkNode: LinkNode = $createLinkNode(textSegment);
+        linkNode.append(new TextNode(textSegment));
+
+        const splits = node.splitText(startIndex, cursorPoint);
+        const targetIndex = splits.length === 3 ? 1 : 0;
+        const targetText = splits[targetIndex];
+        if (targetText) {
+            targetText.replace(linkNode);
+        }
+    });
+}
+
+
+export function registerAutoLinks(editor: LexicalEditor): () => void {
+
+    const handler = (payload: KeyboardEvent): boolean => {
+        const selection = $getSelection();
+        const textNode = $getTextNodeFromSelection(selection);
+        if (textNode && selection) {
+            handlePotentialLinkEvent(textNode, selection, editor);
+        }
+
+        return false;
+    };
+
+    const unregisterSpace = editor.registerCommand(KEY_SPACE_COMMAND, handler, COMMAND_PRIORITY_NORMAL);
+    const unregisterEnter = editor.registerCommand(KEY_ENTER_COMMAND, handler, COMMAND_PRIORITY_NORMAL);
+
+    return (): void => {
+        unregisterSpace();
+        unregisterEnter();
+    };
+}
\ No newline at end of file
diff --git a/resources/js/wysiwyg/utils/selection.ts b/resources/js/wysiwyg/utils/selection.ts
index 28e729e92..167ab32ad 100644
--- a/resources/js/wysiwyg/utils/selection.ts
+++ b/resources/js/wysiwyg/utils/selection.ts
@@ -51,6 +51,10 @@ export function $getNodeFromSelection(selection: BaseSelection | null, matcher:
     return null;
 }
 
+export function $getTextNodeFromSelection(selection: BaseSelection | null): TextNode|null {
+    return $getNodeFromSelection(selection, $isTextNode) as TextNode|null;
+}
+
 export function $selectionContainsTextFormat(selection: BaseSelection | null, format: TextFormatType): boolean {
     if (!selection) {
         return false;