Merge pull request #5928 from BookStackApp/lexical_dev_api

Initial developer API for WYSIWYG editor
This commit is contained in:
Dan Brown
2025-12-06 15:32:47 +00:00
committed by GitHub
20 changed files with 1212 additions and 634 deletions

View File

@@ -3,7 +3,7 @@
All development on BookStack is currently done on the `development` branch.
When it's time for a release the `development` branch is merged into release with built & minified CSS & JS then tagged at its version. Here are the current development requirements:
* [Node.js](https://nodejs.org/en/) v20.0+
* [Node.js](https://nodejs.org/en/) v22.0+
## Building CSS & JavaScript Assets

View File

@@ -161,3 +161,7 @@ window.$components.firstOnElement(element, name);
There are a range of available events that are emitted as part of a public & supported API for accessing or extending JavaScript libraries & components used in the system.
Details on these events can be found in the [JavaScript Public Events file](javascript-public-events.md).
## WYSIWYG Editor API
Details on the API for our custom-built WYSIWYG editor can be found in the [WYSIWYG JavaScript API file](./wysiwyg-js-api.md).

View File

@@ -60,7 +60,7 @@ This event is called when the markdown editor loads, post configuration but befo
#### Event Data
- `markdownIt` - A references to the [MarkdownIt](https://markdown-it.github.io/markdown-it/#MarkdownIt) instance used to render markdown to HTML (Just for the preview).
- `markdownIt` - A reference to the [MarkdownIt](https://markdown-it.github.io/markdown-it/#MarkdownIt) instance used to render markdown to HTML (Just for the preview).
- `displayEl` - The IFrame Element that wraps the HTML preview display.
- `cmEditorView` - The CodeMirror [EditorView](https://codemirror.net/docs/ref/#view.EditorView) instance used for the markdown input editor.
@@ -79,7 +79,7 @@ window.addEventListener('editor-markdown::setup', event => {
This event is called as the embedded diagrams.net drawing editor loads, to allow configuration of the diagrams.net interface.
See [this diagrams.net page](https://www.diagrams.net/doc/faq/configure-diagram-editor) for details on the available options for the configure event.
If using a custom diagrams.net instance, via the `DRAWIO` option, you will need to ensure your DRAWIO option URL has the `configure=1` query parameter.
If using a custom diagrams.net instance, via the `DRAWIO` option, you will need to ensure your DRAWIO option URL has the `configure=1` query parameter.
#### Event Data
@@ -134,6 +134,47 @@ window.addEventListener('editor-tinymce::setup', event => {
});
```
### `editor-wysiwyg::post-init`
This is called after the (new custom-built Lexical-based) WYSIWYG editor has been initialised.
#### Event Data
- `usage` - A string label to identify the usage type of the WYSIWYG editor in BookStack.
- `api` - An instance to the WYSIWYG editor API, as documented in the [WYSIWYG JavaScript API file](./wysiwyg-js-api.md).
##### Example
The below example shows how you'd use this API to create a button, with that button added to the main toolbar of the page editor, which inserts bold "Hello!" text on press:
<details>
<summary>Show Example</summary>
```javascript
window.addEventListener('editor-wysiwyg::post-init', event => {
const {usage, api} = event.detail;
// Check that it's the page editor which is being loaded
if (usage !== 'page-editor') {
return;
}
// Create a custom button which inserts bold hello text on press
const button = api.ui.createButton({
label: 'Greet',
action: () => {
api.content.insertHtml(`<strong>Hello!</strong>`);
}
});
// Add the button to the start of the first section within the main toolbar
const toolbar = api.ui.getMainToolbar();
if (toolbar) {
toolbar.getSections()[0]?.addButton(button, 0);
}
});
```
</details>
### `library-cm6::configure-theme`
This event is called whenever a CodeMirror instance is loaded, as a method to configure the theme used by CodeMirror. This applies to all CodeMirror instances including in-page code blocks, editors using in BookStack settings, and the Page markdown editor.
@@ -142,7 +183,7 @@ This event is called whenever a CodeMirror instance is loaded, as a method to co
- `darkModeActive` - A boolean to indicate if the current view/page is being loaded with dark mode active.
- `registerViewTheme(builder)` - A method that can be called to register a new view (CodeMirror UI) theme.
- `builder` - A function that will return an object that will be passed into the CodeMirror [EditorView.theme()](https://codemirror.net/docs/ref/#view.EditorView^theme) function as a StyleSpec.
- `builder` - A function that will return an object that will be passed into the CodeMirror [EditorView.theme()](https://codemirror.net/docs/ref/#view.EditorView^theme) function as a StyleSpec.
- `registerHighlightStyle(builder)` - A method that can be called to register a new HighlightStyle (code highlighting) theme.
- `builder` - A function, that receives a reference to [Tag.tags](https://lezer.codemirror.net/docs/ref/#highlight.tags) and returns an array of [TagStyle](https://codemirror.net/docs/ref/#language.TagStyle) objects.
@@ -301,7 +342,7 @@ This event is called just after any CodeMirror instances are initialised so that
##### Example
The below shows how you'd prepend some default text to all content (page) code blocks.
The below example shows how you'd prepend some default text to all content (page) code blocks.
<details>
<summary>Show Example</summary>
@@ -318,4 +359,4 @@ window.addEventListener('library-cm6::post-init', event => {
}
});
```
</details>
</details>

127
dev/docs/wysiwyg-js-api.md Normal file
View File

@@ -0,0 +1,127 @@
# WYSIWYG JavaScript API
**Warning: This API is currently in development and may change without notice.**
Feedback is very much welcomed via this issue: https://github.com/BookStackApp/BookStack/issues/5937
This document covers the JavaScript API for the (newer Lexical-based) WYSIWYG editor.
This API is built and designed to abstract the internals of the editor away
to provide a stable interface for performing common customizations.
Only the methods and properties documented here are guaranteed to be stable **once this API
is out of initial development**.
Other elements may be accessible but are not designed to be used directly, and therefore may change
without notice.
Stable parts of the API may still change where needed, but such changes would be noted as part of BookStack update advisories.
The methods shown here are documented using standard TypeScript notation.
## Overview
The API is provided as an object, which itself provides a number of modules
via its properties:
- `ui` - Provides methods related to the UI of the editor, like buttons and toolbars.
- `content` - Provides methods related to the live user content being edited upon.
Each of these modules, and the relevant types used within, are documented in detail below.
---
## UI Module
This module provides methods related to the UI of the editor, like buttons and toolbars.
### Methods
#### createButton(options: object): EditorApiButton
Creates a new button which can be used by other methods.
This takes an option object with the following properties:
- `label` - string, optional - Used for the button text if no icon provided, or the button tooltip if an icon is provided.
- `icon` - string, optional - The icon to use for the button. Expected to be an SVG string.
- `action` - callback, required - The action to perform when the button is clicked.
The function returns an [EditorApiButton](#editorapibutton) object.
**Example**
```javascript
const button = api.ui.createButton({
label: 'Warn',
icon: '<svg>...</svg>',
action: () => {
window.alert('You clicked the button!');
}
});
```
### getMainToolbar(): EditorApiToolbar
Get the main editor toolbar. This is typically the toolbar at the top of the editor.
The function returns an [EditorApiToolbar](#editorapitoolbar) object, or null if no toolbar is found.
**Example**
```javascript
const toolbar = api.ui.getMainToolbar();
const sections = toolbar?.getSections() || [];
if (sections.length > 0) {
sections[0].addButton(button);
}
```
### Types
These are types which may be provided from UI module methods.
#### EditorApiButton
Represents a button created via the `createButton` method.
This has the following methods:
- `setActive(isActive: boolean): void` - Sets whether the button should be in an active state or not (typically active buttons appear as pressed).
#### EditorApiToolbar
Represents a toolbar within the editor. This is a bar typically containing sets of buttons.
This has the following methods:
- `getSections(): EditorApiToolbarSection[]` - Provides the main [EditorApiToolbarSections](#editorapitoolbarsection) contained within this toolbar.
#### EditorApiToolbarSection
Represents a section of the main editor toolbar, which contains a set of buttons.
This has the following methods:
- `getLabel(): string` - Provides the string label of the section.
- `addButton(button: EditorApiButton, targetIndex: number = -1): void` - Adds a button to the section.
- By default, this will append the button, although a target index can be provided to insert at a specific position.
---
## Content Module
This module provides methods related to the live user content being edited within the editor.
### Methods
#### insertHtml(html: string, position: string = 'selection'): void
Inserts the given HTML string at the given position string.
The position, if not provided, will default to `'selection'`, replacing any existing selected content (or inserting at the selection if there's no active selection range).
Valid position string values are: `selection`, `start` and `end`. `start` & `end` are relative to the whole editor document.
The HTML is not assured to be added to the editor exactly as provided, since it will be parsed and serialised to fit the editor's internal known model format. Different parts of the HTML content may be handled differently depending on if it's block or inline content.
The function does not return anything.
**Example**
```javascript
// Basic insert at selection
api.content.insertHtml('<p>Hello <strong>world</strong>!</p>');
// Insert at the start of the editor content
api.content.insertHtml('<p>I\'m at the start!</p>', 'start');
```

1156
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,38 +19,38 @@
"test": "jest"
},
"devDependencies": {
"@eslint/js": "^9.34.0",
"@eslint/js": "^9.39.1",
"@lezer/generator": "^1.8.0",
"@types/markdown-it": "^14.1.2",
"@types/sortablejs": "^1.15.8",
"@types/sortablejs": "^1.15.9",
"chokidar-cli": "^3.0",
"esbuild": "^0.25.9",
"eslint": "^9.34.0",
"esbuild": "^0.27.0",
"eslint": "^9.39.1",
"eslint-plugin-import": "^2.32.0",
"jest": "^30.1.1",
"jest-environment-jsdom": "^30.1.1",
"jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0",
"livereload": "^0.10.3",
"npm-run-all": "^4.1.5",
"sass": "^1.91.0",
"ts-jest": "^29.4.1",
"sass": "^1.94.2",
"ts-jest": "^29.4.5",
"ts-node": "^10.9.2",
"typescript": "5.9.*"
},
"dependencies": {
"@codemirror/commands": "^6.8.1",
"@codemirror/commands": "^6.10.0",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.3.4",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-php": "^6.0.2",
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/language": "^6.11.3",
"@codemirror/legacy-modes": "^6.5.1",
"@codemirror/legacy-modes": "^6.5.2",
"@codemirror/state": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.38.1",
"@lezer/highlight": "^1.2.1",
"@codemirror/view": "^6.38.8",
"@lezer/highlight": "^1.2.3",
"@ssddanbrown/codemirror-lang-smarty": "^1.0.0",
"@ssddanbrown/codemirror-lang-twig": "^1.0.0",
"@types/jest": "^30.0.0",
@@ -58,7 +58,7 @@
"idb-keyval": "^6.2.2",
"markdown-it": "^14.1.0",
"markdown-it-task-lists": "^2.1.1",
"snabbdom": "^3.6.2",
"snabbdom": "^3.6.3",
"sortablejs": "^1.15.6"
}
}

View File

@@ -0,0 +1,14 @@
import {createTestContext} from "lexical/__tests__/utils";
import {EditorApi} from "../api";
import {EditorUiContext} from "../../ui/framework/core";
import {LexicalEditor} from "lexical";
/**
* Create an instance of the EditorApi and EditorUiContext.
*/
export function createEditorApiInstance(): { api: EditorApi; context: EditorUiContext, editor: LexicalEditor} {
const context = createTestContext();
const api = new EditorApi(context);
return {api, context, editor: context.editor};
}

View File

@@ -0,0 +1,93 @@
import {createEditorApiInstance} from "./api-test-utils";
import {$createParagraphNode, $createTextNode, $getRoot, IS_BOLD, LexicalEditor} from "lexical";
import {expectNodeShapeToMatch} from "lexical/__tests__/utils";
describe('Editor API: Content Module', () => {
describe('insertHtml()', () => {
it('should insert html at selection by default', () => {
const {api, editor} = createEditorApiInstance();
insertAndSelectSampleBlock(editor);
api.content.insertHtml('<strong>pp</strong>');
editor.commitUpdates();
expectNodeShapeToMatch(editor, [
{type: 'paragraph', children: [
{text: 'He'},
{text: 'pp', format: IS_BOLD},
{text: 'o World'}
]}
]);
});
it('should handle a mix of inline and block elements', () => {
const {api, editor} = createEditorApiInstance();
insertAndSelectSampleBlock(editor);
api.content.insertHtml('<p>cat</p><strong>pp</strong><p>dog</p>');
editor.commitUpdates();
expectNodeShapeToMatch(editor, [
{type: 'paragraph', children: [{text: 'cat'}]},
{type: 'paragraph', children: [
{text: 'He'},
{text: 'pp', format: IS_BOLD},
{text: 'o World'}
]},
{type: 'paragraph', children: [{text: 'dog'}]},
]);
});
it('should throw and error if an invalid position is provided', () => {
const {api, editor} = createEditorApiInstance();
insertAndSelectSampleBlock(editor);
expect(() => {
api.content.insertHtml('happy<p>cat</p>', 'near-the-end');
}).toThrow('Invalid position: near-the-end. Valid positions are: start, end, selection');
});
it('should append html if end provided as a position', () => {
const {api, editor} = createEditorApiInstance();
insertAndSelectSampleBlock(editor);
api.content.insertHtml('happy<p>cat</p>', 'end');
editor.commitUpdates();
expectNodeShapeToMatch(editor, [
{type: 'paragraph', children: [{text: 'Hello World'}]},
{type: 'paragraph', children: [{text: 'happy'}]},
{type: 'paragraph', children: [{text: 'cat'}]},
]);
});
it('should prepend html if start provided as a position', () => {
const {api, editor} = createEditorApiInstance();
insertAndSelectSampleBlock(editor);
api.content.insertHtml('happy<p>cat</p>', 'start');
editor.commitUpdates();
expectNodeShapeToMatch(editor, [
{type: 'paragraph', children: [{text: 'happy'}]},
{type: 'paragraph', children: [{text: 'cat'}]},
{type: 'paragraph', children: [{text: 'Hello World'}]},
]);
});
});
function insertAndSelectSampleBlock(editor: LexicalEditor) {
editor.updateAndCommit(() => {
const p = $createParagraphNode();
const text = $createTextNode('Hello World');
p.append(text);
$getRoot().append(p);
text.select(2, 4);
});
}
});

View File

@@ -0,0 +1,123 @@
import {createEditorApiInstance} from "./api-test-utils";
import {EditorApiButton, EditorApiToolbar, EditorApiToolbarSection} from "../ui";
import {getMainEditorFullToolbar} from "../../ui/defaults/toolbars";
import {EditorContainerUiElement} from "../../ui/framework/core";
import {EditorOverflowContainer} from "../../ui/framework/blocks/overflow-container";
describe('Editor API: UI Module', () => {
describe('createButton()', () => {
it('should return a button', () => {
const {api} = createEditorApiInstance();
const button = api.ui.createButton({label: 'Test', icon: 'test', action: () => ''});
expect(button).toBeInstanceOf(EditorApiButton);
});
it('should only need action to be required', () => {
const {api} = createEditorApiInstance();
const button = api.ui.createButton({action: () => ''});
expect(button).toBeInstanceOf(EditorApiButton);
});
it('should pass the label and icon to the button', () => {
const {api} = createEditorApiInstance();
const button = api.ui.createButton({label: 'TestLabel', icon: '<svg>cat</svg>', action: () => ''});
const html = button._getOriginalModel().getDOMElement().outerHTML;
expect(html).toContain('TestLabel');
expect(html).toContain('<svg>cat</svg>');
})
});
describe('EditorApiButton', () => {
describe('setActive()', () => {
it('should update the active state of the button', () => {
const {api} = createEditorApiInstance();
const button = api.ui.createButton({label: 'Test', icon: 'test', action: () => ''});
button.setActive(true);
expect(button._getOriginalModel().isActive()).toBe(true);
button.setActive(false);
expect(button._getOriginalModel().isActive()).toBe(false);
})
});
it('should call the provided action on click', () => {
const {api} = createEditorApiInstance();
let count = 0;
const button = api.ui.createButton({label: 'Test', icon: 'test', action: () => {
count++;
}});
const dom = button._getOriginalModel().getDOMElement();
dom.click();
dom.click();
expect(count).toBe(2);
});
});
describe('getMainToolbar()', () => {
it('should return the main editor toolbar', () => {
const {api, context} = createEditorApiInstance();
context.manager.setToolbar(getMainEditorFullToolbar(context));
const toolbar = api.ui.getMainToolbar();
expect(toolbar).toBeInstanceOf(EditorApiToolbar);
});
});
describe('EditorApiToolbar', () => {
describe('getSections()', () => {
it('should return the sections of the toolbar', () => {
const {api, context} = createEditorApiInstance();
context.manager.setToolbar(testToolbar());
const toolbar = api.ui.getMainToolbar();
const sections = toolbar?.getSections() || [];
expect(sections.length).toBe(2);
expect(sections[0]).toBeInstanceOf(EditorApiToolbarSection);
})
})
})
describe('EditorApiToolbarSection', () => {
describe('getLabel()', () => {
it('should return the label of the section', () => {
const {api, context} = createEditorApiInstance();
context.manager.setToolbar(testToolbar());
const section = api.ui.getMainToolbar()?.getSections()[0] as EditorApiToolbarSection;
expect(section.getLabel()).toBe('section-a');
})
});
describe('addButton()', () => {
it('should add a button to the section', () => {
const {api, context} = createEditorApiInstance();
const toolbar = testToolbar();
context.manager.setToolbar(toolbar);
const section = api.ui.getMainToolbar()?.getSections()[0] as EditorApiToolbarSection;
const button = api.ui.createButton({label: 'TestButtonText!', action: () => ''});
section.addButton(button);
const toolbarRendered = toolbar.getDOMElement().innerHTML;
expect(toolbarRendered).toContain('TestButtonText!');
});
});
});
function testToolbar(): EditorContainerUiElement {
return new EditorContainerUiElement([
new EditorOverflowContainer('section-a', 1, []),
new EditorOverflowContainer('section-b', 1, []),
]);
}
});

View File

@@ -0,0 +1,14 @@
import {EditorApiUiModule} from "./ui";
import {EditorUiContext} from "../ui/framework/core";
import {EditorApiContentModule} from "./content";
export class EditorApi {
public ui: EditorApiUiModule;
public content: EditorApiContentModule;
constructor(context: EditorUiContext) {
this.ui = new EditorApiUiModule(context);
this.content = new EditorApiContentModule(context);
}
}

View File

@@ -0,0 +1,26 @@
import {EditorUiContext} from "../ui/framework/core";
import {appendHtmlToEditor, insertHtmlIntoEditor, prependHtmlToEditor} from "../utils/actions";
export class EditorApiContentModule {
readonly #context: EditorUiContext;
constructor(context: EditorUiContext) {
this.#context = context;
}
insertHtml(html: string, position: string = 'selection'): void {
const validPositions = ['start', 'end', 'selection'];
if (!validPositions.includes(position)) {
throw new Error(`Invalid position: ${position}. Valid positions are: ${validPositions.join(', ')}`);
}
if (position === 'start') {
prependHtmlToEditor(this.#context.editor, html);
} else if (position === 'end') {
appendHtmlToEditor(this.#context.editor, html);
} else {
insertHtmlIntoEditor(this.#context.editor, html);
}
}
}

View File

@@ -0,0 +1,89 @@
import {EditorButton} from "../ui/framework/buttons";
import {EditorContainerUiElement, EditorUiContext} from "../ui/framework/core";
import {EditorOverflowContainer} from "../ui/framework/blocks/overflow-container";
type EditorApiButtonOptions = {
label?: string;
icon?: string;
action: () => void;
};
export class EditorApiButton {
readonly #button: EditorButton;
#isActive: boolean = false;
constructor(options: EditorApiButtonOptions, context: EditorUiContext) {
this.#button = new EditorButton({
label: options.label || '',
icon: options.icon || '',
action: () => {
options.action();
},
isActive: () => this.#isActive,
});
this.#button.setContext(context);
}
setActive(active: boolean = true): void {
this.#isActive = active;
this.#button.setActiveState(active);
}
_getOriginalModel() {
return this.#button;
}
}
export class EditorApiToolbar {
readonly #toolbar: EditorContainerUiElement;
constructor(toolbar: EditorContainerUiElement) {
this.#toolbar = toolbar;
}
getSections(): EditorApiToolbarSection[] {
const sections = this.#toolbar.getChildren();
return sections.filter(section => {
return section instanceof EditorOverflowContainer;
}).map(section => new EditorApiToolbarSection(section));
}
}
export class EditorApiToolbarSection {
readonly #section: EditorOverflowContainer;
constructor(section: EditorOverflowContainer) {
this.#section = section;
}
getLabel(): string {
return this.#section.getLabel();
}
addButton(button: EditorApiButton, targetIndex: number = -1): void {
this.#section.addChild(button._getOriginalModel(), targetIndex);
this.#section.rebuildDOM();
}
}
export class EditorApiUiModule {
readonly #context: EditorUiContext;
constructor(context: EditorUiContext) {
this.#context = context;
}
createButton(options: EditorApiButtonOptions): EditorApiButton {
return new EditorApiButton(options, this.#context);
}
getMainToolbar(): EditorApiToolbar|null {
const toolbar = this.#context.manager.getToolbar();
if (!toolbar) {
return null;
}
return new EditorApiToolbar(toolbar);
}
}

View File

@@ -21,6 +21,7 @@ import {CodeBlockDecorator} from "./ui/decorators/code-block";
import {DiagramDecorator} from "./ui/decorators/diagram";
import {registerMouseHandling} from "./services/mouse-handling";
import {registerSelectionHandling} from "./services/selection-handling";
import {EditorApi} from "./api/api";
const theme = {
text: {
@@ -94,6 +95,11 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st
registerCommonNodeMutationListeners(context);
window.$events.emitPublic(container, 'editor-wysiwyg::post-init', {
usage: 'page-editor',
api: new EditorApi(context),
});
return new SimpleWysiwygEditorInterface(context);
}
@@ -122,6 +128,11 @@ export function createBasicEditorInstance(container: HTMLElement, htmlContent: s
setEditorContentFromHtml(editor, htmlContent);
window.$events.emitPublic(container, 'editor-wysiwyg::post-init', {
usage: 'description-editor',
api: new EditorApi(context),
});
return new SimpleWysiwygEditorInterface(context);
}

View File

@@ -504,7 +504,7 @@ export function createTestContext(): EditorUiContext {
options: {},
scrollDOM: scrollWrap,
translate(text: string): string {
return "";
return text;
}
};
@@ -769,6 +769,7 @@ export function expectHtmlToBeEqual(expected: string, actual: string): void {
type nodeTextShape = {
text: string;
format?: number;
};
type nodeShape = {
@@ -786,7 +787,13 @@ export function getNodeShape(node: SerializedLexicalNode): nodeShape|nodeTextSha
if (shape.type === 'text') {
// @ts-ignore
return {text: node.text}
const shape: nodeTextShape = {text: node.text}
// @ts-ignore
if (node && node.format) {
// @ts-ignore
shape.format = node.format;
}
return shape;
}
if (children.length > 0) {

View File

@@ -1,4 +1,4 @@
import {$getSelection, LexicalEditor} from "lexical";
import {LexicalEditor} from "lexical";
import {
appendHtmlToEditor,
focusEditor,

View File

@@ -88,7 +88,7 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai
return new EditorSimpleClassContainer('editor-toolbar-main', [
// History state
new EditorOverflowContainer(2, [
new EditorOverflowContainer('history', 2, [
new EditorButton(undo),
new EditorButton(redo),
]),
@@ -110,7 +110,7 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai
]),
// Inline formats
new EditorOverflowContainer(6, [
new EditorOverflowContainer('inline_formats', 6, [
new EditorButton(bold),
new EditorButton(italic),
new EditorButton(underline),
@@ -128,7 +128,7 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai
]),
// Alignment
new EditorOverflowContainer(6, [
new EditorOverflowContainer('alignment', 6, [
new EditorButton(alignLeft),
new EditorButton(alignCenter),
new EditorButton(alignRight),
@@ -138,7 +138,7 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai
].filter(x => x !== null)),
// Lists
new EditorOverflowContainer(3, [
new EditorOverflowContainer('lists', 3, [
new EditorButton(bulletList),
new EditorButton(numberList),
new EditorButton(taskList),
@@ -147,7 +147,7 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai
]),
// Insert types
new EditorOverflowContainer(4, [
new EditorOverflowContainer('inserts', 4, [
new EditorButton(link),
new EditorDropdownButton({button: table, direction: 'vertical', showAside: false}, [
@@ -200,7 +200,7 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai
]),
// Meta elements
new EditorOverflowContainer(3, [
new EditorOverflowContainer('meta', 3, [
new EditorButton(source),
new EditorButton(about),
new EditorButton(fullscreen),
@@ -223,11 +223,13 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai
export function getBasicEditorToolbar(context: EditorUiContext): EditorContainerUiElement {
return new EditorSimpleClassContainer('editor-toolbar-main', [
new EditorButton(bold),
new EditorButton(italic),
new EditorButton(link),
new EditorButton(bulletList),
new EditorButton(numberList),
new EditorOverflowContainer('formats', 7, [
new EditorButton(bold),
new EditorButton(italic),
new EditorButton(link),
new EditorButton(bulletList),
new EditorButton(numberList),
])
]);
}
@@ -261,16 +263,16 @@ export const contextToolbars: Record<string, EditorContextToolbarDefinition> = {
selector: 'td,th',
content() {
return [
new EditorOverflowContainer(2, [
new EditorOverflowContainer('table', 2, [
new EditorButton(tableProperties),
new EditorButton(deleteTable),
]),
new EditorOverflowContainer(3, [
new EditorOverflowContainer('table_row',3, [
new EditorButton(insertRowAbove),
new EditorButton(insertRowBelow),
new EditorButton(deleteRow),
]),
new EditorOverflowContainer(3, [
new EditorOverflowContainer('table_column', 3, [
new EditorButton(insertColumnBefore),
new EditorButton(insertColumnAfter),
new EditorButton(deleteColumn),

View File

@@ -9,9 +9,11 @@ export class EditorOverflowContainer extends EditorContainerUiElement {
protected size: number;
protected overflowButton: EditorDropdownButton;
protected content: EditorUiElement[];
protected label: string;
constructor(size: number, children: EditorUiElement[]) {
constructor(label: string, size: number, children: EditorUiElement[]) {
super(children);
this.label = label;
this.size = size;
this.content = children;
this.overflowButton = new EditorDropdownButton({
@@ -24,6 +26,11 @@ export class EditorOverflowContainer extends EditorContainerUiElement {
this.addChildren(this.overflowButton);
}
addChild(child: EditorUiElement, targetIndex: number = -1): void {
this.content.splice(targetIndex, 0, child);
this.addChildren(child);
}
protected buildDOM(): HTMLElement {
const slicePosition = this.content.length > this.size ? this.size - 1 : this.size;
const visibleChildren = this.content.slice(0, slicePosition);
@@ -41,5 +48,8 @@ export class EditorOverflowContainer extends EditorContainerUiElement {
}, visibleElements);
}
getLabel(): string {
return this.label;
}
}

View File

@@ -109,6 +109,10 @@ export class EditorUIManager {
this.getContext().containerDOM.prepend(toolbar.getDOMElement());
}
getToolbar(): EditorContainerUiElement|null {
return this.toolbar;
}
registerContextToolbar(key: string, definition: EditorContextToolbarDefinition) {
this.contextToolbarDefinitionsByKey[key] = definition;
}

View File

@@ -1,6 +1,6 @@
import {$getRoot, $getSelection, LexicalEditor} from "lexical";
import {$getRoot, $getSelection, $insertNodes, $isBlockElementNode, LexicalEditor} from "lexical";
import {$generateHtmlFromNodes} from "@lexical/html";
import {$htmlToBlockNodes} from "./nodes";
import {$getNearestNodeBlockParent, $htmlToBlockNodes, $htmlToNodes} from "./nodes";
export function setEditorContentFromHtml(editor: LexicalEditor, html: string) {
editor.update(() => {
@@ -42,14 +42,34 @@ export function prependHtmlToEditor(editor: LexicalEditor, html: string) {
export function insertHtmlIntoEditor(editor: LexicalEditor, html: string) {
editor.update(() => {
const selection = $getSelection();
const nodes = $htmlToBlockNodes(editor, html);
const nodes = $htmlToNodes(editor, html);
const reference = selection?.getNodes()[0];
const referencesParents = reference?.getParents() || [];
const topLevel = referencesParents[referencesParents.length - 1];
if (topLevel && reference) {
for (let i = nodes.length - 1; i >= 0; i--) {
reference.insertAfter(nodes[i]);
let reference = selection?.getNodes()[0];
let replacedReference = false;
let parentBlock = reference ? $getNearestNodeBlockParent(reference) : null;
for (let i = nodes.length - 1; i >= 0; i--) {
const toInsert = nodes[i];
if ($isBlockElementNode(toInsert) && parentBlock) {
// Insert at a block level, before or after the referenced block
// depending on if the reference has been replaced.
if (replacedReference) {
parentBlock.insertBefore(toInsert);
} else {
parentBlock.insertAfter(toInsert);
}
} else if ($isBlockElementNode(toInsert)) {
// Otherwise append blocks to the root
$getRoot().append(toInsert);
} else if (!replacedReference) {
// First inline node, replacing existing selection
$insertNodes([toInsert]);
reference = toInsert;
parentBlock = $getNearestNodeBlockParent(reference);
replacedReference = true;
} else {
// For other inline nodes, insert before the reference node
reference?.insertBefore(toInsert)
}
}
});

View File

@@ -25,10 +25,13 @@ function wrapTextNodes(nodes: LexicalNode[]): LexicalNode[] {
});
}
export function $htmlToBlockNodes(editor: LexicalEditor, html: string): LexicalNode[] {
export function $htmlToNodes(editor: LexicalEditor, html: string): LexicalNode[] {
const dom = htmlToDom(html);
const nodes = $generateNodesFromDOM(editor, dom);
return wrapTextNodes(nodes);
return $generateNodesFromDOM(editor, dom);
}
export function $htmlToBlockNodes(editor: LexicalEditor, html: string): LexicalNode[] {
return wrapTextNodes($htmlToNodes(editor, html));
}
export function $getParentOfType(node: LexicalNode, matcher: LexicalNodeMatcher): LexicalNode | null {