From dbb2fe3e599e29bc7fdec7230c6388294f4750c8 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 20 Aug 2024 14:54:53 +0100 Subject: [PATCH] Lexical: Finished off baseline shortcut implementation --- resources/js/wysiwyg/index.ts | 4 +- resources/js/wysiwyg/services/shortcuts.ts | 51 +++++++++++----- resources/js/wysiwyg/todo.md | 3 +- .../js/wysiwyg/ui/defaults/buttons/lists.ts | 14 ++--- .../js/wysiwyg/ui/defaults/buttons/objects.ts | 29 ++------- .../js/wysiwyg/ui/defaults/forms/objects.ts | 60 +++++++++---------- resources/js/wysiwyg/utils/formats.ts | 48 ++++++++++++++- 7 files changed, 124 insertions(+), 85 deletions(-) diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index 0a939baf4..d7f873ea5 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -45,11 +45,12 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st const editor = createEditor(config); editor.setRootElement(editArea); + const context: EditorUiContext = buildEditorUI(container, editArea, editWrap, editor, options); mergeRegister( registerRichText(editor), registerHistory(editor, createEmptyHistoryState(), 300), - registerShortcuts(editor), + registerShortcuts(context), registerTableResizer(editor, editWrap), registerTableSelectionHandler(editor), registerTaskListHandler(editor, editArea), @@ -89,7 +90,6 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st console.log(editor.getEditorState().toJSON()); }; - const context: EditorUiContext = buildEditorUI(container, editArea, editWrap, editor, options); registerCommonNodeMutationListeners(context); return new SimpleWysiwygEditorInterface(editor); diff --git a/resources/js/wysiwyg/services/shortcuts.ts b/resources/js/wysiwyg/services/shortcuts.ts index 235c2788a..b17ec1bf7 100644 --- a/resources/js/wysiwyg/services/shortcuts.ts +++ b/resources/js/wysiwyg/services/shortcuts.ts @@ -1,12 +1,17 @@ -import {COMMAND_PRIORITY_HIGH, FORMAT_TEXT_COMMAND, KEY_ENTER_COMMAND, LexicalEditor} from "lexical"; +import {$getSelection, COMMAND_PRIORITY_HIGH, FORMAT_TEXT_COMMAND, KEY_ENTER_COMMAND, LexicalEditor} from "lexical"; import { cycleSelectionCalloutFormats, - formatCodeBlock, + formatCodeBlock, insertOrUpdateLink, toggleSelectionAsBlockquote, - toggleSelectionAsHeading, + toggleSelectionAsHeading, toggleSelectionAsList, toggleSelectionAsParagraph } from "../utils/formats"; import {HeadingTagType} from "@lexical/rich-text"; +import {EditorUiContext} from "../ui/framework/core"; +import {$getNodeFromSelection} from "../utils/selection"; +import {$isLinkNode, LinkNode} from "@lexical/link"; +import {$showLinkForm} from "../ui/defaults/forms/objects"; +import {showLinkSelector} from "../utils/links"; function headerHandler(editor: LexicalEditor, tag: HeadingTagType): boolean { toggleSelectionAsHeading(editor, tag); @@ -25,10 +30,9 @@ function toggleInlineCode(editor: LexicalEditor): boolean { return true; } -type ShortcutAction = (editor: LexicalEditor) => boolean; +type ShortcutAction = (editor: LexicalEditor, context: EditorUiContext) => boolean; const actionsByKeys: Record = { - // Save draft 'ctrl+s': () => { window.$events.emit('editor-save-draft'); return true; @@ -51,18 +55,35 @@ const actionsByKeys: Record = { 'ctrl+shift+e': toggleInlineCode, 'ctrl+9': wrapFormatAction(cycleSelectionCalloutFormats), - // TODO Lists - // TODO Links - // TODO Link selector + 'ctrl+o': wrapFormatAction((e) => toggleSelectionAsList(e, 'number')), + 'ctrl+p': wrapFormatAction((e) => toggleSelectionAsList(e, 'bullet')), + 'ctrl+k': (editor, context) => { + editor.getEditorState().read(() => { + const selectedLink = $getNodeFromSelection($getSelection(), $isLinkNode) as LinkNode | null; + $showLinkForm(selectedLink, context); + }); + return true; + }, + 'ctrl+shift+k': (editor, context) => { + showLinkSelector(entity => { + insertOrUpdateLink(editor, { + text: entity.name, + title: entity.link, + target: '', + url: entity.link, + }); + }); + return true; + }, }; -function createKeyDownListener(editor: LexicalEditor): (e: KeyboardEvent) => void { +function createKeyDownListener(context: EditorUiContext): (e: KeyboardEvent) => void { return (event: KeyboardEvent) => { // TODO - Mac Cmd support const combo = `${event.ctrlKey ? 'ctrl+' : ''}${event.shiftKey ? 'shift+' : ''}${event.key}`.toLowerCase(); - console.log(`pressed: ${combo}`); + // console.log(`pressed: ${combo}`); if (actionsByKeys[combo]) { - const handled = actionsByKeys[combo](editor); + const handled = actionsByKeys[combo](context.editor, context); if (handled) { event.stopPropagation(); event.preventDefault(); @@ -78,11 +99,11 @@ function overrideDefaultCommands(editor: LexicalEditor) { }, COMMAND_PRIORITY_HIGH); } -export function registerShortcuts(editor: LexicalEditor) { - const listener = createKeyDownListener(editor); - overrideDefaultCommands(editor); +export function registerShortcuts(context: EditorUiContext) { + const listener = createKeyDownListener(context); + overrideDefaultCommands(context.editor); - return editor.registerRootListener((rootElement: null | HTMLElement, prevRootElement: null | HTMLElement) => { + return context.editor.registerRootListener((rootElement: null | HTMLElement, prevRootElement: null | HTMLElement) => { // add the listener to the current root element rootElement?.addEventListener('keydown', listener); // remove the listener from the old root element diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 75263c927..f05e79baa 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -2,7 +2,7 @@ ## In progress -- Keyboard shortcuts support +// ## Main Todo @@ -13,6 +13,7 @@ - Media resize support (like images) - Table caption text support - Table Cut/Copy/Paste column +- Mac: Shortcut support via command. ## Secondary Todo diff --git a/resources/js/wysiwyg/ui/defaults/buttons/lists.ts b/resources/js/wysiwyg/ui/defaults/buttons/lists.ts index 10500eb67..edec3ea00 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/lists.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/lists.ts @@ -1,11 +1,12 @@ -import {$isListNode, insertList, ListNode, ListType, removeList} from "@lexical/list"; +import {$isListNode, ListNode, ListType} from "@lexical/list"; import {EditorButtonDefinition} from "../../framework/buttons"; import {EditorUiContext} from "../../framework/core"; -import {$getSelection, BaseSelection, LexicalNode} from "lexical"; +import {BaseSelection, LexicalNode} from "lexical"; import listBulletIcon from "@icons/editor/list-bullet.svg"; import listNumberedIcon from "@icons/editor/list-numbered.svg"; import listCheckIcon from "@icons/editor/list-check.svg"; import {$selectionContainsNodeType} from "../../../utils/selection"; +import {toggleSelectionAsList} from "../../../utils/formats"; function buildListButton(label: string, type: ListType, icon: string): EditorButtonDefinition { @@ -13,14 +14,7 @@ function buildListButton(label: string, type: ListType, icon: string): EditorBut label, icon, action(context: EditorUiContext) { - context.editor.getEditorState().read(() => { - const selection = $getSelection(); - if (this.isActive(selection, context)) { - removeList(context.editor); - } else { - insertList(context.editor, type); - } - }); + toggleSelectionAsList(context.editor, type); }, isActive(selection: BaseSelection|null): boolean { return $selectionContainsNodeType(selection, (node: LexicalNode | null | undefined): boolean => { diff --git a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts index 3494096a2..46556d3d1 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts @@ -2,11 +2,9 @@ import {EditorButtonDefinition} from "../../framework/buttons"; import linkIcon from "@icons/editor/link.svg"; import {EditorUiContext} from "../../framework/core"; import { - $createNodeSelection, $createTextNode, $getRoot, $getSelection, $insertNodes, - $setSelection, BaseSelection, ElementNode } from "lexical"; @@ -17,7 +15,7 @@ import {$isImageNode, ImageNode} from "../../../nodes/image"; import horizontalRuleIcon from "@icons/editor/horizontal-rule.svg"; import {$createHorizontalRuleNode, $isHorizontalRuleNode} from "../../../nodes/horizontal-rule"; import codeBlockIcon from "@icons/editor/code-block.svg"; -import {$createCodeBlockNode, $isCodeBlockNode, $openCodeEditorForNode, CodeBlockNode} from "../../../nodes/code-block"; +import {$isCodeBlockNode} from "../../../nodes/code-block"; import editIcon from "@icons/edit.svg"; import diagramIcon from "@icons/editor/diagram.svg"; import {$createDiagramNode, DiagramNode} from "../../../nodes/diagram"; @@ -32,35 +30,16 @@ import { } from "../../../utils/selection"; import {$isDiagramNode, $openDrawingEditorForNode, showDiagramManagerForInsert} from "../../../utils/diagrams"; import {$createLinkedImageNodeFromImageData, showImageManager} from "../../../utils/images"; -import {$showImageForm} from "../forms/objects"; +import {$showImageForm, $showLinkForm} from "../forms/objects"; import {formatCodeBlock} from "../../../utils/formats"; export const link: EditorButtonDefinition = { label: 'Insert/edit link', icon: linkIcon, action(context: EditorUiContext) { - const linkModal = context.manager.createModal('link'); context.editor.getEditorState().read(() => { - const selection = $getSelection(); - const selectedLink = $getNodeFromSelection(selection, $isLinkNode) as LinkNode | null; - - let formDefaults = {}; - if (selectedLink) { - formDefaults = { - url: selectedLink.getURL(), - text: selectedLink.getTextContent(), - title: selectedLink.getTitle(), - target: selectedLink.getTarget(), - } - - context.editor.update(() => { - const selection = $createNodeSelection(); - selection.add(selectedLink.getKey()); - $setSelection(selection); - }); - } - - linkModal.show(formDefaults); + const selectedLink = $getNodeFromSelection($getSelection(), $isLinkNode) as LinkNode | null; + $showLinkForm(selectedLink, context); }); }, isActive(selection: BaseSelection | null): boolean { diff --git a/resources/js/wysiwyg/ui/defaults/forms/objects.ts b/resources/js/wysiwyg/ui/defaults/forms/objects.ts index 2aefe5414..714d5f64b 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/objects.ts @@ -5,9 +5,9 @@ import { EditorSelectFormFieldDefinition } from "../../framework/forms"; import {EditorUiContext} from "../../framework/core"; -import {$createTextNode, $getSelection, $insertNodes} from "lexical"; +import {$createNodeSelection, $createTextNode, $getSelection, $insertNodes, $setSelection} from "lexical"; import {$isImageNode, ImageNode} from "../../../nodes/image"; -import {$createLinkNode, $isLinkNode} from "@lexical/link"; +import {$createLinkNode, $isLinkNode, LinkNode} from "@lexical/link"; import {$createMediaNodeFromHtml, $createMediaNodeFromSrc, $isMediaNode, MediaNode} from "../../../nodes/media"; import {$insertNodeToNearestRoot} from "@lexical/utils"; import {$getNodeFromSelection, getLastSelection} from "../../../utils/selection"; @@ -19,6 +19,7 @@ import searchImageIcon from "@icons/editor/image-search.svg"; import searchIcon from "@icons/search.svg"; import {showLinkSelector} from "../../../utils/links"; import {LinkField} from "../../framework/blocks/link-field"; +import {insertOrUpdateLink} from "../../../utils/formats"; export function $showImageForm(image: ImageNode, context: EditorUiContext) { const imageModal: EditorFormModal = context.manager.createModal('image'); @@ -96,37 +97,36 @@ export const image: EditorFormDefinition = { ], }; +export function $showLinkForm(link: LinkNode|null, context: EditorUiContext) { + const linkModal = context.manager.createModal('link'); + + let formDefaults = {}; + if (link) { + formDefaults = { + url: link.getURL(), + text: link.getTextContent(), + title: link.getTitle(), + target: link.getTarget(), + } + + context.editor.update(() => { + const selection = $createNodeSelection(); + selection.add(link.getKey()); + $setSelection(selection); + }); + } + + linkModal.show(formDefaults); +} + export const link: EditorFormDefinition = { submitText: 'Apply', async action(formData, context: EditorUiContext) { - context.editor.update(() => { - - const url = formData.get('url')?.toString() || ''; - const title = formData.get('title')?.toString() || '' - const target = formData.get('target')?.toString() || ''; - const text = formData.get('text')?.toString() || ''; - - const selection = $getSelection(); - let link = $getNodeFromSelection(selection, $isLinkNode); - if ($isLinkNode(link)) { - link.setURL(url); - link.setTarget(target); - link.setTitle(title); - } else { - link = $createLinkNode(url, { - title: title, - target: target, - }); - - $insertNodes([link]); - } - - if ($isLinkNode(link)) { - for (const child of link.getChildren()) { - child.remove(true); - } - link.append($createTextNode(text)); - } + insertOrUpdateLink(context.editor, { + url: formData.get('url')?.toString() || '', + title: formData.get('title')?.toString() || '', + target: formData.get('target')?.toString() || '', + text: formData.get('text')?.toString() || '', }); return true; }, diff --git a/resources/js/wysiwyg/utils/formats.ts b/resources/js/wysiwyg/utils/formats.ts index 340be393d..97038f07b 100644 --- a/resources/js/wysiwyg/utils/formats.ts +++ b/resources/js/wysiwyg/utils/formats.ts @@ -1,9 +1,9 @@ import {$isQuoteNode, HeadingNode, HeadingTagType} from "@lexical/rich-text"; -import {$getSelection, LexicalEditor, LexicalNode} from "lexical"; +import {$createTextNode, $getSelection, $insertNodes, LexicalEditor, LexicalNode} from "lexical"; import { $getBlockElementNodesInSelection, $getNodeFromSelection, - $insertNewBlockNodeAtSelection, + $insertNewBlockNodeAtSelection, $selectionContainsNodeType, $toggleSelectionBlockNodeType, getLastSelection } from "./selection"; @@ -12,6 +12,9 @@ import {$createCustomParagraphNode, $isCustomParagraphNode} from "../nodes/custo import {$createCustomQuoteNode} from "../nodes/custom-quote"; import {$createCodeBlockNode, $isCodeBlockNode, $openCodeEditorForNode, CodeBlockNode} from "../nodes/code-block"; import {$createCalloutNode, $isCalloutNode, CalloutCategory} from "../nodes/callout"; +import {insertList, ListNode, ListType, removeList} from "@lexical/list"; +import {$isCustomListNode} from "../nodes/custom-list"; +import {$createLinkNode, $isLinkNode} from "@lexical/link"; const $isHeaderNodeOfTag = (node: LexicalNode | null | undefined, tag: HeadingTagType) => { return $isCustomHeadingNode(node) && (node as HeadingNode).getTag() === tag; @@ -38,6 +41,21 @@ export function toggleSelectionAsBlockquote(editor: LexicalEditor) { }); } +export function toggleSelectionAsList(editor: LexicalEditor, type: ListType) { + editor.getEditorState().read(() => { + const selection = $getSelection(); + const listSelected = $selectionContainsNodeType(selection, (node: LexicalNode | null | undefined): boolean => { + return $isCustomListNode(node) && (node as ListNode).getListType() === type; + }); + + if (listSelected) { + removeList(editor); + } else { + insertList(editor, type); + } + }); +} + export function formatCodeBlock(editor: LexicalEditor) { editor.getEditorState().read(() => { const selection = $getSelection(); @@ -85,4 +103,30 @@ export function cycleSelectionCalloutFormats(editor: LexicalEditor) { } } }); +} + +export function insertOrUpdateLink(editor: LexicalEditor, linkDetails: {text: string, title: string, target: string, url: string}) { + editor.update(() => { + const selection = $getSelection(); + let link = $getNodeFromSelection(selection, $isLinkNode); + if ($isLinkNode(link)) { + link.setURL(linkDetails.url); + link.setTarget(linkDetails.target); + link.setTitle(linkDetails.title); + } else { + link = $createLinkNode(linkDetails.url, { + title: linkDetails.title, + target: linkDetails.target, + }); + + $insertNodes([link]); + } + + if ($isLinkNode(link)) { + for (const child of link.getChildren()) { + child.remove(true); + } + link.append($createTextNode(linkDetails.text)); + } + }); } \ No newline at end of file