Lexical: Added ui container type

Structured UI logical to be fairly standard and mostly covered via
a base class that handles context and core dom work.
This commit is contained in:
Dan Brown 2024-05-29 20:38:31 +01:00
parent 483d9bf26c
commit dc1a40ea74
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
12 changed files with 240 additions and 110 deletions

10
package-lock.json generated
View File

@ -20,6 +20,7 @@
"@codemirror/view": "^6.22.2",
"@lexical/history": "^0.15.0",
"@lexical/html": "^0.15.0",
"@lexical/link": "^0.15.0",
"@lexical/rich-text": "^0.15.0",
"@lexical/selection": "^0.15.0",
"@lexical/utils": "^0.15.0",
@ -729,6 +730,15 @@
"lexical": "0.15.0"
}
},
"node_modules/@lexical/link": {
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/@lexical/link/-/link-0.15.0.tgz",
"integrity": "sha512-KBV/zWk5FxqZGNcq3IKGBDCcS4t0uteU1osAIG+pefo4waTkOOgibxxEJDop2QR5wtjkYva3Qp0D8ZyJDMMMlw==",
"dependencies": {
"@lexical/utils": "0.15.0",
"lexical": "0.15.0"
}
},
"node_modules/@lexical/list": {
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.15.0.tgz",

View File

@ -44,6 +44,7 @@
"@codemirror/view": "^6.22.2",
"@lexical/history": "^0.15.0",
"@lexical/html": "^0.15.0",
"@lexical/link": "^0.15.0",
"@lexical/rich-text": "^0.15.0",
"@lexical/selection": "^0.15.0",
"@lexical/utils": "^0.15.0",

View File

@ -3,13 +3,29 @@ import {
$getSelection,
$isTextNode,
BaseSelection,
ElementFormatType,
LexicalEditor, TextFormatType
} from "lexical";
import {LexicalElementNodeCreator, LexicalNodeMatcher} from "./nodes";
import {$getNearestBlockElementAncestorOrThrow} from "@lexical/utils";
import {$setBlocksType} from "@lexical/selection";
import {TextNodeThemeClasses} from "lexical/LexicalEditor";
export function el(tag: string, attrs: Record<string, string> = {}, children: (string|HTMLElement)[] = []): HTMLElement {
const el = document.createElement(tag);
const attrKeys = Object.keys(attrs);
for (const attr of attrKeys) {
el.setAttribute(attr, attrs[attr]);
}
for (const child of children) {
if (typeof child === 'string') {
el.append(document.createTextNode(child));
} else {
el.append(child);
}
}
return el;
}
export function selectionContainsNodeType(selection: BaseSelection|null, matcher: LexicalNodeMatcher): boolean {
if (!selection) {

View File

@ -2,6 +2,7 @@ import {HeadingNode, QuoteNode} from '@lexical/rich-text';
import {CalloutNode} from './callout';
import {ElementNode, KlassConstructor, LexicalNode, LexicalNodeReplacement, ParagraphNode} from "lexical";
import {CustomParagraphNode} from "./custom-paragraph";
import {LinkNode} from "@lexical/link";
/**
* Load the nodes for lexical.
@ -17,7 +18,8 @@ export function getNodesForPageEditor(): (KlassConstructor<typeof LexicalNode> |
with: (node: ParagraphNode) => {
return new CustomParagraphNode();
}
}
},
LinkNode,
];
}

View File

@ -1,4 +1,4 @@
import {EditorButtonDefinition} from "./editor-button";
import {EditorButtonDefinition} from "../framework/buttons";
import {
$createParagraphNode,
$isParagraphNode,
@ -8,8 +8,8 @@ import {
REDO_COMMAND, TextFormatType,
UNDO_COMMAND
} from "lexical";
import {selectionContainsNodeType, selectionContainsTextFormat, toggleSelectionBlockNodeType} from "../helpers";
import {$createCalloutNode, $isCalloutNodeOfCategory, CalloutCategory} from "../nodes/callout";
import {selectionContainsNodeType, selectionContainsTextFormat, toggleSelectionBlockNodeType} from "../../helpers";
import {$createCalloutNode, $isCalloutNodeOfCategory, CalloutCategory} from "../../nodes/callout";
import {
$createHeadingNode,
$createQuoteNode,
@ -18,21 +18,22 @@ import {
HeadingNode,
HeadingTagType
} from "@lexical/rich-text";
import {$isLinkNode, $toggleLink} from "@lexical/link";
export const undoButton: EditorButtonDefinition = {
export const undo: EditorButtonDefinition = {
label: 'Undo',
action(editor: LexicalEditor) {
editor.dispatchCommand(UNDO_COMMAND);
editor.dispatchCommand(UNDO_COMMAND, undefined);
},
isActive(selection: BaseSelection|null): boolean {
return false;
}
}
export const redoButton: EditorButtonDefinition = {
export const redo: EditorButtonDefinition = {
label: 'Redo',
action(editor: LexicalEditor) {
editor.dispatchCommand(REDO_COMMAND);
editor.dispatchCommand(REDO_COMMAND, undefined);
},
isActive(selection: BaseSelection|null): boolean {
return false;
@ -55,10 +56,10 @@ function buildCalloutButton(category: CalloutCategory, name: string): EditorButt
};
}
export const infoCalloutButton: EditorButtonDefinition = buildCalloutButton('info', 'Info');
export const dangerCalloutButton: EditorButtonDefinition = buildCalloutButton('danger', 'Danger');
export const warningCalloutButton: EditorButtonDefinition = buildCalloutButton('warning', 'Warning');
export const successCalloutButton: EditorButtonDefinition = buildCalloutButton('success', 'Success');
export const infoCallout: EditorButtonDefinition = buildCalloutButton('info', 'Info');
export const dangerCallout: EditorButtonDefinition = buildCalloutButton('danger', 'Danger');
export const warningCallout: EditorButtonDefinition = buildCalloutButton('warning', 'Warning');
export const successCallout: EditorButtonDefinition = buildCalloutButton('success', 'Success');
const isHeaderNodeOfTag = (node: LexicalNode | null | undefined, tag: HeadingTagType) => {
return $isHeadingNode(node) && (node as HeadingNode).getTag() === tag;
@ -80,12 +81,12 @@ function buildHeaderButton(tag: HeadingTagType, name: string): EditorButtonDefin
};
}
export const h2Button: EditorButtonDefinition = buildHeaderButton('h2', 'Large Header');
export const h3Button: EditorButtonDefinition = buildHeaderButton('h3', 'Medium Header');
export const h4Button: EditorButtonDefinition = buildHeaderButton('h4', 'Small Header');
export const h5Button: EditorButtonDefinition = buildHeaderButton('h5', 'Tiny Header');
export const h2: EditorButtonDefinition = buildHeaderButton('h2', 'Large Header');
export const h3: EditorButtonDefinition = buildHeaderButton('h3', 'Medium Header');
export const h4: EditorButtonDefinition = buildHeaderButton('h4', 'Small Header');
export const h5: EditorButtonDefinition = buildHeaderButton('h5', 'Tiny Header');
export const blockquoteButton: EditorButtonDefinition = {
export const blockquote: EditorButtonDefinition = {
label: 'Blockquote',
action(editor: LexicalEditor) {
toggleSelectionBlockNodeType(editor, $isQuoteNode, $createQuoteNode);
@ -95,7 +96,7 @@ export const blockquoteButton: EditorButtonDefinition = {
}
};
export const paragraphButton: EditorButtonDefinition = {
export const paragraph: EditorButtonDefinition = {
label: 'Paragraph',
action(editor: LexicalEditor) {
toggleSelectionBlockNodeType(editor, $isParagraphNode, $createParagraphNode);
@ -117,13 +118,27 @@ function buildFormatButton(label: string, format: TextFormatType): EditorButtonD
};
}
export const boldButton: EditorButtonDefinition = buildFormatButton('Bold', 'bold');
export const italicButton: EditorButtonDefinition = buildFormatButton('Italic', 'italic');
export const underlineButton: EditorButtonDefinition = buildFormatButton('Underline', 'underline');
export const bold: EditorButtonDefinition = buildFormatButton('Bold', 'bold');
export const italic: EditorButtonDefinition = buildFormatButton('Italic', 'italic');
export const underline: EditorButtonDefinition = buildFormatButton('Underline', 'underline');
// Todo - Text color
// Todo - Highlight color
export const strikethroughButton: EditorButtonDefinition = buildFormatButton('Strikethrough', 'strikethrough');
export const superscriptButton: EditorButtonDefinition = buildFormatButton('Superscript', 'superscript');
export const subscriptButton: EditorButtonDefinition = buildFormatButton('Subscript', 'subscript');
export const codeButton: EditorButtonDefinition = buildFormatButton('Inline Code', 'code');
export const strikethrough: EditorButtonDefinition = buildFormatButton('Strikethrough', 'strikethrough');
export const superscript: EditorButtonDefinition = buildFormatButton('Superscript', 'superscript');
export const subscript: EditorButtonDefinition = buildFormatButton('Subscript', 'subscript');
export const code: EditorButtonDefinition = buildFormatButton('Inline Code', 'code');
// Todo - Clear formatting
export const link: EditorButtonDefinition = {
label: 'Insert/edit link',
action(editor: LexicalEditor) {
editor.update(() => {
$toggleLink('http://example.com');
})
},
isActive(selection: BaseSelection|null): boolean {
return selectionContainsNodeType(selection, $isLinkNode);
}
};

View File

@ -1,45 +0,0 @@
import {BaseSelection, LexicalEditor} from "lexical";
export interface EditorButtonDefinition {
label: string;
action: (editor: LexicalEditor) => void;
isActive: (selection: BaseSelection|null) => boolean;
}
export class EditorButton {
#definition: EditorButtonDefinition;
#editor: LexicalEditor;
#dom: HTMLButtonElement;
constructor(definition: EditorButtonDefinition, editor: LexicalEditor) {
this.#definition = definition;
this.#editor = editor;
this.#dom = this.buildDOM();
}
private buildDOM(): HTMLButtonElement {
const button = document.createElement("button");
button.setAttribute('type', 'button');
button.textContent = this.#definition.label;
button.classList.add('editor-toolbar-button');
button.addEventListener('click', event => {
this.runAction();
});
return button;
}
getDOMElement(): HTMLButtonElement {
return this.#dom;
}
runAction() {
this.#definition.action(this.#editor);
}
updateActiveState(selection: BaseSelection|null) {
const isActive = this.#definition.isActive(selection);
this.#dom.classList.toggle('editor-toolbar-button-active', isActive);
}
}

View File

@ -0,0 +1,39 @@
import {BaseSelection, LexicalEditor} from "lexical";
export type EditorUiStateUpdate = {
editor: LexicalEditor,
selection: BaseSelection|null,
};
export type EditorUiContext = {
editor: LexicalEditor,
};
export abstract class EditorUiElement {
protected dom: HTMLElement|null = null;
private context: EditorUiContext|null = null;
protected abstract buildDOM(): HTMLElement;
setContext(context: EditorUiContext): void {
this.context = context;
}
getContext(): EditorUiContext {
if (this.context === null) {
throw new Error('Attempted to use EditorUIContext before it has been set');
}
return this.context;
}
getDOMElement(): HTMLElement {
if (!this.dom) {
this.dom = this.buildDOM();
}
return this.dom;
}
abstract updateState(state: EditorUiStateUpdate): void;
}

View File

@ -0,0 +1,40 @@
import {BaseSelection, LexicalEditor} from "lexical";
import {EditorUiElement, EditorUiStateUpdate} from "./base-elements";
import {el} from "../../helpers";
export interface EditorButtonDefinition {
label: string;
action: (editor: LexicalEditor) => void;
isActive: (selection: BaseSelection|null) => boolean;
}
export class EditorButton extends EditorUiElement {
protected definition: EditorButtonDefinition;
constructor(definition: EditorButtonDefinition) {
super();
this.definition = definition;
}
protected buildDOM(): HTMLButtonElement {
const button = el('button', {
type: 'button',
class: 'editor-toolbar-button',
}, [this.definition.label]) as HTMLButtonElement;
button.addEventListener('click', event => {
this.definition.action(this.getContext().editor);
});
return button;
}
updateActiveState(selection: BaseSelection|null) {
const isActive = this.definition.isActive(selection);
this.dom?.classList.toggle('editor-toolbar-button-active', isActive);
}
updateState(state: EditorUiStateUpdate): void {
this.updateActiveState(state.selection);
}
}

View File

@ -0,0 +1,40 @@
import {EditorUiContext, EditorUiElement, EditorUiStateUpdate} from "./base-elements";
import {el} from "../../helpers";
export class EditorContainerUiElement extends EditorUiElement {
protected children : EditorUiElement[];
constructor(children: EditorUiElement[]) {
super();
this.children = children;
}
protected buildDOM(): HTMLElement {
return el('div', {}, this.getChildren().map(child => child.getDOMElement()));
}
getChildren(): EditorUiElement[] {
return this.children;
}
updateState(state: EditorUiStateUpdate): void {
for (const child of this.children) {
child.updateState(state);
}
}
setContext(context: EditorUiContext) {
for (const child of this.getChildren()) {
child.setContext(context);
}
}
}
export class EditorFormatMenu extends EditorContainerUiElement {
buildDOM(): HTMLElement {
return el('div', {
class: 'editor-format-menu'
}, this.getChildren().map(child => child.getDOMElement()));
}
}

View File

@ -4,49 +4,17 @@ import {
LexicalEditor,
SELECTION_CHANGE_COMMAND
} from "lexical";
import {EditorButton, EditorButtonDefinition} from "./editor-button";
import {
blockquoteButton, boldButton, codeButton,
dangerCalloutButton,
h2Button,
h3Button, h4Button, h5Button,
infoCalloutButton, italicButton, paragraphButton, redoButton, strikethroughButton, subscriptButton,
successCalloutButton, superscriptButton, underlineButton, undoButton,
warningCalloutButton
} from "./buttons";
const toolbarButtonDefinitions: EditorButtonDefinition[] = [
undoButton, redoButton,
infoCalloutButton, warningCalloutButton, dangerCalloutButton, successCalloutButton,
h2Button, h3Button, h4Button, h5Button,
blockquoteButton, paragraphButton,
boldButton, italicButton, underlineButton, strikethroughButton,
superscriptButton, subscriptButton, codeButton,
];
import {getMainEditorFullToolbar} from "./toolbars";
export function buildEditorUI(element: HTMLElement, editor: LexicalEditor) {
const toolbarContainer = document.createElement('div');
toolbarContainer.classList.add('editor-toolbar-container');
const buttons = toolbarButtonDefinitions.map(definition => {
return new EditorButton(definition, editor);
});
const buttonElements = buttons.map(button => button.getDOMElement());
toolbarContainer.append(...buttonElements);
element.before(toolbarContainer);
const toolbar = getMainEditorFullToolbar();
toolbar.setContext({editor});
element.before(toolbar.getDOMElement());
// Update button states on editor selection change
editor.registerCommand(SELECTION_CHANGE_COMMAND, () => {
const selection = $getSelection();
for (const button of buttons) {
button.updateActiveState(selection);
}
toolbar.updateState({editor, selection});
return false;
}, COMMAND_PRIORITY_LOW);
}

View File

@ -0,0 +1,43 @@
import {EditorButton} from "./framework/buttons";
import {
blockquote, bold, code,
dangerCallout,
h2, h3, h4, h5,
infoCallout, italic, link, paragraph,
redo, strikethrough, subscript,
successCallout, superscript, underline,
undo,
warningCallout
} from "./defaults/button-definitions";
import {EditorContainerUiElement, EditorFormatMenu} from "./framework/containers";
export function getMainEditorFullToolbar(): EditorContainerUiElement {
return new EditorContainerUiElement([
new EditorButton(undo),
new EditorButton(redo),
new EditorFormatMenu([
new EditorButton(h2),
new EditorButton(h3),
new EditorButton(h4),
new EditorButton(h5),
new EditorButton(blockquote),
new EditorButton(paragraph),
new EditorButton(infoCallout),
new EditorButton(successCallout),
new EditorButton(warningCallout),
new EditorButton(dangerCallout),
]),
new EditorButton(bold),
new EditorButton(italic),
new EditorButton(underline),
new EditorButton(strikethrough),
new EditorButton(superscript),
new EditorButton(subscript),
new EditorButton(code),
new EditorButton(link),
]);
}

View File

@ -14,6 +14,7 @@
<div refs="wysiwyg-editor@edit-area" contenteditable="true">
<p id="Content!">Some <strong>content</strong> here</p>
<p>This has a <a href="https://example.com" target="_blank" title="Link to example">link</a> in it</p>
<h2>List below this h2 header</h2>
<ul>
<li>Hello</li>