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:
@@ -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};
|
||||
}
|
||||
93
resources/js/wysiwyg/api/__tests__/content.test.ts
Normal file
93
resources/js/wysiwyg/api/__tests__/content.test.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
26
resources/js/wysiwyg/api/content.ts
Normal file
26
resources/js/wysiwyg/api/content.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {$getSelection, LexicalEditor} from "lexical";
|
||||
import {LexicalEditor} from "lexical";
|
||||
import {
|
||||
appendHtmlToEditor,
|
||||
focusEditor,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user