diff --git a/dev/docs/wysiwyg-js-api.md b/dev/docs/wysiwyg-js-api.md index dec09b1a8..44e4a52c0 100644 --- a/dev/docs/wysiwyg-js-api.md +++ b/dev/docs/wysiwyg-js-api.md @@ -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. \ No newline at end of file + - 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('

Hello world!

'); + +// Insert at the start of the editor content +api.content.insertHtml('

I\'m at the start!

', 'start'); +``` \ No newline at end of file diff --git a/resources/js/wysiwyg/api/__tests__/api-test-utils.ts b/resources/js/wysiwyg/api/__tests__/api-test-utils.ts index beff8504f..dacec3392 100644 --- a/resources/js/wysiwyg/api/__tests__/api-test-utils.ts +++ b/resources/js/wysiwyg/api/__tests__/api-test-utils.ts @@ -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}; } \ No newline at end of file diff --git a/resources/js/wysiwyg/api/__tests__/content.test.ts b/resources/js/wysiwyg/api/__tests__/content.test.ts new file mode 100644 index 000000000..0915b6685 --- /dev/null +++ b/resources/js/wysiwyg/api/__tests__/content.test.ts @@ -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('pp'); + 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('

cat

pp

dog

'); + 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

cat

', '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

cat

', '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

cat

', '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); + }); + } + +}); \ No newline at end of file diff --git a/resources/js/wysiwyg/api/api.ts b/resources/js/wysiwyg/api/api.ts index 434109c7a..f732170b3 100644 --- a/resources/js/wysiwyg/api/api.ts +++ b/resources/js/wysiwyg/api/api.ts @@ -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); } } \ No newline at end of file diff --git a/resources/js/wysiwyg/api/content.ts b/resources/js/wysiwyg/api/content.ts new file mode 100644 index 000000000..167ec7905 --- /dev/null +++ b/resources/js/wysiwyg/api/content.ts @@ -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); + } + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts index 4f08f35ed..ab54bdb31 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts @@ -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) { diff --git a/resources/js/wysiwyg/services/common-events.ts b/resources/js/wysiwyg/services/common-events.ts index 2ffa722e4..f7fc81cb3 100644 --- a/resources/js/wysiwyg/services/common-events.ts +++ b/resources/js/wysiwyg/services/common-events.ts @@ -1,4 +1,4 @@ -import {$getSelection, LexicalEditor} from "lexical"; +import {LexicalEditor} from "lexical"; import { appendHtmlToEditor, focusEditor, diff --git a/resources/js/wysiwyg/utils/actions.ts b/resources/js/wysiwyg/utils/actions.ts index b7ce65eeb..e18ac515f 100644 --- a/resources/js/wysiwyg/utils/actions.ts +++ b/resources/js/wysiwyg/utils/actions.ts @@ -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) } } }); diff --git a/resources/js/wysiwyg/utils/nodes.ts b/resources/js/wysiwyg/utils/nodes.ts index 116a3f4e5..ed70bf699 100644 --- a/resources/js/wysiwyg/utils/nodes.ts +++ b/resources/js/wysiwyg/utils/nodes.ts @@ -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 {