Lexical: Added auto links on enter/space

This commit is contained in:
Dan Brown 2024-12-14 12:35:13 +00:00
parent a8ef820443
commit 97b201f61f
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
4 changed files with 171 additions and 0 deletions

View File

@ -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);

View File

@ -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');
});
});
});

View File

@ -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();
};
}

View File

@ -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;