From aa1fac62d56a25044698afcdd48298bb74299d16 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 20 Aug 2024 13:07:33 +0100 Subject: [PATCH] Lexical: Started adding editor shortcuts --- resources/js/services/events.ts | 2 +- resources/js/wysiwyg/index.ts | 2 + resources/js/wysiwyg/services/shortcuts.ts | 91 +++++++++++++++++++ resources/js/wysiwyg/todo.md | 3 +- .../ui/defaults/buttons/block-formats.ts | 24 ++--- .../js/wysiwyg/ui/defaults/buttons/objects.ts | 28 ++---- .../js/wysiwyg/ui/defaults/forms/objects.ts | 5 +- resources/js/wysiwyg/ui/framework/core.ts | 1 - resources/js/wysiwyg/ui/framework/manager.ts | 9 +- resources/js/wysiwyg/ui/index.ts | 1 - resources/js/wysiwyg/utils/diagrams.ts | 4 +- resources/js/wysiwyg/utils/formats.ts | 88 ++++++++++++++++++ resources/js/wysiwyg/utils/selection.ts | 15 ++- 13 files changed, 223 insertions(+), 50 deletions(-) create mode 100644 resources/js/wysiwyg/services/shortcuts.ts create mode 100644 resources/js/wysiwyg/utils/formats.ts diff --git a/resources/js/services/events.ts b/resources/js/services/events.ts index 32c70f5a8..be9fba7ec 100644 --- a/resources/js/services/events.ts +++ b/resources/js/services/events.ts @@ -7,7 +7,7 @@ export class EventManager { /** * Emit a custom event for any handlers to pick-up. */ - emit(eventName: string, eventData: {}): void { + emit(eventName: string, eventData: {} = {}): void { this.stack.push({name: eventName, data: eventData}); const listenersToRun = this.listeners[eventName] || []; diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index a07fbd789..0a939baf4 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -12,6 +12,7 @@ import {handleDropEvents} from "./services/drop-handling"; import {registerTaskListHandler} from "./ui/framework/helpers/task-list-handler"; import {registerTableSelectionHandler} from "./ui/framework/helpers/table-selection-handler"; import {el} from "./utils/dom"; +import {registerShortcuts} from "./services/shortcuts"; export function createPageEditorInstance(container: HTMLElement, htmlContent: string, options: Record = {}): SimpleWysiwygEditorInterface { const config: CreateEditorArgs = { @@ -48,6 +49,7 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st mergeRegister( registerRichText(editor), registerHistory(editor, createEmptyHistoryState(), 300), + registerShortcuts(editor), registerTableResizer(editor, editWrap), registerTableSelectionHandler(editor), registerTaskListHandler(editor, editArea), diff --git a/resources/js/wysiwyg/services/shortcuts.ts b/resources/js/wysiwyg/services/shortcuts.ts new file mode 100644 index 000000000..235c2788a --- /dev/null +++ b/resources/js/wysiwyg/services/shortcuts.ts @@ -0,0 +1,91 @@ +import {COMMAND_PRIORITY_HIGH, FORMAT_TEXT_COMMAND, KEY_ENTER_COMMAND, LexicalEditor} from "lexical"; +import { + cycleSelectionCalloutFormats, + formatCodeBlock, + toggleSelectionAsBlockquote, + toggleSelectionAsHeading, + toggleSelectionAsParagraph +} from "../utils/formats"; +import {HeadingTagType} from "@lexical/rich-text"; + +function headerHandler(editor: LexicalEditor, tag: HeadingTagType): boolean { + toggleSelectionAsHeading(editor, tag); + return true; +} + +function wrapFormatAction(formatAction: (editor: LexicalEditor) => any): ShortcutAction { + return (editor: LexicalEditor) => { + formatAction(editor); + return true; + }; +} + +function toggleInlineCode(editor: LexicalEditor): boolean { + editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code'); + return true; +} + +type ShortcutAction = (editor: LexicalEditor) => boolean; + +const actionsByKeys: Record = { + // Save draft + 'ctrl+s': () => { + window.$events.emit('editor-save-draft'); + return true; + }, + 'ctrl+enter': () => { + window.$events.emit('editor-save-page'); + return true; + }, + 'ctrl+1': (editor) => headerHandler(editor, 'h1'), + 'ctrl+2': (editor) => headerHandler(editor, 'h2'), + 'ctrl+3': (editor) => headerHandler(editor, 'h3'), + 'ctrl+4': (editor) => headerHandler(editor, 'h4'), + 'ctrl+5': wrapFormatAction(toggleSelectionAsParagraph), + 'ctrl+d': wrapFormatAction(toggleSelectionAsParagraph), + 'ctrl+6': wrapFormatAction(toggleSelectionAsBlockquote), + 'ctrl+q': wrapFormatAction(toggleSelectionAsBlockquote), + 'ctrl+7': wrapFormatAction(formatCodeBlock), + 'ctrl+e': wrapFormatAction(formatCodeBlock), + 'ctrl+8': toggleInlineCode, + 'ctrl+shift+e': toggleInlineCode, + 'ctrl+9': wrapFormatAction(cycleSelectionCalloutFormats), + + // TODO Lists + // TODO Links + // TODO Link selector +}; + +function createKeyDownListener(editor: LexicalEditor): (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}`); + if (actionsByKeys[combo]) { + const handled = actionsByKeys[combo](editor); + if (handled) { + event.stopPropagation(); + event.preventDefault(); + } + } + }; +} + +function overrideDefaultCommands(editor: LexicalEditor) { + // Prevent default ctrl+enter command + editor.registerCommand(KEY_ENTER_COMMAND, (event) => { + return event?.ctrlKey ? true : false + }, COMMAND_PRIORITY_HIGH); +} + +export function registerShortcuts(editor: LexicalEditor) { + const listener = createKeyDownListener(editor); + overrideDefaultCommands(editor); + + return 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 + prevRootElement?.removeEventListener('keydown', listener); + }); +} \ No newline at end of file diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index fec38271a..75263c927 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -2,14 +2,13 @@ ## In progress -// +- Keyboard shortcuts support ## Main Todo - Alignments: Handle inline block content (image, video) - Image paste upload -- Keyboard shortcuts support - Support media src conversions (https://github.com/tinymce/tinymce/blob/release/6.6/modules/tinymce/src/plugins/media/main/ts/core/UrlPatterns.ts) - Media resize support (like images) - Table caption text support diff --git a/resources/js/wysiwyg/ui/defaults/buttons/block-formats.ts b/resources/js/wysiwyg/ui/defaults/buttons/block-formats.ts index eba903263..80e493486 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/block-formats.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/block-formats.ts @@ -1,16 +1,19 @@ import {$createCalloutNode, $isCalloutNodeOfCategory, CalloutCategory} from "../../../nodes/callout"; import {EditorButtonDefinition} from "../../framework/buttons"; import {EditorUiContext} from "../../framework/core"; -import {$createParagraphNode, $isParagraphNode, BaseSelection, LexicalNode} from "lexical"; +import {$isParagraphNode, BaseSelection, LexicalNode} from "lexical"; import { - $createHeadingNode, - $createQuoteNode, $isHeadingNode, $isQuoteNode, HeadingNode, HeadingTagType } from "@lexical/rich-text"; import {$selectionContainsNodeType, $toggleSelectionBlockNodeType} from "../../../utils/selection"; +import { + toggleSelectionAsBlockquote, + toggleSelectionAsHeading, + toggleSelectionAsParagraph +} from "../../../utils/formats"; function buildCalloutButton(category: CalloutCategory, name: string): EditorButtonDefinition { return { @@ -42,12 +45,7 @@ function buildHeaderButton(tag: HeadingTagType, name: string): EditorButtonDefin return { label: name, action(context: EditorUiContext) { - context.editor.update(() => { - $toggleSelectionBlockNodeType( - (node) => isHeaderNodeOfTag(node, tag), - () => $createHeadingNode(tag), - ) - }); + toggleSelectionAsHeading(context.editor, tag); }, isActive(selection: BaseSelection|null): boolean { return $selectionContainsNodeType(selection, (node) => isHeaderNodeOfTag(node, tag)); @@ -63,9 +61,7 @@ export const h5: EditorButtonDefinition = buildHeaderButton('h5', 'Tiny Header') export const blockquote: EditorButtonDefinition = { label: 'Blockquote', action(context: EditorUiContext) { - context.editor.update(() => { - $toggleSelectionBlockNodeType($isQuoteNode, $createQuoteNode); - }); + toggleSelectionAsBlockquote(context.editor); }, isActive(selection: BaseSelection|null): boolean { return $selectionContainsNodeType(selection, $isQuoteNode); @@ -75,9 +71,7 @@ export const blockquote: EditorButtonDefinition = { export const paragraph: EditorButtonDefinition = { label: 'Paragraph', action(context: EditorUiContext) { - context.editor.update(() => { - $toggleSelectionBlockNodeType($isParagraphNode, $createParagraphNode); - }); + toggleSelectionAsParagraph(context.editor); }, isActive(selection: BaseSelection|null): boolean { return $selectionContainsNodeType(selection, $isParagraphNode); diff --git a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts index 96a92ff22..3494096a2 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts @@ -28,11 +28,12 @@ import {$isMediaNode, MediaNode} from "../../../nodes/media"; import { $getNodeFromSelection, $insertNewBlockNodeAtSelection, - $selectionContainsNodeType + $selectionContainsNodeType, getLastSelection } from "../../../utils/selection"; import {$isDiagramNode, $openDrawingEditorForNode, showDiagramManagerForInsert} from "../../../utils/diagrams"; import {$createLinkedImageNodeFromImageData, showImageManager} from "../../../utils/images"; import {$showImageForm} from "../forms/objects"; +import {formatCodeBlock} from "../../../utils/formats"; export const link: EditorButtonDefinition = { label: 'Insert/edit link', @@ -72,7 +73,7 @@ export const unlink: EditorButtonDefinition = { icon: unlinkIcon, action(context: EditorUiContext) { context.editor.update(() => { - const selection = context.lastSelection; + const selection = getLastSelection(context.editor); const selectedLink = $getNodeFromSelection(selection, $isLinkNode) as LinkNode | null; const selectionPoints = selection?.getStartEndPoints(); @@ -98,7 +99,8 @@ export const image: EditorButtonDefinition = { icon: imageIcon, action(context: EditorUiContext) { context.editor.getEditorState().read(() => { - const selectedImage = $getNodeFromSelection(context.lastSelection, $isImageNode) as ImageNode | null; + const selection = getLastSelection(context.editor); + const selectedImage = $getNodeFromSelection(selection, $isImageNode) as ImageNode | null; if (selectedImage) { $showImageForm(selectedImage, context); return; @@ -134,21 +136,7 @@ export const codeBlock: EditorButtonDefinition = { label: 'Insert code block', icon: codeBlockIcon, action(context: EditorUiContext) { - context.editor.getEditorState().read(() => { - const selection = $getSelection(); - const codeBlock = $getNodeFromSelection(context.lastSelection, $isCodeBlockNode) as (CodeBlockNode | null); - if (codeBlock === null) { - context.editor.update(() => { - const codeBlock = $createCodeBlockNode(); - codeBlock.setCode(selection?.getTextContent() || ''); - $insertNewBlockNodeAtSelection(codeBlock, true); - $openCodeEditorForNode(context.editor, codeBlock); - codeBlock.selectStart(); - }); - } else { - $openCodeEditorForNode(context.editor, codeBlock); - } - }); + formatCodeBlock(context.editor); }, isActive(selection: BaseSelection | null): boolean { return $selectionContainsNodeType(selection, $isCodeBlockNode); @@ -165,8 +153,8 @@ export const diagram: EditorButtonDefinition = { icon: diagramIcon, action(context: EditorUiContext) { context.editor.getEditorState().read(() => { - const selection = $getSelection(); - const diagramNode = $getNodeFromSelection(context.lastSelection, $isDiagramNode) as (DiagramNode | null); + const selection = getLastSelection(context.editor); + const diagramNode = $getNodeFromSelection(selection, $isDiagramNode) as (DiagramNode | null); if (diagramNode === null) { context.editor.update(() => { const diagram = $createDiagramNode(); diff --git a/resources/js/wysiwyg/ui/defaults/forms/objects.ts b/resources/js/wysiwyg/ui/defaults/forms/objects.ts index 2ad27f749..2aefe5414 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/objects.ts @@ -10,7 +10,7 @@ import {$isImageNode, ImageNode} from "../../../nodes/image"; import {$createLinkNode, $isLinkNode} from "@lexical/link"; import {$createMediaNodeFromHtml, $createMediaNodeFromSrc, $isMediaNode, MediaNode} from "../../../nodes/media"; import {$insertNodeToNearestRoot} from "@lexical/utils"; -import {$getNodeFromSelection} from "../../../utils/selection"; +import {$getNodeFromSelection, getLastSelection} from "../../../utils/selection"; import {EditorFormModal} from "../../framework/modals"; import {EditorActionField} from "../../framework/blocks/action-field"; import {EditorButton} from "../../framework/buttons"; @@ -39,7 +39,8 @@ export const image: EditorFormDefinition = { submitText: 'Apply', async action(formData, context: EditorUiContext) { context.editor.update(() => { - const selectedImage = $getNodeFromSelection(context.lastSelection, $isImageNode); + const selection = getLastSelection(context.editor); + const selectedImage = $getNodeFromSelection(selection, $isImageNode); if ($isImageNode(selectedImage)) { selectedImage.setSrc(formData.get('src')?.toString() || ''); selectedImage.setAltText(formData.get('alt')?.toString() || ''); diff --git a/resources/js/wysiwyg/ui/framework/core.ts b/resources/js/wysiwyg/ui/framework/core.ts index 3e9f1e3d9..b6fe52dcd 100644 --- a/resources/js/wysiwyg/ui/framework/core.ts +++ b/resources/js/wysiwyg/ui/framework/core.ts @@ -15,7 +15,6 @@ export type EditorUiContext = { scrollDOM: HTMLElement; // DOM element which is the main content scroll container translate: (text: string) => string; // Translate function manager: EditorUIManager; // UI Manager instance for this editor - lastSelection: BaseSelection|null; // The last tracked selection made by the user options: Record; // General user options which may be used by sub elements }; diff --git a/resources/js/wysiwyg/ui/framework/manager.ts b/resources/js/wysiwyg/ui/framework/manager.ts index 92891b540..f10e85b47 100644 --- a/resources/js/wysiwyg/ui/framework/manager.ts +++ b/resources/js/wysiwyg/ui/framework/manager.ts @@ -5,6 +5,7 @@ import {$getSelection, BaseSelection, COMMAND_PRIORITY_LOW, LexicalEditor, SELEC import {DecoratorListener} from "lexical/LexicalEditor"; import type {NodeKey} from "lexical/LexicalNode"; import {EditorContextToolbar, EditorContextToolbarDefinition} from "./toolbars"; +import {getLastSelection, setLastSelection} from "../../utils/selection"; export type SelectionChangeHandler = (selection: BaseSelection|null) => void; @@ -108,8 +109,7 @@ export class EditorUIManager { } protected triggerStateUpdate(update: EditorUiStateUpdate): void { - const context = this.getContext(); - context.lastSelection = update.selection; + setLastSelection(update.editor, update.selection); this.toolbar?.updateState(update); this.updateContextToolbars(update); for (const toolbar of this.activeContextToolbars) { @@ -119,9 +119,10 @@ export class EditorUIManager { } triggerStateRefresh(): void { + const editor = this.getContext().editor; this.triggerStateUpdate({ - editor: this.getContext().editor, - selection: this.getContext().lastSelection, + editor, + selection: getLastSelection(editor), }); } diff --git a/resources/js/wysiwyg/ui/index.ts b/resources/js/wysiwyg/ui/index.ts index 5fbaec91b..116d6e1fc 100644 --- a/resources/js/wysiwyg/ui/index.ts +++ b/resources/js/wysiwyg/ui/index.ts @@ -21,7 +21,6 @@ export function buildEditorUI(container: HTMLElement, element: HTMLElement, scro scrollDOM: scrollContainer, manager, translate: (text: string): string => text, - lastSelection: null, options, }; manager.setContext(context); diff --git a/resources/js/wysiwyg/utils/diagrams.ts b/resources/js/wysiwyg/utils/diagrams.ts index 2dee3ab6b..fb5543005 100644 --- a/resources/js/wysiwyg/utils/diagrams.ts +++ b/resources/js/wysiwyg/utils/diagrams.ts @@ -5,7 +5,7 @@ import * as DrawIO from "../../services/drawio"; import {$createDiagramNode, DiagramNode} from "../nodes/diagram"; import {ImageManager} from "../../components"; import {EditorImageData} from "./images"; -import {$getNodeFromSelection} from "./selection"; +import {$getNodeFromSelection, getLastSelection} from "./selection"; export function $isDiagramNode(node: LexicalNode | null | undefined): node is DiagramNode { return node instanceof DiagramNode; @@ -80,7 +80,7 @@ export function showDiagramManager(callback: (image: EditorImageData) => any) { } export function showDiagramManagerForInsert(context: EditorUiContext) { - const selection = context.lastSelection; + const selection = getLastSelection(context.editor); showDiagramManager((image: EditorImageData) => { context.editor.update(() => { const diagramNode = $createDiagramNode(image.id, image.url); diff --git a/resources/js/wysiwyg/utils/formats.ts b/resources/js/wysiwyg/utils/formats.ts new file mode 100644 index 000000000..340be393d --- /dev/null +++ b/resources/js/wysiwyg/utils/formats.ts @@ -0,0 +1,88 @@ +import {$isQuoteNode, HeadingNode, HeadingTagType} from "@lexical/rich-text"; +import {$getSelection, LexicalEditor, LexicalNode} from "lexical"; +import { + $getBlockElementNodesInSelection, + $getNodeFromSelection, + $insertNewBlockNodeAtSelection, + $toggleSelectionBlockNodeType, + getLastSelection +} from "./selection"; +import {$createCustomHeadingNode, $isCustomHeadingNode} from "../nodes/custom-heading"; +import {$createCustomParagraphNode, $isCustomParagraphNode} from "../nodes/custom-paragraph"; +import {$createCustomQuoteNode} from "../nodes/custom-quote"; +import {$createCodeBlockNode, $isCodeBlockNode, $openCodeEditorForNode, CodeBlockNode} from "../nodes/code-block"; +import {$createCalloutNode, $isCalloutNode, CalloutCategory} from "../nodes/callout"; + +const $isHeaderNodeOfTag = (node: LexicalNode | null | undefined, tag: HeadingTagType) => { + return $isCustomHeadingNode(node) && (node as HeadingNode).getTag() === tag; +}; + +export function toggleSelectionAsHeading(editor: LexicalEditor, tag: HeadingTagType) { + editor.update(() => { + $toggleSelectionBlockNodeType( + (node) => $isHeaderNodeOfTag(node, tag), + () => $createCustomHeadingNode(tag), + ) + }); +} + +export function toggleSelectionAsParagraph(editor: LexicalEditor) { + editor.update(() => { + $toggleSelectionBlockNodeType($isCustomParagraphNode, $createCustomParagraphNode); + }); +} + +export function toggleSelectionAsBlockquote(editor: LexicalEditor) { + editor.update(() => { + $toggleSelectionBlockNodeType($isQuoteNode, $createCustomQuoteNode); + }); +} + +export function formatCodeBlock(editor: LexicalEditor) { + editor.getEditorState().read(() => { + const selection = $getSelection(); + const lastSelection = getLastSelection(editor); + const codeBlock = $getNodeFromSelection(lastSelection, $isCodeBlockNode) as (CodeBlockNode | null); + if (codeBlock === null) { + editor.update(() => { + const codeBlock = $createCodeBlockNode(); + codeBlock.setCode(selection?.getTextContent() || ''); + $insertNewBlockNodeAtSelection(codeBlock, true); + $openCodeEditorForNode(editor, codeBlock); + codeBlock.selectStart(); + }); + } else { + $openCodeEditorForNode(editor, codeBlock); + } + }); +} + +export function cycleSelectionCalloutFormats(editor: LexicalEditor) { + editor.update(() => { + const selection = $getSelection(); + const blocks = $getBlockElementNodesInSelection(selection); + + let created = false; + for (const block of blocks) { + if (!$isCalloutNode(block)) { + block.replace($createCalloutNode('info'), true); + created = true; + } + } + + if (created) { + return; + } + + const types: CalloutCategory[] = ['info', 'warning', 'danger', 'success']; + for (const block of blocks) { + if ($isCalloutNode(block)) { + const type = block.getCategory(); + const typeIndex = types.indexOf(type); + const newIndex = (typeIndex + 1) % types.length; + const newType = types[newIndex]; + block.setCategory(newType); + } + } + }); +} \ No newline at end of file diff --git a/resources/js/wysiwyg/utils/selection.ts b/resources/js/wysiwyg/utils/selection.ts index e34afbe36..74dd94527 100644 --- a/resources/js/wysiwyg/utils/selection.ts +++ b/resources/js/wysiwyg/utils/selection.ts @@ -8,7 +8,7 @@ import { $setSelection, BaseSelection, ElementFormatType, - ElementNode, + ElementNode, LexicalEditor, LexicalNode, TextFormatType } from "lexical"; @@ -17,6 +17,17 @@ import {LexicalElementNodeCreator, LexicalNodeMatcher} from "../nodes"; import {$setBlocksType} from "@lexical/selection"; import {$getParentOfType} from "./nodes"; +import {$createCustomParagraphNode} from "../nodes/custom-paragraph"; + +const lastSelectionByEditor = new WeakMap; + +export function getLastSelection(editor: LexicalEditor): BaseSelection|null { + return lastSelectionByEditor.get(editor) || null; +} + +export function setLastSelection(editor: LexicalEditor, selection: BaseSelection|null): void { + lastSelectionByEditor.set(editor, selection); +} export function $selectionContainsNodeType(selection: BaseSelection | null, matcher: LexicalNodeMatcher): boolean { return $getNodeFromSelection(selection, matcher) !== null; @@ -59,7 +70,7 @@ export function $toggleSelectionBlockNodeType(matcher: LexicalNodeMatcher, creat const selection = $getSelection(); const blockElement = selection ? $getNearestBlockElementAncestorOrThrow(selection.getNodes()[0]) : null; if (selection && matcher(blockElement)) { - $setBlocksType(selection, $createParagraphNode); + $setBlocksType(selection, $createCustomParagraphNode); } else { $setBlocksType(selection, creator); }