mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-10-01 01:36:00 -04:00
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:
parent
483d9bf26c
commit
dc1a40ea74
10
package-lock.json
generated
10
package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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) {
|
||||
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
39
resources/js/wysiwyg/ui/framework/base-elements.ts
Normal file
39
resources/js/wysiwyg/ui/framework/base-elements.ts
Normal 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;
|
||||
}
|
40
resources/js/wysiwyg/ui/framework/buttons.ts
Normal file
40
resources/js/wysiwyg/ui/framework/buttons.ts
Normal 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);
|
||||
}
|
||||
}
|
40
resources/js/wysiwyg/ui/framework/containers.ts
Normal file
40
resources/js/wysiwyg/ui/framework/containers.ts
Normal 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()));
|
||||
}
|
||||
|
||||
}
|
@ -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);
|
||||
}
|
43
resources/js/wysiwyg/ui/toolbars.ts
Normal file
43
resources/js/wysiwyg/ui/toolbars.ts
Normal 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),
|
||||
]);
|
||||
}
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user