diff --git a/dev/build/svg-blank-transform.js b/dev/build/svg-blank-transform.js new file mode 100644 index 000000000..5183014c8 --- /dev/null +++ b/dev/build/svg-blank-transform.js @@ -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'; + }, +}; diff --git a/jest.config.ts b/jest.config.ts index 3c04f05b2..53bfceb05 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -185,6 +185,7 @@ const config: Config = { // A map from regular expressions to paths to transformers transform: { "^.+.tsx?$": ["ts-jest",{}], + "^.+.svg$": ["/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 diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index 510ab1f92..ffdc7d7e8 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -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 = () => { diff --git a/resources/js/wysiwyg/lexical/core/LexicalEditor.ts b/resources/js/wysiwyg/lexical/core/LexicalEditor.ts index 092429156..364f6c6b7 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalEditor.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalEditor.ts @@ -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. diff --git a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts index e9d14ef11..2fc57315b 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts @@ -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 | 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+<').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); + } + }); } \ No newline at end of file diff --git a/resources/js/wysiwyg/lexical/headless/__tests__/unit/LexicalHeadlessEditor.test.ts b/resources/js/wysiwyg/lexical/headless/__tests__/unit/LexicalHeadlessEditor.test.ts index 122516d45..c03f1bdb2 100644 --- a/resources/js/wysiwyg/lexical/headless/__tests__/unit/LexicalHeadlessEditor.test.ts +++ b/resources/js/wysiwyg/lexical/headless/__tests__/unit/LexicalHeadlessEditor.test.ts @@ -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 () => { diff --git a/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalDetailsNode.test.ts b/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalDetailsNode.test.ts new file mode 100644 index 000000000..faa31d887 --- /dev/null +++ b/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalDetailsNode.test.ts @@ -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(`
`); + }); + + test('exportDOM()', () => { + const {editor} = testEnv; + let html!: string; + + editor.updateAndCommit(() => { + const details = $createDetailsNode(); + html = (details.exportDOM(editor).element as HTMLElement).outerHTML; + }); + + expect(html).toBe(`
`); + }); + + + }); +}) \ No newline at end of file diff --git a/resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts b/resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts new file mode 100644 index 000000000..14a1ea973 --- /dev/null +++ b/resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts @@ -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!'); + }); + }); +}); \ No newline at end of file diff --git a/resources/js/wysiwyg/services/common-events.ts b/resources/js/wysiwyg/services/common-events.ts index 16522d66b..2ffa722e4 100644 --- a/resources/js/wysiwyg/services/common-events.ts +++ b/resources/js/wysiwyg/services/common-events.ts @@ -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('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', ''); + } + } + }); } diff --git a/resources/js/wysiwyg/ui/framework/manager.ts b/resources/js/wysiwyg/ui/framework/manager.ts index 185cd5dcc..0f501d9fa 100644 --- a/resources/js/wysiwyg/ui/framework/manager.ts +++ b/resources/js/wysiwyg/ui/framework/manager.ts @@ -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) {