Lexical: Added code block selection & edit features

Also added extra lifecycle handling for decorators to things can be
properly cleaned up after node destruction.
This commit is contained in:
Dan Brown 2024-07-16 16:36:08 +01:00
parent 51d8044a54
commit ea4c50c2c2
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
11 changed files with 156 additions and 19 deletions

View File

@ -1,14 +1,14 @@
import { import {
$createNodeSelection,
$createParagraphNode, $getRoot, $createParagraphNode, $getRoot,
$getSelection, $getSelection,
$isTextNode, $isTextNode, $setSelection,
BaseSelection, ElementNode, BaseSelection,
LexicalEditor, LexicalNode, TextFormatType LexicalEditor, LexicalNode, TextFormatType
} from "lexical"; } from "lexical";
import {LexicalElementNodeCreator, LexicalNodeMatcher} from "./nodes"; import {getNodesForPageEditor, LexicalElementNodeCreator, LexicalNodeMatcher} from "./nodes";
import {$getNearestBlockElementAncestorOrThrow} from "@lexical/utils"; import {$getNearestBlockElementAncestorOrThrow} from "@lexical/utils";
import {$setBlocksType} from "@lexical/selection"; import {$setBlocksType} from "@lexical/selection";
import {$createDetailsNode} from "./nodes/details";
export function el(tag: string, attrs: Record<string, string|null> = {}, children: (string|HTMLElement)[] = []): HTMLElement { export function el(tag: string, attrs: Record<string, string|null> = {}, children: (string|HTMLElement)[] = []): HTMLElement {
const el = document.createElement(tag); const el = document.createElement(tag);
@ -94,3 +94,24 @@ export function insertNewBlockNodeAtSelection(node: LexicalNode, insertAfter: bo
$getRoot().append(node); $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;
}

View File

@ -2,11 +2,12 @@ import {createEditor, CreateEditorArgs, LexicalEditor} from 'lexical';
import {createEmptyHistoryState, registerHistory} from '@lexical/history'; import {createEmptyHistoryState, registerHistory} from '@lexical/history';
import {registerRichText} from '@lexical/rich-text'; import {registerRichText} from '@lexical/rich-text';
import {mergeRegister} from '@lexical/utils'; import {mergeRegister} from '@lexical/utils';
import {getNodesForPageEditor} from './nodes'; import {getNodesForPageEditor, registerCommonNodeMutationListeners} from './nodes';
import {buildEditorUI} from "./ui"; import {buildEditorUI} from "./ui";
import {getEditorContentAsHtml, setEditorContentFromHtml} from "./actions"; import {getEditorContentAsHtml, setEditorContentFromHtml} from "./actions";
import {registerTableResizer} from "./ui/framework/helpers/table-resizer"; import {registerTableResizer} from "./ui/framework/helpers/table-resizer";
import {el} from "./helpers"; import {el} from "./helpers";
import {EditorUiContext} from "./ui/framework/core";
export function createPageEditorInstance(container: HTMLElement, htmlContent: string): SimpleWysiwygEditorInterface { export function createPageEditorInstance(container: HTMLElement, htmlContent: string): SimpleWysiwygEditorInterface {
const config: CreateEditorArgs = { 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); return new SimpleWysiwygEditorInterface(editor);
} }

View File

@ -1,6 +1,14 @@
import {HeadingNode, QuoteNode} from '@lexical/rich-text'; import {HeadingNode, QuoteNode} from '@lexical/rich-text';
import {CalloutNode} from './callout'; 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 {CustomParagraphNode} from "./custom-paragraph";
import {LinkNode} from "@lexical/link"; import {LinkNode} from "@lexical/link";
import {ImageNode} from "./image"; import {ImageNode} from "./image";
@ -11,6 +19,8 @@ import {CustomTableNode} from "./custom-table";
import {HorizontalRuleNode} from "./horizontal-rule"; import {HorizontalRuleNode} from "./horizontal-rule";
import {CodeBlockNode} from "./code-block"; import {CodeBlockNode} from "./code-block";
import {DiagramNode} from "./diagram"; import {DiagramNode} from "./diagram";
import {EditorUIManager} from "../ui/framework/manager";
import {EditorUiContext} from "../ui/framework/core";
/** /**
* Load the nodes for lexical. * Load the nodes for lexical.
@ -47,5 +57,25 @@ export function getNodesForPageEditor(): (KlassConstructor<typeof LexicalNode> |
]; ];
} }
export function registerCommonNodeMutationListeners(context: EditorUiContext): void {
const decorated = [ImageNode, CodeBlockNode, DiagramNode];
const decorationDestroyListener = (mutations: Map<string, NodeMutation>): 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 LexicalNodeMatcher = (node: LexicalNode|null|undefined) => boolean;
export type LexicalElementNodeCreator = () => ElementNode; export type LexicalElementNodeCreator = () => ElementNode;

View File

@ -1,7 +1,9 @@
import {EditorDecorator} from "../framework/decorator"; import {EditorDecorator} from "../framework/decorator";
import {EditorUiContext} from "../framework/core"; import {EditorUiContext} from "../framework/core";
import {$openCodeEditorForNode, CodeBlockNode} from "../../nodes/code-block"; 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 { export class CodeBlockDecorator extends EditorDecorator {
@ -32,12 +34,26 @@ export class CodeBlockDecorator extends EditorDecorator {
const startTime = Date.now(); const startTime = Date.now();
element.addEventListener('click', event => {
context.editor.update(() => {
selectSingleNode(this.getNode());
})
});
element.addEventListener('dblclick', event => { element.addEventListener('dblclick', event => {
context.editor.getEditorState().read(() => { context.editor.getEditorState().read(() => {
$openCodeEditorForNode(context.editor, (this.getNode() as CodeBlockNode)); $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 // @ts-ignore
const renderEditor = (Code) => { const renderEditor = (Code) => {
this.editor = Code.wysiwygView(element, document, this.latestCode, this.latestLanguage); this.editor = Code.wysiwygView(element, document, this.latestCode, this.latestLanguage);

View File

@ -1,5 +1,5 @@
import {EditorDecorator} from "../framework/decorator"; import {EditorDecorator} from "../framework/decorator";
import {el} from "../../helpers"; import {el, selectSingleNode} from "../../helpers";
import {$createNodeSelection, $setSelection} from "lexical"; import {$createNodeSelection, $setSelection} from "lexical";
import {EditorUiContext} from "../framework/core"; import {EditorUiContext} from "../framework/core";
import {ImageNode} from "../../nodes/image"; import {ImageNode} from "../../nodes/image";
@ -41,9 +41,7 @@ export class ImageDecorator extends EditorDecorator {
tracker = this.setupTracker(decorateEl, context); tracker = this.setupTracker(decorateEl, context);
context.editor.update(() => { context.editor.update(() => {
const nodeSelection = $createNodeSelection(); selectSingleNode(this.getNode());
nodeSelection.add(this.getNode().getKey());
$setSelection(nodeSelection);
}); });
}; };

View File

@ -53,6 +53,7 @@ import codeBlockIcon from "@icons/editor/code-block.svg"
import detailsIcon from "@icons/editor/details.svg" import detailsIcon from "@icons/editor/details.svg"
import sourceIcon from "@icons/editor/source-view.svg" import sourceIcon from "@icons/editor/source-view.svg"
import fullscreenIcon from "@icons/editor/fullscreen.svg" import fullscreenIcon from "@icons/editor/fullscreen.svg"
import editIcon from "@icons/edit.svg"
import {$createHorizontalRuleNode, $isHorizontalRuleNode} from "../../nodes/horizontal-rule"; import {$createHorizontalRuleNode, $isHorizontalRuleNode} from "../../nodes/horizontal-rule";
import {$createCodeBlockNode, $isCodeBlockNode, $openCodeEditorForNode, CodeBlockNode} from "../../nodes/code-block"; import {$createCodeBlockNode, $isCodeBlockNode, $openCodeEditorForNode, CodeBlockNode} from "../../nodes/code-block";
@ -344,7 +345,7 @@ export const codeBlock: EditorButtonDefinition = {
action(context: EditorUiContext) { action(context: EditorUiContext) {
context.editor.getEditorState().read(() => { context.editor.getEditorState().read(() => {
const selection = $getSelection(); const selection = $getSelection();
const codeBlock = getNodeFromSelection(selection, $isCodeBlockNode) as (CodeBlockNode|null); const codeBlock = getNodeFromSelection(context.lastSelection, $isCodeBlockNode) as (CodeBlockNode|null);
if (codeBlock === null) { if (codeBlock === null) {
context.editor.update(() => { context.editor.update(() => {
const codeBlock = $createCodeBlockNode(); 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 = { export const details: EditorButtonDefinition = {
label: 'Insert collapsible block', label: 'Insert collapsible block',
icon: detailsIcon, icon: detailsIcon,

View File

@ -11,6 +11,8 @@ export abstract class EditorDecorator {
protected node: LexicalNode | null = null; protected node: LexicalNode | null = null;
protected context: EditorUiContext; protected context: EditorUiContext;
private onDestroyCallbacks: (() => void)[] = [];
constructor(context: EditorUiContext) { constructor(context: EditorUiContext) {
this.context = context; this.context = context;
} }
@ -27,6 +29,13 @@ export abstract class EditorDecorator {
this.node = node; 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. * Render the decorator.
* Can run on both creation and update for a node 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; 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();
}
}
} }

View File

@ -1,11 +1,13 @@
import {EditorFormModal, EditorFormModalDefinition} from "./modals"; import {EditorFormModal, EditorFormModalDefinition} from "./modals";
import {EditorContainerUiElement, EditorUiContext, EditorUiElement, EditorUiStateUpdate} from "./core"; import {EditorContainerUiElement, EditorUiContext, EditorUiElement, EditorUiStateUpdate} from "./core";
import {EditorDecorator, EditorDecoratorAdapter} from "./decorator"; 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 {DecoratorListener} from "lexical/LexicalEditor";
import type {NodeKey} from "lexical/LexicalNode"; import type {NodeKey} from "lexical/LexicalNode";
import {EditorContextToolbar, EditorContextToolbarDefinition} from "./toolbars"; import {EditorContextToolbar, EditorContextToolbarDefinition} from "./toolbars";
export type SelectionChangeHandler = (selection: BaseSelection|null) => void;
export class EditorUIManager { export class EditorUIManager {
protected modalDefinitionsByKey: Record<string, EditorFormModalDefinition> = {}; protected modalDefinitionsByKey: Record<string, EditorFormModalDefinition> = {};
@ -15,6 +17,7 @@ export class EditorUIManager {
protected toolbar: EditorContainerUiElement|null = null; protected toolbar: EditorContainerUiElement|null = null;
protected contextToolbarDefinitionsByKey: Record<string, EditorContextToolbarDefinition> = {}; protected contextToolbarDefinitionsByKey: Record<string, EditorContextToolbarDefinition> = {};
protected activeContextToolbars: EditorContextToolbar[] = []; protected activeContextToolbars: EditorContextToolbar[] = [];
protected selectionChangeHandlers: Set<SelectionChangeHandler> = new Set();
setContext(context: EditorUiContext) { setContext(context: EditorUiContext) {
this.context = context; this.context = context;
@ -72,6 +75,10 @@ export class EditorUIManager {
return decorator; return decorator;
} }
getDecoratorByNodeKey(nodeKey: string): EditorDecorator|null {
return this.decoratorInstancesByNodeKey[nodeKey] || null;
}
setToolbar(toolbar: EditorContainerUiElement) { setToolbar(toolbar: EditorContainerUiElement) {
if (this.toolbar) { if (this.toolbar) {
this.toolbar.getDOMElement().remove(); this.toolbar.getDOMElement().remove();
@ -94,7 +101,7 @@ export class EditorUIManager {
for (const toolbar of this.activeContextToolbars) { for (const toolbar of this.activeContextToolbars) {
toolbar.updateState(update); toolbar.updateState(update);
} }
// console.log('selection update', update.selection); this.triggerSelectionChange(update.selection);
} }
triggerStateRefresh(): void { 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 { protected updateContextToolbars(update: EditorUiStateUpdate): void {
for (const toolbar of this.activeContextToolbars) { for (const toolbar of this.activeContextToolbars) {
toolbar.empty(); toolbar.empty();

View File

@ -1,5 +1,10 @@
import {LexicalEditor} from "lexical"; import {LexicalEditor} from "lexical";
import {getImageToolbarContent, getLinkToolbarContent, getMainEditorFullToolbar} from "./toolbars"; import {
getCodeToolbarContent,
getImageToolbarContent,
getLinkToolbarContent,
getMainEditorFullToolbar
} from "./toolbars";
import {EditorUIManager} from "./framework/manager"; import {EditorUIManager} from "./framework/manager";
import {image as imageFormDefinition, link as linkFormDefinition, source as sourceFormDefinition} from "./defaults/form-definitions"; import {image as imageFormDefinition, link as linkFormDefinition, source as sourceFormDefinition} from "./defaults/form-definitions";
import {ImageDecorator} from "./decorators/image"; import {ImageDecorator} from "./decorators/image";
@ -7,7 +12,7 @@ import {EditorUiContext} from "./framework/core";
import {CodeBlockDecorator} from "./decorators/code-block"; import {CodeBlockDecorator} from "./decorators/code-block";
import {DiagramDecorator} from "./decorators/diagram"; 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 manager = new EditorUIManager();
const context: EditorUiContext = { const context: EditorUiContext = {
editor, editor,
@ -48,9 +53,15 @@ export function buildEditorUI(container: HTMLElement, element: HTMLElement, edit
selector: 'a', selector: 'a',
content: getLinkToolbarContent(), content: getLinkToolbarContent(),
}); });
manager.registerContextToolbar('code', {
selector: '.editor-code-block-wrap',
content: getCodeToolbarContent(),
});
// Register image decorator listener // Register image decorator listener
manager.registerDecoratorType('image', ImageDecorator); manager.registerDecoratorType('image', ImageDecorator);
manager.registerDecoratorType('code', CodeBlockDecorator); manager.registerDecoratorType('code', CodeBlockDecorator);
manager.registerDecoratorType('diagram', DiagramDecorator); manager.registerDecoratorType('diagram', DiagramDecorator);
return context;
} }

View File

@ -1,7 +1,7 @@
import {EditorButton} from "./framework/buttons"; import {EditorButton} from "./framework/buttons";
import { import {
blockquote, bold, bulletList, clearFormating, code, codeBlock, blockquote, bold, bulletList, clearFormating, code, codeBlock,
dangerCallout, details, fullscreen, dangerCallout, details, editCodeBlock, fullscreen,
h2, h3, h4, h5, highlightColor, horizontalRule, image, h2, h3, h4, h5, highlightColor, horizontalRule, image,
infoCallout, italic, link, numberList, paragraph, infoCallout, italic, link, numberList, paragraph,
redo, source, strikethrough, subscript, redo, source, strikethrough, subscript,
@ -9,7 +9,7 @@ import {
undo, unlink, undo, unlink,
warningCallout warningCallout
} from "./defaults/button-definitions"; } from "./defaults/button-definitions";
import {EditorContainerUiElement, EditorSimpleClassContainer, EditorUiContext, EditorUiElement} from "./framework/core"; import {EditorContainerUiElement, EditorSimpleClassContainer, EditorUiElement} from "./framework/core";
import {el} from "../helpers"; import {el} from "../helpers";
import {EditorFormatMenu} from "./framework/blocks/format-menu"; import {EditorFormatMenu} from "./framework/blocks/format-menu";
import {FormatPreviewButton} from "./framework/blocks/format-preview-button"; import {FormatPreviewButton} from "./framework/blocks/format-preview-button";
@ -112,3 +112,9 @@ export function getLinkToolbarContent(): EditorUiElement[] {
new EditorButton(unlink), new EditorButton(unlink),
]; ];
} }
export function getCodeToolbarContent(): EditorUiElement[] {
return [
new EditorButton(editCodeBlock),
];
}

View File

@ -312,6 +312,9 @@ body.editor-is-fullscreen {
> * { > * {
pointer-events: none; pointer-events: none;
} }
&.selected .cm-editor {
border: 1px dashed var(--editor-color-primary);
}
} }
// Editor form elements // Editor form elements