1
0
mirror of https://github.com/BookStackApp/BookStack.git synced 2025-12-11 19:57:23 +03:00

Lexical API: Added content module, testing and documented

This commit is contained in:
Dan Brown
2025-12-05 12:15:18 +00:00
parent ebceba0afe
commit dfdcfcfdb8
9 changed files with 202 additions and 25 deletions

View File

@@ -3,14 +3,10 @@
TODO - Link to this from JS code doc.
TODO - Create JS events and add to the js public events doc.
TODO - Document the JS API.
TODO - Add testing coverage
**Warning: This API is currently in development and may change without notice.**
This document covers the JavaScript API for the (newer Lexical-based) WYSIWYG editor.
This API is custom-built, and designed to abstract the internals of the editor away
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
@@ -25,8 +21,9 @@ The API is provided as an object, which itself provides a number of modules
via its properties:
- `ui` - Provides all actions related to the UI of the editor, like buttons and toolbars.
- `content` - Provides all actions related to the live user content being edited upon.
Each of these modules, and the relevant types used within, can be found detailed below.
Each of these modules, and the relevant types used within, are documented in detail below.
---
@@ -36,7 +33,7 @@ This module provides all actions related to the UI of the editor, like buttons a
### Methods
#### createButton(options)
#### createButton(options: object)
Creates a new button which can be used by other methods.
This takes an option object with the following properties:
@@ -92,6 +89,33 @@ This has the following methods:
Represents a section of the main editor toolbar, which contains a set of buttons.
This has the following methods:
- `getLabel(): string` - Gets the label of the section.
- `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 the button at a specific position.
---
## Content Module
This module provides all actions related to the live user content being edited within the editor.
### Methods
#### insertHtml(html, position)
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');
```

View File

@@ -1,13 +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 } {
export function createEditorApiInstance(): { api: EditorApi; context: EditorUiContext, editor: LexicalEditor} {
const context = createTestContext();
const api = new EditorApi(context);
return {api, 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

@@ -1,11 +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

@@ -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

@@ -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 {