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;