mirror of
https://github.com/BookStackApp/BookStack.git
synced 2025-02-01 00:12:02 +08:00
Lexical: Added auto links on enter/space
This commit is contained in:
parent
a8ef820443
commit
97b201f61f
|
@ -15,6 +15,7 @@ import {el} from "./utils/dom";
|
||||||
import {registerShortcuts} from "./services/shortcuts";
|
import {registerShortcuts} from "./services/shortcuts";
|
||||||
import {registerNodeResizer} from "./ui/framework/helpers/node-resizer";
|
import {registerNodeResizer} from "./ui/framework/helpers/node-resizer";
|
||||||
import {registerKeyboardHandling} from "./services/keyboard-handling";
|
import {registerKeyboardHandling} from "./services/keyboard-handling";
|
||||||
|
import {registerAutoLinks} from "./services/auto-links";
|
||||||
|
|
||||||
export function createPageEditorInstance(container: HTMLElement, htmlContent: string, options: Record<string, any> = {}): SimpleWysiwygEditorInterface {
|
export function createPageEditorInstance(container: HTMLElement, htmlContent: string, options: Record<string, any> = {}): SimpleWysiwygEditorInterface {
|
||||||
const config: CreateEditorArgs = {
|
const config: CreateEditorArgs = {
|
||||||
|
@ -64,6 +65,7 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st
|
||||||
registerTaskListHandler(editor, editArea),
|
registerTaskListHandler(editor, editArea),
|
||||||
registerDropPasteHandling(context),
|
registerDropPasteHandling(context),
|
||||||
registerNodeResizer(context),
|
registerNodeResizer(context),
|
||||||
|
registerAutoLinks(editor),
|
||||||
);
|
);
|
||||||
|
|
||||||
listenToCommonEvents(editor);
|
listenToCommonEvents(editor);
|
||||||
|
|
91
resources/js/wysiwyg/services/__tests__/auto-links.test.ts
Normal file
91
resources/js/wysiwyg/services/__tests__/auto-links.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
74
resources/js/wysiwyg/services/auto-links.ts
Normal file
74
resources/js/wysiwyg/services/auto-links.ts
Normal 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();
|
||||||
|
};
|
||||||
|
}
|
|
@ -51,6 +51,10 @@ export function $getNodeFromSelection(selection: BaseSelection | null, matcher:
|
||||||
return null;
|
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 {
|
export function $selectionContainsTextFormat(selection: BaseSelection | null, format: TextFormatType): boolean {
|
||||||
if (!selection) {
|
if (!selection) {
|
||||||
return false;
|
return false;
|
||||||
|
|
Loading…
Reference in New Issue
Block a user