Skip to content

Commit

Permalink
Lexical: Added testing for some added shortcuts
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
ssddanbrown committed Dec 16, 2024
1 parent 8486775 commit e50cd33
Show file tree
Hide file tree
Showing 10 changed files with 239 additions and 34 deletions.
14 changes: 14 additions & 0 deletions dev/build/svg-blank-transform.js
Original file line number Diff line number Diff line change
@@ -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';
},
};
1 change: 1 addition & 0 deletions jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ const config: Config = {
// A map from regular expressions to paths to transformers
transform: {
"^.+.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
Expand Down
36 changes: 5 additions & 31 deletions resources/js/wysiwyg/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,38 +75,12 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st
const debugView = document.getElementById('lexical-debug');
if (debugView) {
debugView.hidden = true;
}

let changeFromLoading = true;
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) {
editor.registerUpdateListener(({dirtyElements, dirtyLeaves, editorState, prevEditorState}) => {
// Debug logic
// console.log('editorState', editorState.toJSON());
debugView.textContent = JSON.stringify(editorState.toJSON(), null, 2);
}
});
});
}

// @ts-ignore
window.debugEditorState = () => {
Expand Down
8 changes: 8 additions & 0 deletions resources/js/wysiwyg/lexical/core/LexicalEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1188,6 +1188,14 @@ export class LexicalEditor {
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
* @param callbackFn - A function to run after the editor is focused.
Expand Down
46 changes: 46 additions & 0 deletions resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {ListItemNode, ListNode} from '@lexical/list';
import {TableCellNode, TableNode, TableRowNode} from '@lexical/table';

import {
$getSelection,
$isRangeSelection,
createEditor,
DecoratorNode,
Expand All @@ -37,6 +38,10 @@ import {
import {resetRandomKey} from '../../LexicalUtils';
import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode";
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 = {
Expand Down Expand Up @@ -420,6 +425,7 @@ const DEFAULT_NODES: NonNullable<ReadonlyArray<Klass<LexicalNode> | LexicalNodeR
TableRowNode,
AutoLinkNode,
LinkNode,
DetailsNode,
TestElementNode,
TestSegmentedNode,
TestExcludeFromCopyElementNode,
Expand Down Expand Up @@ -451,6 +457,7 @@ export function createTestEditor(
...config,
nodes: DEFAULT_NODES.concat(customNodes),
});

return editor;
}

Expand All @@ -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 {
if (!$isRangeSelection(selection)) {
throw new Error(`Expected RangeSelection, got ${selection}`);
Expand Down Expand Up @@ -717,4 +744,23 @@ export function expectHtmlToBeEqual(expected: string, actual: string): void {

function formatHtml(s: string): string {
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);
}
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@ describe('LexicalHeadlessEditor', () => {
it('should be headless environment', async () => {
expect(typeof window === 'undefined').toBe(true);
expect(typeof document === 'undefined').toBe(true);
expect(typeof navigator === 'undefined').toBe(true);
});

it('can update editor', async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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>`);
});


});
})
95 changes: 95 additions & 0 deletions resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts
Original file line number Diff line number Diff line change
@@ -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!');
});
});
});
14 changes: 13 additions & 1 deletion resources/js/wysiwyg/services/common-events.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {LexicalEditor} from "lexical";
import {$getSelection, LexicalEditor} from "lexical";
import {
appendHtmlToEditor,
focusEditor,
Expand Down Expand Up @@ -40,4 +40,16 @@ export function listen(editor: LexicalEditor): void {
window.$events.listen<EditorEventContent>('editor::focus', () => {
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', '');
}
}
});
}
18 changes: 17 additions & 1 deletion resources/js/wysiwyg/ui/framework/manager.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {EditorFormModal, EditorFormModalDefinition} from "./modals";
import {EditorContainerUiElement, EditorUiContext, EditorUiElement, EditorUiStateUpdate} from "./core";
import {EditorDecorator, EditorDecoratorAdapter} from "./decorator";
import {BaseSelection, LexicalEditor} from "lexical";
import {$getSelection, BaseSelection, LexicalEditor} from "lexical";
import {DecoratorListener} from "lexical/LexicalEditor";
import type {NodeKey} from "lexical/LexicalNode";
import {EditorContextToolbar, EditorContextToolbarDefinition} from "./toolbars";
Expand Down Expand Up @@ -231,6 +231,22 @@ export class EditorUIManager {
});
}
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) {
Expand Down

0 comments on commit e50cd33

Please sign in to comment.