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<string, string|null> = {}, children: (string|HTMLElement)[] = []): HTMLElement {
const el = document.createElement(tag);
} 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
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 = {
}
});
- buildEditorUI(container, editArea, editor);
+ const context: EditorUiContext = buildEditorUI(container, editArea, editor);
+ registerCommonNodeMutationListeners(context);
return new SimpleWysiwygEditorInterface(editor);
}
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";
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.
];
}
+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 LexicalElementNodeCreator = () => ElementNode;
\ No newline at end of file
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 {
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);
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";
tracker = this.setupTracker(decorateEl, context);
context.editor.update(() => {
- const nodeSelection = $createNodeSelection();
- nodeSelection.add(this.getNode().getKey());
- $setSelection(nodeSelection);
+ selectSingleNode(this.getNode());
});
};
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";
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();
}
};
+export const editCodeBlock: EditorButtonDefinition = Object.assign({}, codeBlock, {
+ label: 'Edit code block',
+ icon: editIcon,
+});
+
export const details: EditorButtonDefinition = {
label: 'Insert collapsible block',
icon: detailsIcon,
protected node: LexicalNode | null = null;
protected context: EditorUiContext;
+ private onDestroyCallbacks: (() => void)[] = [];
+
constructor(context: EditorUiContext) {
this.context = context;
}
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.
*/
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
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<string, EditorFormModalDefinition> = {};
protected toolbar: EditorContainerUiElement|null = null;
protected contextToolbarDefinitionsByKey: Record<string, EditorContextToolbarDefinition> = {};
protected activeContextToolbars: EditorContextToolbar[] = [];
+ protected selectionChangeHandlers: Set<SelectionChangeHandler> = new Set();
setContext(context: EditorUiContext) {
this.context = context;
return decorator;
}
+ getDecoratorByNodeKey(nodeKey: string): EditorDecorator|null {
+ return this.decoratorInstancesByNodeKey[nodeKey] || null;
+ }
+
setToolbar(toolbar: EditorContainerUiElement) {
if (this.toolbar) {
this.toolbar.getDOMElement().remove();
for (const toolbar of this.activeContextToolbars) {
toolbar.updateState(update);
}
- // console.log('selection update', update.selection);
+ this.triggerSelectionChange(update.selection);
}
triggerStateRefresh(): void {
});
}
+ 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();
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";
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,
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
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,
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";
new EditorButton(link),
new EditorButton(unlink),
];
+}
+
+export function getCodeToolbarContent(): EditorUiElement[] {
+ return [
+ new EditorButton(editCodeBlock),
+ ];
}
\ No newline at end of file
> * {
pointer-events: none;
}
+ &.selected .cm-editor {
+ border: 1px dashed var(--editor-color-primary);
+ }
}
// Editor form elements