Lexical: Fixed actions not applying on empty state

Updated editor to always attempt to start from at least a paragraph
isntead of empty state.
Improved focus on HTML change.
This commit is contained in:
Dan Brown
2026-05-09 17:54:24 +01:00
parent 1ef9b7d48f
commit b53499932b
4 changed files with 129 additions and 3 deletions

View File

@@ -1,12 +1,16 @@
import {EditorFormDefinition} from "../../framework/forms";
import {EditorUiContext, EditorUiElement} from "../../framework/core";
import {setEditorContentFromHtml} from "../../../utils/actions";
import {focusEditor, setEditorContentFromHtml} from "../../../utils/actions";
import {ExternalContent} from "../../framework/blocks/external-content";
export const source: EditorFormDefinition = {
submitText: 'Save',
async action(formData, context: EditorUiContext) {
setEditorContentFromHtml(context.editor, formData.get('source')?.toString() || '');
context.editor.commitUpdates();
window.requestAnimationFrame(() => {
focusEditor(context.editor);
});
return true;
},
fields: [

View File

@@ -0,0 +1,107 @@
import {
createTestContext,
destroyFromContext,
expectNodeShapeToMatch,
} from "lexical/__tests__/utils";
import { $createParagraphNode, $createTextNode, $getRoot, LexicalEditor } from "lexical";
import { EditorUiContext } from "../../ui/framework/core";
import { setEditorContentFromHtml } from "../actions";
describe('Actions', () => {
let context!: EditorUiContext;
let editor!: LexicalEditor;
beforeEach(() => {
context = createTestContext();
editor = context.editor;
});
afterEach(() => {
destroyFromContext(context);
});
describe('setEditorContentFromHtml', () => {
it('parses HTML and sets content in editor', async () => {
setEditorContentFromHtml(editor, '<p>Hello World</p>');
await Promise.resolve().then();
expectNodeShapeToMatch(editor, [
{
type: 'paragraph',
children: [{ text: 'Hello World' }],
},
]);
});
it('parses multiple block elements', async () => {
setEditorContentFromHtml(editor, '<p>First</p><p>Second</p>');
await Promise.resolve().then();
expectNodeShapeToMatch(editor, [
{ type: 'paragraph', children: [{ text: 'First' }] },
{ type: 'paragraph', children: [{ text: 'Second' }] },
]);
});
it('wraps plain text in a paragraph', async () => {
setEditorContentFromHtml(editor, 'Plain text');
await Promise.resolve().then();
expectNodeShapeToMatch(editor, [
{
type: 'paragraph',
children: [{ text: 'Plain text' }],
},
]);
});
it('ensures at least a paragraph when HTML is empty', async () => {
setEditorContentFromHtml(editor, '');
await Promise.resolve().then();
expectNodeShapeToMatch(editor, [
{ type: 'paragraph' },
]);
});
it('ensures at least a paragraph when HTML contains only whitespace', async () => {
setEditorContentFromHtml(editor, ' ');
await Promise.resolve().then();
expectNodeShapeToMatch(editor, [
{ type: 'paragraph' },
]);
});
it('clears existing content before setting new content', async () => {
editor.updateAndCommit(() => {
const p = $createParagraphNode();
p.append($createTextNode('Existing'));
$getRoot().append(p);
});
setEditorContentFromHtml(editor, '<p>New</p>');
await Promise.resolve().then();
expectNodeShapeToMatch(editor, [
{ type: 'paragraph', children: [{ text: 'New' }] },
]);
});
it('handles nested HTML structures', async () => {
setEditorContentFromHtml(editor, '<ul><li>Item A</li><li>Item B</li></ul>');
await Promise.resolve().then();
expectNodeShapeToMatch(editor, [
{
type: 'list',
children: [
{ type: 'listitem', children: [{ text: 'Item A' }] },
{ type: 'listitem', children: [{ text: 'Item B' }] },
],
},
]);
});
});
});

View File

@@ -1,4 +1,4 @@
import {$getRoot, $getSelection, $insertNodes, $isBlockElementNode, LexicalEditor} from "lexical";
import {$createParagraphNode, $getRoot, $getSelection, $insertNodes, $isBlockElementNode, LexicalEditor} from "lexical";
import {$generateHtmlFromNodes} from "@lexical/html";
import {$getNearestNodeBlockParent, $htmlToBlockNodes, $htmlToNodes} from "./nodes";
@@ -12,6 +12,12 @@ export function setEditorContentFromHtml(editor: LexicalEditor, html: string) {
const nodes = $htmlToBlockNodes(editor, html);
root.append(...nodes);
// Always ensure we at least have a paragraph in the root
// as a target for the cursor/focus/actions.
if (root.isEmpty()) {
root.append($createParagraphNode());
}
});
}
@@ -85,5 +91,14 @@ export function getEditorContentAsHtml(editor: LexicalEditor): Promise<string> {
}
export function focusEditor(editor: LexicalEditor): void {
editor.update(() => {
const root = $getRoot();
const selection = $getSelection();
const firstChild = root.getFirstChild();
if (firstChild && !selection) {
firstChild.selectStart();
}
});
editor.commitUpdates();
editor.focus(() => {}, {defaultSelection: "rootStart"});
}

View File

@@ -11,7 +11,7 @@
{{-- <div id="lexical-debug" style="white-space: pre-wrap; font-size: 12px; height: 200px; overflow-y: scroll; background-color: #000; padding: 1rem; border-radius: 4px; color: #FFF;"></div>--}}
<textarea refs="wysiwyg-editor@input" id="html-editor" hidden="hidden" name="html" rows="5">{{ old('html') ?? $model->html ?? '' }}</textarea>
<textarea refs="wysiwyg-editor@input" id="html-editor" hidden="hidden" name="html" rows="5">{{ (old('html') ?? $model->html ?? '') ?: '<p></p>' }}</textarea>
</div>
@if($errors->has('html'))