mirror of
https://github.com/BookStackApp/BookStack.git
synced 2025-01-19 08:42:48 +08:00
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
14
dev/build/svg-blank-transform.js
Normal file
14
dev/build/svg-blank-transform.js
Normal 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';
|
||||
},
|
||||
};
|
|
@ -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
|
||||
|
|
|
@ -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 = () => {
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -13,6 +13,7 @@ import {ListItemNode, ListNode} from '@lexical/list';
|
|||
import {TableCellNode, TableNode, TableRowNode} from '@lexical/table';
|
||||
|
||||
import {
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
createEditor,
|
||||
DecoratorNode,
|
||||
|
@ -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 = {
|
||||
|
@ -420,6 +425,7 @@ const DEFAULT_NODES: NonNullable<ReadonlyArray<Klass<LexicalNode> | LexicalNodeR
|
|||
TableRowNode,
|
||||
AutoLinkNode,
|
||||
LinkNode,
|
||||
DetailsNode,
|
||||
TestElementNode,
|
||||
TestSegmentedNode,
|
||||
TestExcludeFromCopyElementNode,
|
||||
|
@ -451,6 +457,7 @@ export function createTestEditor(
|
|||
...config,
|
||||
nodes: DEFAULT_NODES.concat(customNodes),
|
||||
});
|
||||
|
||||
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 {
|
||||
if (!$isRangeSelection(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 {
|
||||
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 () => {
|
||||
expect(typeof window === 'undefined').toBe(true);
|
||||
expect(typeof document === 'undefined').toBe(true);
|
||||
expect(typeof navigator === 'undefined').toBe(true);
|
||||
});
|
||||
|
||||
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 {
|
||||
appendHtmlToEditor,
|
||||
focusEditor,
|
||||
|
@ -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', '');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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";
|
||||
|
@ -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) {
|
||||
|
|
Loading…
Reference in New Issue
Block a user