Skip to content

Commit dfdcfcf

Browse files
committed
Lexical API: Added content module, testing and documented
1 parent ebceba0 commit dfdcfcf

File tree

9 files changed

+202
-25
lines changed

9 files changed

+202
-25
lines changed

dev/docs/wysiwyg-js-api.md

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,10 @@
33
TODO - Link to this from JS code doc.
44
TODO - Create JS events and add to the js public events doc.
55

6-
TODO - Document the JS API.
7-
8-
TODO - Add testing coverage
9-
106
**Warning: This API is currently in development and may change without notice.**
117

128
This document covers the JavaScript API for the (newer Lexical-based) WYSIWYG editor.
13-
This API is custom-built, and designed to abstract the internals of the editor away
9+
This API is built and designed to abstract the internals of the editor away
1410
to provide a stable interface for performing common customizations.
1511

1612
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
2521
via its properties:
2622

2723
- `ui` - Provides all actions related to the UI of the editor, like buttons and toolbars.
24+
- `content` - Provides all actions related to the live user content being edited upon.
2825

29-
Each of these modules, and the relevant types used within, can be found detailed below.
26+
Each of these modules, and the relevant types used within, are documented in detail below.
3027

3128
---
3229

@@ -36,7 +33,7 @@ This module provides all actions related to the UI of the editor, like buttons a
3633

3734
### Methods
3835

39-
#### createButton(options)
36+
#### createButton(options: object)
4037

4138
Creates a new button which can be used by other methods.
4239
This takes an option object with the following properties:
@@ -92,6 +89,33 @@ This has the following methods:
9289
Represents a section of the main editor toolbar, which contains a set of buttons.
9390
This has the following methods:
9491

95-
- `getLabel(): string` - Gets the label of the section.
92+
- `getLabel(): string` - Provides the string label of the section.
9693
- `addButton(button: EditorApiButton, targetIndex: number = -1): void` - Adds a button to the section.
97-
- By default, this will append the button, although a target index can be provided to insert the button at a specific position.
94+
- By default, this will append the button, although a target index can be provided to insert the button at a specific position.
95+
96+
---
97+
98+
## Content Module
99+
100+
This module provides all actions related to the live user content being edited within the editor.
101+
102+
### Methods
103+
104+
#### insertHtml(html, position)
105+
106+
Inserts the given HTML string at the given position string.
107+
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).
108+
Valid position string values are: `selection`, `start` and `end`. `start` & `end` are relative to the whole editor document.
109+
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.
110+
111+
The function does not return anything.
112+
113+
**Example**
114+
115+
```javascript
116+
// Basic insert at selection
117+
api.content.insertHtml('<p>Hello <strong>world</strong>!</p>');
118+
119+
// Insert at the start of the editor content
120+
api.content.insertHtml('<p>I\'m at the start!</p>', 'start');
121+
```
Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import {createTestContext} from "lexical/__tests__/utils";
22
import {EditorApi} from "../api";
33
import {EditorUiContext} from "../../ui/framework/core";
4+
import {LexicalEditor} from "lexical";
45

56

67
/**
78
* Create an instance of the EditorApi and EditorUiContext.
89
*/
9-
export function createEditorApiInstance(): { api: EditorApi; context: EditorUiContext } {
10+
export function createEditorApiInstance(): { api: EditorApi; context: EditorUiContext, editor: LexicalEditor} {
1011
const context = createTestContext();
1112
const api = new EditorApi(context);
12-
return {api, context};
13+
return {api, context, editor: context.editor};
1314
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import {createEditorApiInstance} from "./api-test-utils";
2+
import {$createParagraphNode, $createTextNode, $getRoot, IS_BOLD, LexicalEditor} from "lexical";
3+
import {expectNodeShapeToMatch} from "lexical/__tests__/utils";
4+
5+
6+
describe('Editor API: Content Module', () => {
7+
8+
describe('insertHtml()', () => {
9+
it('should insert html at selection by default', () => {
10+
const {api, editor} = createEditorApiInstance();
11+
insertAndSelectSampleBlock(editor);
12+
13+
api.content.insertHtml('<strong>pp</strong>');
14+
editor.commitUpdates();
15+
16+
expectNodeShapeToMatch(editor, [
17+
{type: 'paragraph', children: [
18+
{text: 'He'},
19+
{text: 'pp', format: IS_BOLD},
20+
{text: 'o World'}
21+
]}
22+
]);
23+
});
24+
25+
it('should handle a mix of inline and block elements', () => {
26+
const {api, editor} = createEditorApiInstance();
27+
insertAndSelectSampleBlock(editor);
28+
29+
api.content.insertHtml('<p>cat</p><strong>pp</strong><p>dog</p>');
30+
editor.commitUpdates();
31+
32+
expectNodeShapeToMatch(editor, [
33+
{type: 'paragraph', children: [{text: 'cat'}]},
34+
{type: 'paragraph', children: [
35+
{text: 'He'},
36+
{text: 'pp', format: IS_BOLD},
37+
{text: 'o World'}
38+
]},
39+
{type: 'paragraph', children: [{text: 'dog'}]},
40+
]);
41+
});
42+
43+
it('should throw and error if an invalid position is provided', () => {
44+
const {api, editor} = createEditorApiInstance();
45+
insertAndSelectSampleBlock(editor);
46+
47+
48+
expect(() => {
49+
api.content.insertHtml('happy<p>cat</p>', 'near-the-end');
50+
}).toThrow('Invalid position: near-the-end. Valid positions are: start, end, selection');
51+
});
52+
53+
it('should append html if end provided as a position', () => {
54+
const {api, editor} = createEditorApiInstance();
55+
insertAndSelectSampleBlock(editor);
56+
57+
api.content.insertHtml('happy<p>cat</p>', 'end');
58+
editor.commitUpdates();
59+
60+
expectNodeShapeToMatch(editor, [
61+
{type: 'paragraph', children: [{text: 'Hello World'}]},
62+
{type: 'paragraph', children: [{text: 'happy'}]},
63+
{type: 'paragraph', children: [{text: 'cat'}]},
64+
]);
65+
});
66+
67+
it('should prepend html if start provided as a position', () => {
68+
const {api, editor} = createEditorApiInstance();
69+
insertAndSelectSampleBlock(editor);
70+
71+
api.content.insertHtml('happy<p>cat</p>', 'start');
72+
editor.commitUpdates();
73+
74+
expectNodeShapeToMatch(editor, [
75+
{type: 'paragraph', children: [{text: 'happy'}]},
76+
{type: 'paragraph', children: [{text: 'cat'}]},
77+
{type: 'paragraph', children: [{text: 'Hello World'}]},
78+
]);
79+
});
80+
});
81+
82+
function insertAndSelectSampleBlock(editor: LexicalEditor) {
83+
editor.updateAndCommit(() => {
84+
const p = $createParagraphNode();
85+
const text = $createTextNode('Hello World');
86+
p.append(text);
87+
$getRoot().append(p);
88+
89+
text.select(2, 4);
90+
});
91+
}
92+
93+
});

resources/js/wysiwyg/api/api.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import {EditorApiUiModule} from "./ui";
22
import {EditorUiContext} from "../ui/framework/core";
3+
import {EditorApiContentModule} from "./content";
34

45
export class EditorApi {
56

67
public ui: EditorApiUiModule;
8+
public content: EditorApiContentModule;
79

810
constructor(context: EditorUiContext) {
911
this.ui = new EditorApiUiModule(context);
12+
this.content = new EditorApiContentModule(context);
1013
}
1114
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import {EditorUiContext} from "../ui/framework/core";
2+
import {appendHtmlToEditor, insertHtmlIntoEditor, prependHtmlToEditor} from "../utils/actions";
3+
4+
5+
export class EditorApiContentModule {
6+
readonly #context: EditorUiContext;
7+
8+
constructor(context: EditorUiContext) {
9+
this.#context = context;
10+
}
11+
12+
insertHtml(html: string, position: string = 'selection'): void {
13+
const validPositions = ['start', 'end', 'selection'];
14+
if (!validPositions.includes(position)) {
15+
throw new Error(`Invalid position: ${position}. Valid positions are: ${validPositions.join(', ')}`);
16+
}
17+
18+
if (position === 'start') {
19+
prependHtmlToEditor(this.#context.editor, html);
20+
} else if (position === 'end') {
21+
appendHtmlToEditor(this.#context.editor, html);
22+
} else {
23+
insertHtmlIntoEditor(this.#context.editor, html);
24+
}
25+
}
26+
}

resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -769,6 +769,7 @@ export function expectHtmlToBeEqual(expected: string, actual: string): void {
769769

770770
type nodeTextShape = {
771771
text: string;
772+
format?: number;
772773
};
773774

774775
type nodeShape = {
@@ -786,7 +787,13 @@ export function getNodeShape(node: SerializedLexicalNode): nodeShape|nodeTextSha
786787

787788
if (shape.type === 'text') {
788789
// @ts-ignore
789-
return {text: node.text}
790+
const shape: nodeTextShape = {text: node.text}
791+
// @ts-ignore
792+
if (node && node.format) {
793+
// @ts-ignore
794+
shape.format = node.format;
795+
}
796+
return shape;
790797
}
791798

792799
if (children.length > 0) {

resources/js/wysiwyg/services/common-events.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {$getSelection, LexicalEditor} from "lexical";
1+
import {LexicalEditor} from "lexical";
22
import {
33
appendHtmlToEditor,
44
focusEditor,

resources/js/wysiwyg/utils/actions.ts

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import {$getRoot, $getSelection, LexicalEditor} from "lexical";
1+
import {$getRoot, $getSelection, $insertNodes, $isBlockElementNode, LexicalEditor} from "lexical";
22
import {$generateHtmlFromNodes} from "@lexical/html";
3-
import {$htmlToBlockNodes} from "./nodes";
3+
import {$getNearestNodeBlockParent, $htmlToBlockNodes, $htmlToNodes} from "./nodes";
44

55
export function setEditorContentFromHtml(editor: LexicalEditor, html: string) {
66
editor.update(() => {
@@ -42,14 +42,34 @@ export function prependHtmlToEditor(editor: LexicalEditor, html: string) {
4242
export function insertHtmlIntoEditor(editor: LexicalEditor, html: string) {
4343
editor.update(() => {
4444
const selection = $getSelection();
45-
const nodes = $htmlToBlockNodes(editor, html);
45+
const nodes = $htmlToNodes(editor, html);
46+
47+
let reference = selection?.getNodes()[0];
48+
let replacedReference = false;
49+
let parentBlock = reference ? $getNearestNodeBlockParent(reference) : null;
4650

47-
const reference = selection?.getNodes()[0];
48-
const referencesParents = reference?.getParents() || [];
49-
const topLevel = referencesParents[referencesParents.length - 1];
50-
if (topLevel && reference) {
51-
for (let i = nodes.length - 1; i >= 0; i--) {
52-
reference.insertAfter(nodes[i]);
51+
for (let i = nodes.length - 1; i >= 0; i--) {
52+
const toInsert = nodes[i];
53+
if ($isBlockElementNode(toInsert) && parentBlock) {
54+
// Insert at a block level, before or after the referenced block
55+
// depending on if the reference has been replaced.
56+
if (replacedReference) {
57+
parentBlock.insertBefore(toInsert);
58+
} else {
59+
parentBlock.insertAfter(toInsert);
60+
}
61+
} else if ($isBlockElementNode(toInsert)) {
62+
// Otherwise append blocks to the root
63+
$getRoot().append(toInsert);
64+
} else if (!replacedReference) {
65+
// First inline node, replacing existing selection
66+
$insertNodes([toInsert]);
67+
reference = toInsert;
68+
parentBlock = $getNearestNodeBlockParent(reference);
69+
replacedReference = true;
70+
} else {
71+
// For other inline nodes, insert before the reference node
72+
reference?.insertBefore(toInsert)
5373
}
5474
}
5575
});

resources/js/wysiwyg/utils/nodes.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,13 @@ function wrapTextNodes(nodes: LexicalNode[]): LexicalNode[] {
2525
});
2626
}
2727

28-
export function $htmlToBlockNodes(editor: LexicalEditor, html: string): LexicalNode[] {
28+
export function $htmlToNodes(editor: LexicalEditor, html: string): LexicalNode[] {
2929
const dom = htmlToDom(html);
30-
const nodes = $generateNodesFromDOM(editor, dom);
31-
return wrapTextNodes(nodes);
30+
return $generateNodesFromDOM(editor, dom);
31+
}
32+
33+
export function $htmlToBlockNodes(editor: LexicalEditor, html: string): LexicalNode[] {
34+
return wrapTextNodes($htmlToNodes(editor, html));
3235
}
3336

3437
export function $getParentOfType(node: LexicalNode, matcher: LexicalNodeMatcher): LexicalNode | null {

0 commit comments

Comments
 (0)