Skip to content

Commit 97b201f

Browse files
committed
Lexical: Added auto links on enter/space
1 parent a8ef820 commit 97b201f

File tree

4 files changed

+171
-0
lines changed

4 files changed

+171
-0
lines changed

resources/js/wysiwyg/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {el} from "./utils/dom";
1515
import {registerShortcuts} from "./services/shortcuts";
1616
import {registerNodeResizer} from "./ui/framework/helpers/node-resizer";
1717
import {registerKeyboardHandling} from "./services/keyboard-handling";
18+
import {registerAutoLinks} from "./services/auto-links";
1819

1920
export function createPageEditorInstance(container: HTMLElement, htmlContent: string, options: Record<string, any> = {}): SimpleWysiwygEditorInterface {
2021
const config: CreateEditorArgs = {
@@ -64,6 +65,7 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st
6465
registerTaskListHandler(editor, editArea),
6566
registerDropPasteHandling(context),
6667
registerNodeResizer(context),
68+
registerAutoLinks(editor),
6769
);
6870

6971
listenToCommonEvents(editor);
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import {initializeUnitTest} from "lexical/__tests__/utils";
2+
import {SerializedLinkNode} from "@lexical/link";
3+
import {
4+
$getRoot,
5+
ParagraphNode,
6+
SerializedParagraphNode,
7+
SerializedTextNode,
8+
TextNode
9+
} from "lexical";
10+
import {registerAutoLinks} from "../auto-links";
11+
12+
describe('Auto-link service tests', () => {
13+
initializeUnitTest((testEnv) => {
14+
15+
test('space after link in text', async () => {
16+
const {editor} = testEnv;
17+
18+
registerAutoLinks(editor);
19+
let pNode!: ParagraphNode;
20+
21+
editor.update(() => {
22+
pNode = new ParagraphNode();
23+
const text = new TextNode('Some https://example.com?test=true text');
24+
pNode.append(text);
25+
$getRoot().append(pNode);
26+
27+
text.select(35, 35);
28+
});
29+
30+
editor.commitUpdates();
31+
32+
const pDomEl = editor.getElementByKey(pNode.getKey());
33+
const event = new KeyboardEvent('keydown', {
34+
bubbles: true,
35+
cancelable: true,
36+
key: ' ',
37+
keyCode: 62,
38+
});
39+
pDomEl?.dispatchEvent(event);
40+
41+
editor.commitUpdates();
42+
43+
const paragraph = editor!.getEditorState().toJSON().root
44+
.children[0] as SerializedParagraphNode;
45+
expect(paragraph.children[1].type).toBe('link');
46+
47+
const link = paragraph.children[1] as SerializedLinkNode;
48+
expect(link.url).toBe('https://example.com?test=true');
49+
const linkText = link.children[0] as SerializedTextNode;
50+
expect(linkText.text).toBe('https://example.com?test=true');
51+
});
52+
53+
test('enter after link in text', async () => {
54+
const {editor} = testEnv;
55+
56+
registerAutoLinks(editor);
57+
let pNode!: ParagraphNode;
58+
59+
editor.update(() => {
60+
pNode = new ParagraphNode();
61+
const text = new TextNode('Some https://example.com?test=true text');
62+
pNode.append(text);
63+
$getRoot().append(pNode);
64+
65+
text.select(35, 35);
66+
});
67+
68+
editor.commitUpdates();
69+
70+
const pDomEl = editor.getElementByKey(pNode.getKey());
71+
const event = new KeyboardEvent('keydown', {
72+
bubbles: true,
73+
cancelable: true,
74+
key: 'Enter',
75+
keyCode: 66,
76+
});
77+
pDomEl?.dispatchEvent(event);
78+
79+
editor.commitUpdates();
80+
81+
const paragraph = editor!.getEditorState().toJSON().root
82+
.children[0] as SerializedParagraphNode;
83+
expect(paragraph.children[1].type).toBe('link');
84+
85+
const link = paragraph.children[1] as SerializedLinkNode;
86+
expect(link.url).toBe('https://example.com?test=true');
87+
const linkText = link.children[0] as SerializedTextNode;
88+
expect(linkText.text).toBe('https://example.com?test=true');
89+
});
90+
});
91+
});
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import {
2+
$getSelection, BaseSelection,
3+
COMMAND_PRIORITY_NORMAL,
4+
KEY_ENTER_COMMAND,
5+
KEY_SPACE_COMMAND,
6+
LexicalEditor,
7+
TextNode
8+
} from "lexical";
9+
import {$getTextNodeFromSelection} from "../utils/selection";
10+
import {$createLinkNode, LinkNode} from "@lexical/link";
11+
12+
13+
function isLinkText(text: string): boolean {
14+
const lower = text.toLowerCase();
15+
if (!lower.startsWith('http')) {
16+
return false;
17+
}
18+
19+
const linkRegex = /(http|https):\/\/(\S+)\.\S+$/;
20+
return linkRegex.test(text);
21+
}
22+
23+
24+
function handlePotentialLinkEvent(node: TextNode, selection: BaseSelection, editor: LexicalEditor) {
25+
const selectionRange = selection.getStartEndPoints();
26+
if (!selectionRange) {
27+
return;
28+
}
29+
30+
const cursorPoint = selectionRange[0].offset - 1;
31+
const nodeText = node.getTextContent();
32+
const rTrimText = nodeText.slice(0, cursorPoint);
33+
const priorSpaceIndex = rTrimText.lastIndexOf(' ');
34+
const startIndex = priorSpaceIndex + 1;
35+
const textSegment = nodeText.slice(startIndex, cursorPoint);
36+
37+
if (!isLinkText(textSegment)) {
38+
return;
39+
}
40+
41+
editor.update(() => {
42+
const linkNode: LinkNode = $createLinkNode(textSegment);
43+
linkNode.append(new TextNode(textSegment));
44+
45+
const splits = node.splitText(startIndex, cursorPoint);
46+
const targetIndex = splits.length === 3 ? 1 : 0;
47+
const targetText = splits[targetIndex];
48+
if (targetText) {
49+
targetText.replace(linkNode);
50+
}
51+
});
52+
}
53+
54+
55+
export function registerAutoLinks(editor: LexicalEditor): () => void {
56+
57+
const handler = (payload: KeyboardEvent): boolean => {
58+
const selection = $getSelection();
59+
const textNode = $getTextNodeFromSelection(selection);
60+
if (textNode && selection) {
61+
handlePotentialLinkEvent(textNode, selection, editor);
62+
}
63+
64+
return false;
65+
};
66+
67+
const unregisterSpace = editor.registerCommand(KEY_SPACE_COMMAND, handler, COMMAND_PRIORITY_NORMAL);
68+
const unregisterEnter = editor.registerCommand(KEY_ENTER_COMMAND, handler, COMMAND_PRIORITY_NORMAL);
69+
70+
return (): void => {
71+
unregisterSpace();
72+
unregisterEnter();
73+
};
74+
}

resources/js/wysiwyg/utils/selection.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ export function $getNodeFromSelection(selection: BaseSelection | null, matcher:
5151
return null;
5252
}
5353

54+
export function $getTextNodeFromSelection(selection: BaseSelection | null): TextNode|null {
55+
return $getNodeFromSelection(selection, $isTextNode) as TextNode|null;
56+
}
57+
5458
export function $selectionContainsTextFormat(selection: BaseSelection | null, format: TextFormatType): boolean {
5559
if (!selection) {
5660
return false;

0 commit comments

Comments
 (0)