Lexical: Added testing for some added shortcuts

Also:
- Added svg loading support (dummy stub) for jest.
- Updated headless test case due to node changes.
- Split out editor change detected to where appropriate.
- Added functions to help with testing, like mocking our context.
This commit is contained in:
Dan Brown 2024-12-16 16:24:47 +00:00
parent 8486775edf
commit e50cd33277
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
10 changed files with 239 additions and 34 deletions

View File

@ -0,0 +1,14 @@
// This is a basic transformer stub to help jest handle SVG files.
// Essentially blanks them since we don't really need to involve them
// in our tests (yet).
module.exports = {
process() {
return {
code: 'module.exports = \'\';',
};
},
getCacheKey() {
// The output is always the same.
return 'svgTransform';
},
};

View File

@ -185,6 +185,7 @@ const config: Config = {
// A map from regular expressions to paths to transformers // A map from regular expressions to paths to transformers
transform: { transform: {
"^.+.tsx?$": ["ts-jest",{}], "^.+.tsx?$": ["ts-jest",{}],
"^.+.svg$": ["<rootDir>/dev/build/svg-blank-transform.js",{}],
}, },
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation

View File

@ -75,38 +75,12 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st
const debugView = document.getElementById('lexical-debug'); const debugView = document.getElementById('lexical-debug');
if (debugView) { if (debugView) {
debugView.hidden = true; debugView.hidden = true;
} editor.registerUpdateListener(({dirtyElements, dirtyLeaves, editorState, prevEditorState}) => {
// Debug logic
let changeFromLoading = true; // console.log('editorState', editorState.toJSON());
editor.registerUpdateListener(({dirtyElements, dirtyLeaves, editorState, prevEditorState}) => {
// Watch for selection changes to update the UI on change
// Used to be done via SELECTION_CHANGE_COMMAND but this would not always emit
// for all selection changes, so this proved more reliable.
const selectionChange = !(prevEditorState._selection?.is(editorState._selection) || false);
if (selectionChange) {
editor.update(() => {
const selection = $getSelection();
context.manager.triggerStateUpdate({
editor, selection,
});
});
}
// Emit change event to component system (for draft detection) on actual user content change
if (dirtyElements.size > 0 || dirtyLeaves.size > 0) {
if (changeFromLoading) {
changeFromLoading = false;
} else {
window.$events.emit('editor-html-change', '');
}
}
// Debug logic
// console.log('editorState', editorState.toJSON());
if (debugView) {
debugView.textContent = JSON.stringify(editorState.toJSON(), null, 2); debugView.textContent = JSON.stringify(editorState.toJSON(), null, 2);
} });
}); }
// @ts-ignore // @ts-ignore
window.debugEditorState = () => { window.debugEditorState = () => {

View File

@ -1188,6 +1188,14 @@ export class LexicalEditor {
updateEditor(this, updateFn, options); updateEditor(this, updateFn, options);
} }
/**
* Helper to run the update and commitUpdates methods in a single call.
*/
updateAndCommit(updateFn: () => void, options?: EditorUpdateOptions): void {
this.update(updateFn, options);
this.commitUpdates();
}
/** /**
* Focuses the editor * Focuses the editor
* @param callbackFn - A function to run after the editor is focused. * @param callbackFn - A function to run after the editor is focused.

View File

@ -13,6 +13,7 @@ import {ListItemNode, ListNode} from '@lexical/list';
import {TableCellNode, TableNode, TableRowNode} from '@lexical/table'; import {TableCellNode, TableNode, TableRowNode} from '@lexical/table';
import { import {
$getSelection,
$isRangeSelection, $isRangeSelection,
createEditor, createEditor,
DecoratorNode, DecoratorNode,
@ -37,6 +38,10 @@ import {
import {resetRandomKey} from '../../LexicalUtils'; import {resetRandomKey} from '../../LexicalUtils';
import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode"; import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode";
import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode"; import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode";
import {DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
import {EditorUiContext} from "../../../../ui/framework/core";
import {EditorUIManager} from "../../../../ui/framework/manager";
import {registerRichText} from "@lexical/rich-text";
type TestEnv = { type TestEnv = {
@ -420,6 +425,7 @@ const DEFAULT_NODES: NonNullable<ReadonlyArray<Klass<LexicalNode> | LexicalNodeR
TableRowNode, TableRowNode,
AutoLinkNode, AutoLinkNode,
LinkNode, LinkNode,
DetailsNode,
TestElementNode, TestElementNode,
TestSegmentedNode, TestSegmentedNode,
TestExcludeFromCopyElementNode, TestExcludeFromCopyElementNode,
@ -451,6 +457,7 @@ export function createTestEditor(
...config, ...config,
nodes: DEFAULT_NODES.concat(customNodes), nodes: DEFAULT_NODES.concat(customNodes),
}); });
return editor; return editor;
} }
@ -465,6 +472,26 @@ export function createTestHeadlessEditor(
}); });
} }
export function createTestContext(env: TestEnv): EditorUiContext {
const context = {
containerDOM: document.createElement('div'),
editor: env.editor,
editorDOM: document.createElement('div'),
error(text: string | Error): void {
},
manager: new EditorUIManager(),
options: {},
scrollDOM: document.createElement('div'),
translate(text: string): string {
return "";
}
};
context.manager.setContext(context);
return context;
}
export function $assertRangeSelection(selection: unknown): RangeSelection { export function $assertRangeSelection(selection: unknown): RangeSelection {
if (!$isRangeSelection(selection)) { if (!$isRangeSelection(selection)) {
throw new Error(`Expected RangeSelection, got ${selection}`); throw new Error(`Expected RangeSelection, got ${selection}`);
@ -717,4 +744,23 @@ export function expectHtmlToBeEqual(expected: string, actual: string): void {
function formatHtml(s: string): string { function formatHtml(s: string): string {
return s.replace(/>\s+</g, '><').replace(/\s*\n\s*/g, ' ').trim(); return s.replace(/>\s+</g, '><').replace(/\s*\n\s*/g, ' ').trim();
}
export function dispatchKeydownEventForNode(node: LexicalNode, editor: LexicalEditor, key: string) {
const nodeDomEl = editor.getElementByKey(node.getKey());
const event = new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key,
});
nodeDomEl?.dispatchEvent(event);
}
export function dispatchKeydownEventForSelectedNode(editor: LexicalEditor, key: string) {
editor.getEditorState().read((): void => {
const node = $getSelection()?.getNodes()[0] || null;
if (node) {
dispatchKeydownEventForNode(node, editor, key);
}
});
} }

View File

@ -62,7 +62,6 @@ describe('LexicalHeadlessEditor', () => {
it('should be headless environment', async () => { it('should be headless environment', async () => {
expect(typeof window === 'undefined').toBe(true); expect(typeof window === 'undefined').toBe(true);
expect(typeof document === 'undefined').toBe(true); expect(typeof document === 'undefined').toBe(true);
expect(typeof navigator === 'undefined').toBe(true);
}); });
it('can update editor', async () => { it('can update editor', async () => {

View File

@ -0,0 +1,40 @@
import {dispatchKeydownEventForNode, initializeUnitTest} from "lexical/__tests__/utils";
import {$createDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
import {$createParagraphNode, $getRoot, LexicalNode, ParagraphNode} from "lexical";
const editorConfig = Object.freeze({
namespace: '',
theme: {
},
});
describe('LexicalDetailsNode tests', () => {
initializeUnitTest((testEnv) => {
test('createDOM()', () => {
const {editor} = testEnv;
let html!: string;
editor.updateAndCommit(() => {
const details = $createDetailsNode();
html = details.createDOM(editorConfig, editor).outerHTML;
});
expect(html).toBe(`<details><summary contenteditable="false"></summary></details>`);
});
test('exportDOM()', () => {
const {editor} = testEnv;
let html!: string;
editor.updateAndCommit(() => {
const details = $createDetailsNode();
html = (details.exportDOM(editor).element as HTMLElement).outerHTML;
});
expect(html).toBe(`<details><summary></summary></details>`);
});
});
})

View File

@ -0,0 +1,95 @@
import {
createTestContext,
dispatchKeydownEventForNode,
dispatchKeydownEventForSelectedNode,
initializeUnitTest
} from "lexical/__tests__/utils";
import {
$createParagraphNode, $createTextNode,
$getRoot, LexicalNode,
ParagraphNode,
} from "lexical";
import {$createDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
import {registerKeyboardHandling} from "../keyboard-handling";
import {registerRichText} from "@lexical/rich-text";
describe('Keyboard-handling service tests', () => {
initializeUnitTest((testEnv) => {
test('Details: down key on last lines creates new sibling node', () => {
const {editor} = testEnv;
registerRichText(editor);
registerKeyboardHandling(createTestContext(testEnv));
let lastRootChild!: LexicalNode|null;
let detailsPara!: ParagraphNode;
editor.updateAndCommit(() => {
const root = $getRoot()
const details = $createDetailsNode();
detailsPara = $createParagraphNode();
details.append(detailsPara);
$getRoot().append(details);
detailsPara.select();
lastRootChild = root.getLastChild();
});
expect(lastRootChild).toBeInstanceOf(DetailsNode);
dispatchKeydownEventForNode(detailsPara, editor, 'ArrowDown');
editor.commitUpdates();
editor.getEditorState().read(() => {
lastRootChild = $getRoot().getLastChild();
});
expect(lastRootChild).toBeInstanceOf(ParagraphNode);
});
test('Details: enter on last empy block creates new sibling node', () => {
const {editor} = testEnv;
registerRichText(editor);
registerKeyboardHandling(createTestContext(testEnv));
let lastRootChild!: LexicalNode|null;
let detailsPara!: ParagraphNode;
editor.updateAndCommit(() => {
const root = $getRoot()
const details = $createDetailsNode();
const text = $createTextNode('Hello!');
detailsPara = $createParagraphNode();
detailsPara.append(text);
details.append(detailsPara);
$getRoot().append(details);
text.selectEnd();
lastRootChild = root.getLastChild();
});
expect(lastRootChild).toBeInstanceOf(DetailsNode);
dispatchKeydownEventForNode(detailsPara, editor, 'Enter');
editor.commitUpdates();
dispatchKeydownEventForSelectedNode(editor, 'Enter');
editor.commitUpdates();
let detailsChildren!: LexicalNode[];
let lastDetailsText!: string;
editor.getEditorState().read(() => {
detailsChildren = (lastRootChild as DetailsNode).getChildren();
lastRootChild = $getRoot().getLastChild();
lastDetailsText = detailsChildren[0].getTextContent();
});
expect(lastRootChild).toBeInstanceOf(ParagraphNode);
expect(detailsChildren).toHaveLength(1);
expect(lastDetailsText).toBe('Hello!');
});
});
});

View File

@ -1,4 +1,4 @@
import {LexicalEditor} from "lexical"; import {$getSelection, LexicalEditor} from "lexical";
import { import {
appendHtmlToEditor, appendHtmlToEditor,
focusEditor, focusEditor,
@ -40,4 +40,16 @@ export function listen(editor: LexicalEditor): void {
window.$events.listen<EditorEventContent>('editor::focus', () => { window.$events.listen<EditorEventContent>('editor::focus', () => {
focusEditor(editor); focusEditor(editor);
}); });
let changeFromLoading = true;
editor.registerUpdateListener(({dirtyElements, dirtyLeaves, editorState, prevEditorState}) => {
// Emit change event to component system (for draft detection) on actual user content change
if (dirtyElements.size > 0 || dirtyLeaves.size > 0) {
if (changeFromLoading) {
changeFromLoading = false;
} else {
window.$events.emit('editor-html-change', '');
}
}
});
} }

View File

@ -1,7 +1,7 @@
import {EditorFormModal, EditorFormModalDefinition} from "./modals"; import {EditorFormModal, EditorFormModalDefinition} from "./modals";
import {EditorContainerUiElement, EditorUiContext, EditorUiElement, EditorUiStateUpdate} from "./core"; import {EditorContainerUiElement, EditorUiContext, EditorUiElement, EditorUiStateUpdate} from "./core";
import {EditorDecorator, EditorDecoratorAdapter} from "./decorator"; import {EditorDecorator, EditorDecoratorAdapter} from "./decorator";
import {BaseSelection, LexicalEditor} from "lexical"; import {$getSelection, BaseSelection, LexicalEditor} from "lexical";
import {DecoratorListener} from "lexical/LexicalEditor"; import {DecoratorListener} from "lexical/LexicalEditor";
import type {NodeKey} from "lexical/LexicalNode"; import type {NodeKey} from "lexical/LexicalNode";
import {EditorContextToolbar, EditorContextToolbarDefinition} from "./toolbars"; import {EditorContextToolbar, EditorContextToolbarDefinition} from "./toolbars";
@ -231,6 +231,22 @@ export class EditorUIManager {
}); });
} }
editor.registerDecoratorListener(domDecorateListener); editor.registerDecoratorListener(domDecorateListener);
// Watch for changes to update local state
editor.registerUpdateListener(({editorState, prevEditorState}) => {
// Watch for selection changes to update the UI on change
// Used to be done via SELECTION_CHANGE_COMMAND but this would not always emit
// for all selection changes, so this proved more reliable.
const selectionChange = !(prevEditorState._selection?.is(editorState._selection) || false);
if (selectionChange) {
editor.update(() => {
const selection = $getSelection();
this.triggerStateUpdate({
editor, selection,
});
});
}
});
} }
protected setupEventListeners(context: EditorUiContext) { protected setupEventListeners(context: EditorUiContext) {