mirror of
				https://github.com/BookStackApp/BookStack.git
				synced 2025-11-03 02:13:16 +03:00 
			
		
		
		
	Lexical: Added auto links on enter/space
This commit is contained in:
		@@ -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;
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user