mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-11-23 03:12:32 +01:00
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:
parent
51d8044a54
commit
ea4c50c2c2
@ -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<string, string|null> = {}, 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;
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
@ -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<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 LexicalElementNodeCreator = () => ElementNode;
|
@ -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);
|
||||
|
@ -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());
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -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<string, EditorFormModalDefinition> = {};
|
||||
@ -15,6 +17,7 @@ export class EditorUIManager {
|
||||
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;
|
||||
@ -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();
|
||||
|
@ -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;
|
||||
}
|
@ -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),
|
||||
];
|
||||
}
|
@ -312,6 +312,9 @@ body.editor-is-fullscreen {
|
||||
> * {
|
||||
pointer-events: none;
|
||||
}
|
||||
&.selected .cm-editor {
|
||||
border: 1px dashed var(--editor-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
// Editor form elements
|
||||
|
Loading…
Reference in New Issue
Block a user