From ea4c50c2c22be9a8920d5dfe7f1162c3454f2d53 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 16 Jul 2024 16:36:08 +0100 Subject: [PATCH] Lexical: Added code block selection & edit features Also added extra lifecycle handling for decorators to things can be properly cleaned up after node destruction. --- resources/js/wysiwyg/helpers.ts | 29 ++++++++++++++--- resources/js/wysiwyg/index.ts | 6 ++-- resources/js/wysiwyg/nodes/index.ts | 32 ++++++++++++++++++- .../js/wysiwyg/ui/decorators/code-block.ts | 18 ++++++++++- resources/js/wysiwyg/ui/decorators/image.ts | 6 ++-- .../wysiwyg/ui/defaults/button-definitions.ts | 8 ++++- .../js/wysiwyg/ui/framework/decorator.ts | 19 +++++++++++ resources/js/wysiwyg/ui/framework/manager.ts | 29 +++++++++++++++-- resources/js/wysiwyg/ui/index.ts | 15 +++++++-- resources/js/wysiwyg/ui/toolbars.ts | 10 ++++-- resources/sass/_editor.scss | 3 ++ 11 files changed, 156 insertions(+), 19 deletions(-) diff --git a/resources/js/wysiwyg/helpers.ts b/resources/js/wysiwyg/helpers.ts index 64bcb6490..3708c2b25 100644 --- a/resources/js/wysiwyg/helpers.ts +++ b/resources/js/wysiwyg/helpers.ts @@ -1,14 +1,14 @@ import { + $createNodeSelection, $createParagraphNode, $getRoot, $getSelection, - $isTextNode, - BaseSelection, ElementNode, + $isTextNode, $setSelection, + BaseSelection, LexicalEditor, LexicalNode, TextFormatType } from "lexical"; -import {LexicalElementNodeCreator, LexicalNodeMatcher} from "./nodes"; +import {getNodesForPageEditor, LexicalElementNodeCreator, LexicalNodeMatcher} from "./nodes"; import {$getNearestBlockElementAncestorOrThrow} from "@lexical/utils"; import {$setBlocksType} from "@lexical/selection"; -import {$createDetailsNode} from "./nodes/details"; export function el(tag: string, attrs: Record = {}, children: (string|HTMLElement)[] = []): HTMLElement { const el = document.createElement(tag); @@ -93,4 +93,25 @@ export function insertNewBlockNodeAtSelection(node: LexicalNode, insertAfter: bo } else { $getRoot().append(node); } +} + +export function selectSingleNode(node: LexicalNode) { + const nodeSelection = $createNodeSelection(); + nodeSelection.add(node.getKey()); + $setSelection(nodeSelection); +} + +export function selectionContainsNode(selection: BaseSelection|null, node: LexicalNode): boolean { + if (!selection) { + return false; + } + + const key = node.getKey(); + for (const node of selection.getNodes()) { + if (node.getKey() === key) { + return true; + } + } + + return false; } \ No newline at end of file diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index d1d96b172..8cbaccd79 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -2,11 +2,12 @@ import {createEditor, CreateEditorArgs, LexicalEditor} from 'lexical'; import {createEmptyHistoryState, registerHistory} from '@lexical/history'; import {registerRichText} from '@lexical/rich-text'; import {mergeRegister} from '@lexical/utils'; -import {getNodesForPageEditor} from './nodes'; +import {getNodesForPageEditor, registerCommonNodeMutationListeners} from './nodes'; import {buildEditorUI} from "./ui"; import {getEditorContentAsHtml, setEditorContentFromHtml} from "./actions"; import {registerTableResizer} from "./ui/framework/helpers/table-resizer"; import {el} from "./helpers"; +import {EditorUiContext} from "./ui/framework/core"; export function createPageEditorInstance(container: HTMLElement, htmlContent: string): SimpleWysiwygEditorInterface { const config: CreateEditorArgs = { @@ -59,7 +60,8 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st } }); - buildEditorUI(container, editArea, editor); + const context: EditorUiContext = buildEditorUI(container, editArea, editor); + registerCommonNodeMutationListeners(context); return new SimpleWysiwygEditorInterface(editor); } diff --git a/resources/js/wysiwyg/nodes/index.ts b/resources/js/wysiwyg/nodes/index.ts index e2c6902d3..a2c739576 100644 --- a/resources/js/wysiwyg/nodes/index.ts +++ b/resources/js/wysiwyg/nodes/index.ts @@ -1,6 +1,14 @@ import {HeadingNode, QuoteNode} from '@lexical/rich-text'; import {CalloutNode} from './callout'; -import {ElementNode, KlassConstructor, LexicalNode, LexicalNodeReplacement, ParagraphNode} from "lexical"; +import { + $getNodeByKey, + ElementNode, + KlassConstructor, + LexicalEditor, + LexicalNode, + LexicalNodeReplacement, NodeMutation, + ParagraphNode +} from "lexical"; import {CustomParagraphNode} from "./custom-paragraph"; import {LinkNode} from "@lexical/link"; import {ImageNode} from "./image"; @@ -11,6 +19,8 @@ import {CustomTableNode} from "./custom-table"; import {HorizontalRuleNode} from "./horizontal-rule"; import {CodeBlockNode} from "./code-block"; import {DiagramNode} from "./diagram"; +import {EditorUIManager} from "../ui/framework/manager"; +import {EditorUiContext} from "../ui/framework/core"; /** * Load the nodes for lexical. @@ -47,5 +57,25 @@ export function getNodesForPageEditor(): (KlassConstructor | ]; } +export function registerCommonNodeMutationListeners(context: EditorUiContext): void { + const decorated = [ImageNode, CodeBlockNode, DiagramNode]; + + const decorationDestroyListener = (mutations: Map): void => { + for (let [nodeKey, mutation] of mutations) { + if (mutation === "destroyed") { + const decorator = context.manager.getDecoratorByNodeKey(nodeKey); + if (decorator) { + decorator.destroy(context); + } + } + } + }; + + for (let decoratedNode of decorated) { + // Have to pass a unique function here since they are stored by lexical keyed on listener function. + context.editor.registerMutationListener(decoratedNode, (mutations) => decorationDestroyListener(mutations)); + } +} + export type LexicalNodeMatcher = (node: LexicalNode|null|undefined) => boolean; export type LexicalElementNodeCreator = () => ElementNode; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/decorators/code-block.ts b/resources/js/wysiwyg/ui/decorators/code-block.ts index 80dcef3bd..11cc02e8f 100644 --- a/resources/js/wysiwyg/ui/decorators/code-block.ts +++ b/resources/js/wysiwyg/ui/decorators/code-block.ts @@ -1,7 +1,9 @@ import {EditorDecorator} from "../framework/decorator"; import {EditorUiContext} from "../framework/core"; import {$openCodeEditorForNode, CodeBlockNode} from "../../nodes/code-block"; -import {ImageNode} from "../../nodes/image"; +import {selectionContainsNode, selectSingleNode} from "../../helpers"; +import {context} from "esbuild"; +import {BaseSelection} from "lexical"; export class CodeBlockDecorator extends EditorDecorator { @@ -32,12 +34,26 @@ export class CodeBlockDecorator extends EditorDecorator { const startTime = Date.now(); + element.addEventListener('click', event => { + context.editor.update(() => { + selectSingleNode(this.getNode()); + }) + }); + element.addEventListener('dblclick', event => { context.editor.getEditorState().read(() => { $openCodeEditorForNode(context.editor, (this.getNode() as CodeBlockNode)); }); }); + const selectionChange = (selection: BaseSelection|null): void => { + element.classList.toggle('selected', selectionContainsNode(selection, codeNode)); + }; + context.manager.onSelectionChange(selectionChange); + this.onDestroy(() => { + context.manager.offSelectionChange(selectionChange); + }); + // @ts-ignore const renderEditor = (Code) => { this.editor = Code.wysiwygView(element, document, this.latestCode, this.latestLanguage); diff --git a/resources/js/wysiwyg/ui/decorators/image.ts b/resources/js/wysiwyg/ui/decorators/image.ts index 1e8bfd165..1bc1ea543 100644 --- a/resources/js/wysiwyg/ui/decorators/image.ts +++ b/resources/js/wysiwyg/ui/decorators/image.ts @@ -1,5 +1,5 @@ import {EditorDecorator} from "../framework/decorator"; -import {el} from "../../helpers"; +import {el, selectSingleNode} from "../../helpers"; import {$createNodeSelection, $setSelection} from "lexical"; import {EditorUiContext} from "../framework/core"; import {ImageNode} from "../../nodes/image"; @@ -41,9 +41,7 @@ export class ImageDecorator extends EditorDecorator { tracker = this.setupTracker(decorateEl, context); context.editor.update(() => { - const nodeSelection = $createNodeSelection(); - nodeSelection.add(this.getNode().getKey()); - $setSelection(nodeSelection); + selectSingleNode(this.getNode()); }); }; diff --git a/resources/js/wysiwyg/ui/defaults/button-definitions.ts b/resources/js/wysiwyg/ui/defaults/button-definitions.ts index 9f83fbea3..c6ea85b0d 100644 --- a/resources/js/wysiwyg/ui/defaults/button-definitions.ts +++ b/resources/js/wysiwyg/ui/defaults/button-definitions.ts @@ -53,6 +53,7 @@ import codeBlockIcon from "@icons/editor/code-block.svg" import detailsIcon from "@icons/editor/details.svg" import sourceIcon from "@icons/editor/source-view.svg" import fullscreenIcon from "@icons/editor/fullscreen.svg" +import editIcon from "@icons/edit.svg" import {$createHorizontalRuleNode, $isHorizontalRuleNode} from "../../nodes/horizontal-rule"; import {$createCodeBlockNode, $isCodeBlockNode, $openCodeEditorForNode, CodeBlockNode} from "../../nodes/code-block"; @@ -344,7 +345,7 @@ export const codeBlock: EditorButtonDefinition = { action(context: EditorUiContext) { context.editor.getEditorState().read(() => { const selection = $getSelection(); - const codeBlock = getNodeFromSelection(selection, $isCodeBlockNode) as (CodeBlockNode|null); + const codeBlock = getNodeFromSelection(context.lastSelection, $isCodeBlockNode) as (CodeBlockNode|null); if (codeBlock === null) { context.editor.update(() => { const codeBlock = $createCodeBlockNode(); @@ -363,6 +364,11 @@ export const codeBlock: EditorButtonDefinition = { } }; +export const editCodeBlock: EditorButtonDefinition = Object.assign({}, codeBlock, { + label: 'Edit code block', + icon: editIcon, +}); + export const details: EditorButtonDefinition = { label: 'Insert collapsible block', icon: detailsIcon, diff --git a/resources/js/wysiwyg/ui/framework/decorator.ts b/resources/js/wysiwyg/ui/framework/decorator.ts index a9917ab23..570b8222b 100644 --- a/resources/js/wysiwyg/ui/framework/decorator.ts +++ b/resources/js/wysiwyg/ui/framework/decorator.ts @@ -11,6 +11,8 @@ export abstract class EditorDecorator { protected node: LexicalNode | null = null; protected context: EditorUiContext; + private onDestroyCallbacks: (() => void)[] = []; + constructor(context: EditorUiContext) { this.context = context; } @@ -27,6 +29,13 @@ export abstract class EditorDecorator { this.node = node; } + /** + * Register a callback to be ran on destroy of this decorator's node. + */ + protected onDestroy(callback: () => void) { + this.onDestroyCallbacks.push(callback); + } + /** * Render the decorator. * Can run on both creation and update for a node decorator. @@ -35,4 +44,14 @@ export abstract class EditorDecorator { */ abstract render(context: EditorUiContext, decorated: HTMLElement): HTMLElement|void; + /** + * Destroy this decorator. Used for tear-down operations upon destruction + * of the underlying node this decorator is attached to. + */ + destroy(context: EditorUiContext): void { + for (const callback of this.onDestroyCallbacks) { + callback(); + } + } + } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/manager.ts b/resources/js/wysiwyg/ui/framework/manager.ts index 6477c4a1a..cfa94e8ae 100644 --- a/resources/js/wysiwyg/ui/framework/manager.ts +++ b/resources/js/wysiwyg/ui/framework/manager.ts @@ -1,11 +1,13 @@ import {EditorFormModal, EditorFormModalDefinition} from "./modals"; import {EditorContainerUiElement, EditorUiContext, EditorUiElement, EditorUiStateUpdate} from "./core"; import {EditorDecorator, EditorDecoratorAdapter} from "./decorator"; -import {$getSelection, COMMAND_PRIORITY_LOW, LexicalEditor, SELECTION_CHANGE_COMMAND} from "lexical"; +import {$getSelection, BaseSelection, COMMAND_PRIORITY_LOW, LexicalEditor, SELECTION_CHANGE_COMMAND} from "lexical"; import {DecoratorListener} from "lexical/LexicalEditor"; import type {NodeKey} from "lexical/LexicalNode"; import {EditorContextToolbar, EditorContextToolbarDefinition} from "./toolbars"; +export type SelectionChangeHandler = (selection: BaseSelection|null) => void; + export class EditorUIManager { protected modalDefinitionsByKey: Record = {}; @@ -15,6 +17,7 @@ export class EditorUIManager { protected toolbar: EditorContainerUiElement|null = null; protected contextToolbarDefinitionsByKey: Record = {}; protected activeContextToolbars: EditorContextToolbar[] = []; + protected selectionChangeHandlers: Set = new Set(); setContext(context: EditorUiContext) { this.context = context; @@ -72,6 +75,10 @@ export class EditorUIManager { return decorator; } + getDecoratorByNodeKey(nodeKey: string): EditorDecorator|null { + return this.decoratorInstancesByNodeKey[nodeKey] || null; + } + setToolbar(toolbar: EditorContainerUiElement) { if (this.toolbar) { this.toolbar.getDOMElement().remove(); @@ -94,7 +101,7 @@ export class EditorUIManager { for (const toolbar of this.activeContextToolbars) { toolbar.updateState(update); } - // console.log('selection update', update.selection); + this.triggerSelectionChange(update.selection); } triggerStateRefresh(): void { @@ -104,6 +111,24 @@ export class EditorUIManager { }); } + protected triggerSelectionChange(selection: BaseSelection|null): void { + if (!selection) { + return; + } + + for (const handler of this.selectionChangeHandlers) { + handler(selection); + } + } + + onSelectionChange(handler: SelectionChangeHandler): void { + this.selectionChangeHandlers.add(handler); + } + + offSelectionChange(handler: SelectionChangeHandler): void { + this.selectionChangeHandlers.delete(handler); + } + protected updateContextToolbars(update: EditorUiStateUpdate): void { for (const toolbar of this.activeContextToolbars) { toolbar.empty(); diff --git a/resources/js/wysiwyg/ui/index.ts b/resources/js/wysiwyg/ui/index.ts index 50307fa61..748370959 100644 --- a/resources/js/wysiwyg/ui/index.ts +++ b/resources/js/wysiwyg/ui/index.ts @@ -1,5 +1,10 @@ import {LexicalEditor} from "lexical"; -import {getImageToolbarContent, getLinkToolbarContent, getMainEditorFullToolbar} from "./toolbars"; +import { + getCodeToolbarContent, + getImageToolbarContent, + getLinkToolbarContent, + getMainEditorFullToolbar +} from "./toolbars"; import {EditorUIManager} from "./framework/manager"; import {image as imageFormDefinition, link as linkFormDefinition, source as sourceFormDefinition} from "./defaults/form-definitions"; import {ImageDecorator} from "./decorators/image"; @@ -7,7 +12,7 @@ import {EditorUiContext} from "./framework/core"; import {CodeBlockDecorator} from "./decorators/code-block"; import {DiagramDecorator} from "./decorators/diagram"; -export function buildEditorUI(container: HTMLElement, element: HTMLElement, editor: LexicalEditor) { +export function buildEditorUI(container: HTMLElement, element: HTMLElement, editor: LexicalEditor): EditorUiContext { const manager = new EditorUIManager(); const context: EditorUiContext = { editor, @@ -48,9 +53,15 @@ export function buildEditorUI(container: HTMLElement, element: HTMLElement, edit selector: 'a', content: getLinkToolbarContent(), }); + manager.registerContextToolbar('code', { + selector: '.editor-code-block-wrap', + content: getCodeToolbarContent(), + }); // Register image decorator listener manager.registerDecoratorType('image', ImageDecorator); manager.registerDecoratorType('code', CodeBlockDecorator); manager.registerDecoratorType('diagram', DiagramDecorator); + + return context; } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/toolbars.ts index 25a7e7815..d512b58e2 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/toolbars.ts @@ -1,7 +1,7 @@ import {EditorButton} from "./framework/buttons"; import { blockquote, bold, bulletList, clearFormating, code, codeBlock, - dangerCallout, details, fullscreen, + dangerCallout, details, editCodeBlock, fullscreen, h2, h3, h4, h5, highlightColor, horizontalRule, image, infoCallout, italic, link, numberList, paragraph, redo, source, strikethrough, subscript, @@ -9,7 +9,7 @@ import { undo, unlink, warningCallout } from "./defaults/button-definitions"; -import {EditorContainerUiElement, EditorSimpleClassContainer, EditorUiContext, EditorUiElement} from "./framework/core"; +import {EditorContainerUiElement, EditorSimpleClassContainer, EditorUiElement} from "./framework/core"; import {el} from "../helpers"; import {EditorFormatMenu} from "./framework/blocks/format-menu"; import {FormatPreviewButton} from "./framework/blocks/format-preview-button"; @@ -111,4 +111,10 @@ export function getLinkToolbarContent(): EditorUiElement[] { new EditorButton(link), new EditorButton(unlink), ]; +} + +export function getCodeToolbarContent(): EditorUiElement[] { + return [ + new EditorButton(editCodeBlock), + ]; } \ No newline at end of file diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index 1f932e147..99045dd5a 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -312,6 +312,9 @@ body.editor-is-fullscreen { > * { pointer-events: none; } + &.selected .cm-editor { + border: 1px dashed var(--editor-color-primary); + } } // Editor form elements