Skip to content

Commit e50cd33

Browse files
committed
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.
1 parent 8486775 commit e50cd33

File tree

10 files changed

+239
-34
lines changed

10 files changed

+239
-34
lines changed

dev/build/svg-blank-transform.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// This is a basic transformer stub to help jest handle SVG files.
2+
// Essentially blanks them since we don't really need to involve them
3+
// in our tests (yet).
4+
module.exports = {
5+
process() {
6+
return {
7+
code: 'module.exports = \'\';',
8+
};
9+
},
10+
getCacheKey() {
11+
// The output is always the same.
12+
return 'svgTransform';
13+
},
14+
};

jest.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ const config: Config = {
185185
// A map from regular expressions to paths to transformers
186186
transform: {
187187
"^.+.tsx?$": ["ts-jest",{}],
188+
"^.+.svg$": ["<rootDir>/dev/build/svg-blank-transform.js",{}],
188189
},
189190

190191
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation

resources/js/wysiwyg/index.ts

Lines changed: 5 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -75,38 +75,12 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st
7575
const debugView = document.getElementById('lexical-debug');
7676
if (debugView) {
7777
debugView.hidden = true;
78-
}
79-
80-
let changeFromLoading = true;
81-
editor.registerUpdateListener(({dirtyElements, dirtyLeaves, editorState, prevEditorState}) => {
82-
// Watch for selection changes to update the UI on change
83-
// Used to be done via SELECTION_CHANGE_COMMAND but this would not always emit
84-
// for all selection changes, so this proved more reliable.
85-
const selectionChange = !(prevEditorState._selection?.is(editorState._selection) || false);
86-
if (selectionChange) {
87-
editor.update(() => {
88-
const selection = $getSelection();
89-
context.manager.triggerStateUpdate({
90-
editor, selection,
91-
});
92-
});
93-
}
94-
95-
// Emit change event to component system (for draft detection) on actual user content change
96-
if (dirtyElements.size > 0 || dirtyLeaves.size > 0) {
97-
if (changeFromLoading) {
98-
changeFromLoading = false;
99-
} else {
100-
window.$events.emit('editor-html-change', '');
101-
}
102-
}
103-
104-
// Debug logic
105-
// console.log('editorState', editorState.toJSON());
106-
if (debugView) {
78+
editor.registerUpdateListener(({dirtyElements, dirtyLeaves, editorState, prevEditorState}) => {
79+
// Debug logic
80+
// console.log('editorState', editorState.toJSON());
10781
debugView.textContent = JSON.stringify(editorState.toJSON(), null, 2);
108-
}
109-
});
82+
});
83+
}
11084

11185
// @ts-ignore
11286
window.debugEditorState = () => {

resources/js/wysiwyg/lexical/core/LexicalEditor.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1188,6 +1188,14 @@ export class LexicalEditor {
11881188
updateEditor(this, updateFn, options);
11891189
}
11901190

1191+
/**
1192+
* Helper to run the update and commitUpdates methods in a single call.
1193+
*/
1194+
updateAndCommit(updateFn: () => void, options?: EditorUpdateOptions): void {
1195+
this.update(updateFn, options);
1196+
this.commitUpdates();
1197+
}
1198+
11911199
/**
11921200
* Focuses the editor
11931201
* @param callbackFn - A function to run after the editor is focused.

resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {ListItemNode, ListNode} from '@lexical/list';
1313
import {TableCellNode, TableNode, TableRowNode} from '@lexical/table';
1414

1515
import {
16+
$getSelection,
1617
$isRangeSelection,
1718
createEditor,
1819
DecoratorNode,
@@ -37,6 +38,10 @@ import {
3738
import {resetRandomKey} from '../../LexicalUtils';
3839
import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode";
3940
import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode";
41+
import {DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
42+
import {EditorUiContext} from "../../../../ui/framework/core";
43+
import {EditorUIManager} from "../../../../ui/framework/manager";
44+
import {registerRichText} from "@lexical/rich-text";
4045

4146

4247
type TestEnv = {
@@ -420,6 +425,7 @@ const DEFAULT_NODES: NonNullable<ReadonlyArray<Klass<LexicalNode> | LexicalNodeR
420425
TableRowNode,
421426
AutoLinkNode,
422427
LinkNode,
428+
DetailsNode,
423429
TestElementNode,
424430
TestSegmentedNode,
425431
TestExcludeFromCopyElementNode,
@@ -451,6 +457,7 @@ export function createTestEditor(
451457
...config,
452458
nodes: DEFAULT_NODES.concat(customNodes),
453459
});
460+
454461
return editor;
455462
}
456463

@@ -465,6 +472,26 @@ export function createTestHeadlessEditor(
465472
});
466473
}
467474

475+
export function createTestContext(env: TestEnv): EditorUiContext {
476+
const context = {
477+
containerDOM: document.createElement('div'),
478+
editor: env.editor,
479+
editorDOM: document.createElement('div'),
480+
error(text: string | Error): void {
481+
},
482+
manager: new EditorUIManager(),
483+
options: {},
484+
scrollDOM: document.createElement('div'),
485+
translate(text: string): string {
486+
return "";
487+
}
488+
};
489+
490+
context.manager.setContext(context);
491+
492+
return context;
493+
}
494+
468495
export function $assertRangeSelection(selection: unknown): RangeSelection {
469496
if (!$isRangeSelection(selection)) {
470497
throw new Error(`Expected RangeSelection, got ${selection}`);
@@ -717,4 +744,23 @@ export function expectHtmlToBeEqual(expected: string, actual: string): void {
717744

718745
function formatHtml(s: string): string {
719746
return s.replace(/>\s+</g, '><').replace(/\s*\n\s*/g, ' ').trim();
747+
}
748+
749+
export function dispatchKeydownEventForNode(node: LexicalNode, editor: LexicalEditor, key: string) {
750+
const nodeDomEl = editor.getElementByKey(node.getKey());
751+
const event = new KeyboardEvent('keydown', {
752+
bubbles: true,
753+
cancelable: true,
754+
key,
755+
});
756+
nodeDomEl?.dispatchEvent(event);
757+
}
758+
759+
export function dispatchKeydownEventForSelectedNode(editor: LexicalEditor, key: string) {
760+
editor.getEditorState().read((): void => {
761+
const node = $getSelection()?.getNodes()[0] || null;
762+
if (node) {
763+
dispatchKeydownEventForNode(node, editor, key);
764+
}
765+
});
720766
}

resources/js/wysiwyg/lexical/headless/__tests__/unit/LexicalHeadlessEditor.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,6 @@ describe('LexicalHeadlessEditor', () => {
6262
it('should be headless environment', async () => {
6363
expect(typeof window === 'undefined').toBe(true);
6464
expect(typeof document === 'undefined').toBe(true);
65-
expect(typeof navigator === 'undefined').toBe(true);
6665
});
6766

6867
it('can update editor', async () => {
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import {dispatchKeydownEventForNode, initializeUnitTest} from "lexical/__tests__/utils";
2+
import {$createDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
3+
import {$createParagraphNode, $getRoot, LexicalNode, ParagraphNode} from "lexical";
4+
5+
const editorConfig = Object.freeze({
6+
namespace: '',
7+
theme: {
8+
},
9+
});
10+
11+
describe('LexicalDetailsNode tests', () => {
12+
initializeUnitTest((testEnv) => {
13+
14+
test('createDOM()', () => {
15+
const {editor} = testEnv;
16+
let html!: string;
17+
18+
editor.updateAndCommit(() => {
19+
const details = $createDetailsNode();
20+
html = details.createDOM(editorConfig, editor).outerHTML;
21+
});
22+
23+
expect(html).toBe(`<details><summary contenteditable="false"></summary></details>`);
24+
});
25+
26+
test('exportDOM()', () => {
27+
const {editor} = testEnv;
28+
let html!: string;
29+
30+
editor.updateAndCommit(() => {
31+
const details = $createDetailsNode();
32+
html = (details.exportDOM(editor).element as HTMLElement).outerHTML;
33+
});
34+
35+
expect(html).toBe(`<details><summary></summary></details>`);
36+
});
37+
38+
39+
});
40+
})
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import {
2+
createTestContext,
3+
dispatchKeydownEventForNode,
4+
dispatchKeydownEventForSelectedNode,
5+
initializeUnitTest
6+
} from "lexical/__tests__/utils";
7+
import {
8+
$createParagraphNode, $createTextNode,
9+
$getRoot, LexicalNode,
10+
ParagraphNode,
11+
} from "lexical";
12+
import {$createDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
13+
import {registerKeyboardHandling} from "../keyboard-handling";
14+
import {registerRichText} from "@lexical/rich-text";
15+
16+
describe('Keyboard-handling service tests', () => {
17+
initializeUnitTest((testEnv) => {
18+
19+
test('Details: down key on last lines creates new sibling node', () => {
20+
const {editor} = testEnv;
21+
22+
registerRichText(editor);
23+
registerKeyboardHandling(createTestContext(testEnv));
24+
25+
let lastRootChild!: LexicalNode|null;
26+
let detailsPara!: ParagraphNode;
27+
28+
editor.updateAndCommit(() => {
29+
const root = $getRoot()
30+
const details = $createDetailsNode();
31+
detailsPara = $createParagraphNode();
32+
details.append(detailsPara);
33+
$getRoot().append(details);
34+
detailsPara.select();
35+
36+
lastRootChild = root.getLastChild();
37+
});
38+
39+
expect(lastRootChild).toBeInstanceOf(DetailsNode);
40+
41+
dispatchKeydownEventForNode(detailsPara, editor, 'ArrowDown');
42+
editor.commitUpdates();
43+
44+
editor.getEditorState().read(() => {
45+
lastRootChild = $getRoot().getLastChild();
46+
});
47+
48+
expect(lastRootChild).toBeInstanceOf(ParagraphNode);
49+
});
50+
51+
test('Details: enter on last empy block creates new sibling node', () => {
52+
const {editor} = testEnv;
53+
54+
registerRichText(editor);
55+
registerKeyboardHandling(createTestContext(testEnv));
56+
57+
let lastRootChild!: LexicalNode|null;
58+
let detailsPara!: ParagraphNode;
59+
60+
editor.updateAndCommit(() => {
61+
const root = $getRoot()
62+
const details = $createDetailsNode();
63+
const text = $createTextNode('Hello!');
64+
detailsPara = $createParagraphNode();
65+
detailsPara.append(text);
66+
details.append(detailsPara);
67+
$getRoot().append(details);
68+
text.selectEnd();
69+
70+
lastRootChild = root.getLastChild();
71+
});
72+
73+
expect(lastRootChild).toBeInstanceOf(DetailsNode);
74+
75+
dispatchKeydownEventForNode(detailsPara, editor, 'Enter');
76+
editor.commitUpdates();
77+
78+
dispatchKeydownEventForSelectedNode(editor, 'Enter');
79+
editor.commitUpdates();
80+
81+
let detailsChildren!: LexicalNode[];
82+
let lastDetailsText!: string;
83+
84+
editor.getEditorState().read(() => {
85+
detailsChildren = (lastRootChild as DetailsNode).getChildren();
86+
lastRootChild = $getRoot().getLastChild();
87+
lastDetailsText = detailsChildren[0].getTextContent();
88+
});
89+
90+
expect(lastRootChild).toBeInstanceOf(ParagraphNode);
91+
expect(detailsChildren).toHaveLength(1);
92+
expect(lastDetailsText).toBe('Hello!');
93+
});
94+
});
95+
});

resources/js/wysiwyg/services/common-events.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {LexicalEditor} from "lexical";
1+
import {$getSelection, LexicalEditor} from "lexical";
22
import {
33
appendHtmlToEditor,
44
focusEditor,
@@ -40,4 +40,16 @@ export function listen(editor: LexicalEditor): void {
4040
window.$events.listen<EditorEventContent>('editor::focus', () => {
4141
focusEditor(editor);
4242
});
43+
44+
let changeFromLoading = true;
45+
editor.registerUpdateListener(({dirtyElements, dirtyLeaves, editorState, prevEditorState}) => {
46+
// Emit change event to component system (for draft detection) on actual user content change
47+
if (dirtyElements.size > 0 || dirtyLeaves.size > 0) {
48+
if (changeFromLoading) {
49+
changeFromLoading = false;
50+
} else {
51+
window.$events.emit('editor-html-change', '');
52+
}
53+
}
54+
});
4355
}

resources/js/wysiwyg/ui/framework/manager.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {EditorFormModal, EditorFormModalDefinition} from "./modals";
22
import {EditorContainerUiElement, EditorUiContext, EditorUiElement, EditorUiStateUpdate} from "./core";
33
import {EditorDecorator, EditorDecoratorAdapter} from "./decorator";
4-
import {BaseSelection, LexicalEditor} from "lexical";
4+
import {$getSelection, BaseSelection, LexicalEditor} from "lexical";
55
import {DecoratorListener} from "lexical/LexicalEditor";
66
import type {NodeKey} from "lexical/LexicalNode";
77
import {EditorContextToolbar, EditorContextToolbarDefinition} from "./toolbars";
@@ -231,6 +231,22 @@ export class EditorUIManager {
231231
});
232232
}
233233
editor.registerDecoratorListener(domDecorateListener);
234+
235+
// Watch for changes to update local state
236+
editor.registerUpdateListener(({editorState, prevEditorState}) => {
237+
// Watch for selection changes to update the UI on change
238+
// Used to be done via SELECTION_CHANGE_COMMAND but this would not always emit
239+
// for all selection changes, so this proved more reliable.
240+
const selectionChange = !(prevEditorState._selection?.is(editorState._selection) || false);
241+
if (selectionChange) {
242+
editor.update(() => {
243+
const selection = $getSelection();
244+
this.triggerStateUpdate({
245+
editor, selection,
246+
});
247+
});
248+
}
249+
});
234250
}
235251

236252
protected setupEventListeners(context: EditorUiContext) {

0 commit comments

Comments
 (0)