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:
parent
8486775edf
commit
e50cd33277
|
@ -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';
|
||||||
|
},
|
||||||
|
};
|
|
@ -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
|
||||||
|
|
|
@ -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 = () => {
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
|
@ -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 () => {
|
||||||
|
|
|
@ -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>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
|
})
|
|
@ -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!');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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', '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
Loading…
Reference in New Issue