From 5c6671b3bf5b582c2ed0e120bb8afb5f296a63e9 Mon Sep 17 00:00:00 2001 From: Dan Brown <ssddanbrown@googlemail.com> Date: Thu, 27 Mar 2025 14:13:18 +0000 Subject: [PATCH 1/5] Lexical: Fixed issues with content not saving Found that saving via Ctrl+Enter did not save as logic to load editor output into form was bypassed, which this fixes by ensuring submit events are raised during for this shortcut. Submit handling also gets a timeout added since, at least in FF, requestSubmit did not re-submit a form while in a submit event. --- resources/js/components/page-editor.js | 2 +- resources/js/components/wysiwyg-editor.js | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/resources/js/components/page-editor.js b/resources/js/components/page-editor.js index 64cd601a9..81378e944 100644 --- a/resources/js/components/page-editor.js +++ b/resources/js/components/page-editor.js @@ -112,7 +112,7 @@ export class PageEditor extends Component { } savePage() { - this.container.closest('form').submit(); + this.container.closest('form').requestSubmit(); } async saveDraft() { diff --git a/resources/js/components/wysiwyg-editor.js b/resources/js/components/wysiwyg-editor.js index 56dbe8d7c..5a2581900 100644 --- a/resources/js/components/wysiwyg-editor.js +++ b/resources/js/components/wysiwyg-editor.js @@ -25,6 +25,7 @@ export class WysiwygEditor extends Component { textDirection: this.$opts.textDirection, translations, }); + window.wysiwyg = this.editor; }); let handlingFormSubmit = false; @@ -38,7 +39,9 @@ export class WysiwygEditor extends Component { handlingFormSubmit = true; this.editor.getContentAsHtml().then(html => { this.input.value = html; - this.input.form.submit(); + setTimeout(() => { + this.input.form.requestSubmit(); + }, 5); }); } else { handlingFormSubmit = false; From c03e44124a3312d6832f71103bda76ab66646a54 Mon Sep 17 00:00:00 2001 From: Dan Brown <ssddanbrown@googlemail.com> Date: Thu, 27 Mar 2025 14:56:32 +0000 Subject: [PATCH 2/5] Lexical: Fixed task list parsing Updated list DOM parsing to properly consider task list format set by other MD/WYSIWYG editors. --- .../js/wysiwyg/lexical/list/LexicalListNode.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/resources/js/wysiwyg/lexical/list/LexicalListNode.ts b/resources/js/wysiwyg/lexical/list/LexicalListNode.ts index 6edf0d64a..b5c83addd 100644 --- a/resources/js/wysiwyg/lexical/list/LexicalListNode.ts +++ b/resources/js/wysiwyg/lexical/list/LexicalListNode.ts @@ -332,7 +332,19 @@ function isDomChecklist(domNode: HTMLElement) { } // if children are checklist items, the node is a checklist ul. Applicable for googledoc checklist pasting. for (const child of domNode.childNodes) { - if (isHTMLElement(child) && child.hasAttribute('aria-checked')) { + if (!isHTMLElement(child)) { + continue; + } + + if (child.hasAttribute('aria-checked')) { + return true; + } + + if (child.classList.contains('task-list-item')) { + return true; + } + + if (child.firstElementChild && child.firstElementChild.matches('input[type="checkbox"]')) { return true; } } From 62c8eb335785e41c730569ec34df7137f2f325f1 Mon Sep 17 00:00:00 2001 From: Dan Brown <ssddanbrown@googlemail.com> Date: Thu, 27 Mar 2025 17:49:48 +0000 Subject: [PATCH 3/5] Lexical: Made list selections & intendting more reliable - Added handling to not include parent of top-most list range selection so that it's not also changed while not visually part of the selection range. - Fixed issue where list items could be left over after unnesting, due to empty checks/removals occuring before all child handling. - Added node sorting, applied to list items during nest operations so that selection range remains reliable. --- resources/js/wysiwyg/utils/lists.ts | 47 +++++++++++++++++++++++------ resources/js/wysiwyg/utils/nodes.ts | 24 +++++++++++++++ 2 files changed, 61 insertions(+), 10 deletions(-) diff --git a/resources/js/wysiwyg/utils/lists.ts b/resources/js/wysiwyg/utils/lists.ts index 005b05f98..3deb9dfb6 100644 --- a/resources/js/wysiwyg/utils/lists.ts +++ b/resources/js/wysiwyg/utils/lists.ts @@ -1,6 +1,6 @@ import {$createTextNode, $getSelection, BaseSelection, LexicalEditor, TextNode} from "lexical"; import {$getBlockElementNodesInSelection, $selectNodes, $toggleSelection} from "./selection"; -import {nodeHasInset} from "./nodes"; +import {$sortNodes, nodeHasInset} from "./nodes"; import {$createListItemNode, $createListNode, $isListItemNode, $isListNode, ListItemNode} from "@lexical/list"; @@ -49,16 +49,11 @@ export function $unnestListItem(node: ListItemNode): ListItemNode { } const laterSiblings = node.getNextSiblings(); - parentListItem.insertAfter(node); if (list.getChildren().length === 0) { list.remove(); } - if (parentListItem.getChildren().length === 0) { - parentListItem.remove(); - } - if (laterSiblings.length > 0) { const childList = $createListNode(list.getListType()); childList.append(...laterSiblings); @@ -69,23 +64,54 @@ export function $unnestListItem(node: ListItemNode): ListItemNode { list.remove(); } + if (parentListItem.getChildren().length === 0) { + parentListItem.remove(); + } + return node; } function getListItemsForSelection(selection: BaseSelection|null): (ListItemNode|null)[] { const nodes = selection?.getNodes() || []; - const listItemNodes = []; + let [start, end] = selection?.getStartEndPoints() || [null, null]; + // Ensure we ignore parent list items of the top-most list item since, + // although technically part of the selection, from a user point of + // view the selection does not spread to encompass this outer element. + const itemsToIgnore: Set<string> = new Set(); + if (selection && start) { + if (selection.isBackward() && end) { + [end, start] = [start, end]; + } + + const startParents = start.getNode().getParents(); + let foundList = false; + for (const parent of startParents) { + if ($isListItemNode(parent)) { + if (foundList) { + itemsToIgnore.add(parent.getKey()); + } else { + foundList = true; + } + } + } + } + + const listItemNodes = []; outer: for (const node of nodes) { if ($isListItemNode(node)) { - listItemNodes.push(node); + if (!itemsToIgnore.has(node.getKey())) { + listItemNodes.push(node); + } continue; } const parents = node.getParents(); for (const parent of parents) { if ($isListItemNode(parent)) { - listItemNodes.push(parent); + if (!itemsToIgnore.has(parent.getKey())) { + listItemNodes.push(parent); + } continue outer; } } @@ -110,7 +136,8 @@ function $reduceDedupeListItems(listItems: (ListItemNode|null)[]): ListItemNode[ } } - return Object.values(listItemMap); + const items = Object.values(listItemMap); + return $sortNodes(items) as ListItemNode[]; } export function $setInsetForSelection(editor: LexicalEditor, change: number): void { diff --git a/resources/js/wysiwyg/utils/nodes.ts b/resources/js/wysiwyg/utils/nodes.ts index b5cc78955..591232ea3 100644 --- a/resources/js/wysiwyg/utils/nodes.ts +++ b/resources/js/wysiwyg/utils/nodes.ts @@ -94,6 +94,30 @@ export function $getNearestNodeBlockParent(node: LexicalNode): LexicalNode|null return $findMatchingParent(node, isBlockNode); } +export function $sortNodes(nodes: LexicalNode[]): LexicalNode[] { + const idChain: string[] = []; + const addIds = (n: ElementNode) => { + for (const child of n.getChildren()) { + idChain.push(child.getKey()) + if ($isElementNode(child)) { + addIds(child) + } + } + }; + + const root = $getRoot(); + addIds(root); + + const sorted = Array.from(nodes); + sorted.sort((a, b) => { + const aIndex = idChain.indexOf(a.getKey()); + const bIndex = idChain.indexOf(b.getKey()); + return aIndex - bIndex; + }); + + return sorted; +} + export function nodeHasAlignment(node: object): node is NodeHasAlignment { return '__alignment' in node; } From 9bfcadd95f4c3917ff4aa4fd4b10bfa7ce4c6c09 Mon Sep 17 00:00:00 2001 From: Dan Brown <ssddanbrown@googlemail.com> Date: Fri, 28 Mar 2025 14:17:52 +0000 Subject: [PATCH 4/5] Lexical: Improved navigation around images/media - Added specific handling to move/insert-up/down on arrow press. - Prevented resize overlay from interrupting image node focus. --- .../lexical/rich-text/LexicalImageNode.ts | 2 +- .../js/wysiwyg/services/keyboard-handling.ts | 43 +++++++++++++++++-- resources/sass/_editor.scss | 2 + 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/resources/js/wysiwyg/lexical/rich-text/LexicalImageNode.ts b/resources/js/wysiwyg/lexical/rich-text/LexicalImageNode.ts index 9f42ad732..40f4ab711 100644 --- a/resources/js/wysiwyg/lexical/rich-text/LexicalImageNode.ts +++ b/resources/js/wysiwyg/lexical/rich-text/LexicalImageNode.ts @@ -133,7 +133,7 @@ export class ImageNode extends ElementNode { element.addEventListener('click', e => { _editor.update(() => { - $selectSingleNode(this); + this.select(); }); }); diff --git a/resources/js/wysiwyg/services/keyboard-handling.ts b/resources/js/wysiwyg/services/keyboard-handling.ts index ff6117b2b..0ef0b81bf 100644 --- a/resources/js/wysiwyg/services/keyboard-handling.ts +++ b/resources/js/wysiwyg/services/keyboard-handling.ts @@ -3,7 +3,7 @@ import { $createParagraphNode, $getSelection, $isDecoratorNode, - COMMAND_PRIORITY_LOW, KEY_ARROW_DOWN_COMMAND, + COMMAND_PRIORITY_LOW, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_BACKSPACE_COMMAND, KEY_DELETE_COMMAND, KEY_ENTER_COMMAND, KEY_TAB_COMMAND, @@ -43,7 +43,7 @@ function deleteSingleSelectedNode(editor: LexicalEditor) { } /** - * Insert a new empty node after the selection if the selection contains a single + * Insert a new empty node before/after the selection if the selection contains a single * selected node (like image, media etc...). */ function insertAfterSingleSelectedNode(editor: LexicalEditor, event: KeyboardEvent|null): boolean { @@ -67,6 +67,37 @@ function insertAfterSingleSelectedNode(editor: LexicalEditor, event: KeyboardEve return false; } +function focusAdjacentOrInsertForSingleSelectNode(editor: LexicalEditor, event: KeyboardEvent|null, after: boolean = true): boolean { + const selectionNodes = getLastSelection(editor)?.getNodes() || []; + if (!isSingleSelectedNode(selectionNodes)) { + return false; + } + + event?.preventDefault(); + + const node = selectionNodes[0]; + const nearestBlock = $getNearestNodeBlockParent(node) || node; + let target = after ? nearestBlock.getNextSibling() : nearestBlock.getPreviousSibling(); + + requestAnimationFrame(() => { + editor.update(() => { + if (!target) { + target = $createParagraphNode(); + if (after) { + nearestBlock.insertAfter(target) + } else { + nearestBlock.insertBefore(target); + } + } + + target.selectStart(); + }); + }); + + + return true; +} + /** * Insert a new node after a details node, if inside a details node that's * the last element, and if the cursor is at the last block within the details node. @@ -199,8 +230,13 @@ export function registerKeyboardHandling(context: EditorUiContext): () => void { return handleInsetOnTab(context.editor, event); }, COMMAND_PRIORITY_LOW); + const unregisterUp = context.editor.registerCommand(KEY_ARROW_UP_COMMAND, (event): boolean => { + return focusAdjacentOrInsertForSingleSelectNode(context.editor, event, false); + }, COMMAND_PRIORITY_LOW); + const unregisterDown = context.editor.registerCommand(KEY_ARROW_DOWN_COMMAND, (event): boolean => { - return insertAfterDetails(context.editor, event); + return insertAfterDetails(context.editor, event) + || focusAdjacentOrInsertForSingleSelectNode(context.editor, event, true) }, COMMAND_PRIORITY_LOW); return () => { @@ -208,6 +244,7 @@ export function registerKeyboardHandling(context: EditorUiContext): () => void { unregisterDelete(); unregisterEnter(); unregisterTab(); + unregisterUp(); unregisterDown(); }; } \ No newline at end of file diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index 9f7694e85..35f11c5a2 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -370,8 +370,10 @@ body.editor-is-fullscreen { display: inline-block; outline: 2px dashed var(--editor-color-primary); direction: ltr; + pointer-events: none; } .editor-node-resizer-handle { + pointer-events: auto; position: absolute; display: block; width: 10px; From bb44334224d6eee53b229e1f59d63b4f1ef45e68 Mon Sep 17 00:00:00 2001 From: Dan Brown <ssddanbrown@googlemail.com> Date: Fri, 28 Mar 2025 18:29:00 +0000 Subject: [PATCH 5/5] Lexical: Added tests to cover recent changes Also updated list tests to new test process. --- .../lexical/core/__tests__/utils/index.ts | 4 + .../__tests__/unit/LexicalListNode.test.ts | 378 +++++++++--------- .../__tests__/keyboard-handling.test.ts | 33 +- .../js/wysiwyg/services/keyboard-handling.ts | 21 +- 4 files changed, 240 insertions(+), 196 deletions(-) diff --git a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts index d54a64ce8..6a8e45724 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts @@ -37,6 +37,7 @@ 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 {ImageNode} from "@lexical/rich-text/LexicalImageNode"; type TestEnv = { readonly container: HTMLDivElement; @@ -484,6 +485,9 @@ export function createTestContext(): EditorUiContext { const editor = createTestEditor({ namespace: 'testing', theme: {}, + nodes: [ + ImageNode, + ] }); editor.setRootElement(editorDOM); diff --git a/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListNode.test.ts b/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListNode.test.ts index 8c7729dbf..b85383e7d 100644 --- a/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListNode.test.ts +++ b/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListNode.test.ts @@ -6,7 +6,7 @@ * */ import {ParagraphNode, TextNode} from 'lexical'; -import {initializeUnitTest} from 'lexical/__tests__/utils'; +import {createTestContext} from 'lexical/__tests__/utils'; import { $createListItemNode, @@ -16,6 +16,7 @@ import { ListItemNode, ListNode, } from '../..'; +import {$htmlToBlockNodes} from "../../../../utils/nodes"; const editorConfig = Object.freeze({ namespace: '', @@ -46,123 +47,122 @@ const editorConfig = Object.freeze({ }); describe('LexicalListNode tests', () => { - initializeUnitTest((testEnv) => { - test('ListNode.constructor', async () => { - const {editor} = testEnv; + test('ListNode.constructor', async () => { + const {editor} = createTestContext(); - await editor.update(() => { - const listNode = $createListNode('bullet', 1); - expect(listNode.getType()).toBe('list'); - expect(listNode.getTag()).toBe('ul'); - expect(listNode.getTextContent()).toBe(''); - }); - - // @ts-expect-error - expect(() => $createListNode()).toThrow(); + await editor.update(() => { + const listNode = $createListNode('bullet', 1); + expect(listNode.getType()).toBe('list'); + expect(listNode.getTag()).toBe('ul'); + expect(listNode.getTextContent()).toBe(''); }); - test('ListNode.getTag()', async () => { - const {editor} = testEnv; + // @ts-expect-error + expect(() => $createListNode()).toThrow(); + }); - await editor.update(() => { - const ulListNode = $createListNode('bullet', 1); - expect(ulListNode.getTag()).toBe('ul'); - const olListNode = $createListNode('number', 1); - expect(olListNode.getTag()).toBe('ol'); - const checkListNode = $createListNode('check', 1); - expect(checkListNode.getTag()).toBe('ul'); - }); + test('ListNode.getTag()', async () => { + const {editor} = createTestContext(); + + await editor.update(() => { + const ulListNode = $createListNode('bullet', 1); + expect(ulListNode.getTag()).toBe('ul'); + const olListNode = $createListNode('number', 1); + expect(olListNode.getTag()).toBe('ol'); + const checkListNode = $createListNode('check', 1); + expect(checkListNode.getTag()).toBe('ul'); }); + }); - test('ListNode.createDOM()', async () => { - const {editor} = testEnv; + test('ListNode.createDOM()', async () => { + const {editor} = createTestContext(); - await editor.update(() => { - const listNode = $createListNode('bullet', 1); - expect(listNode.createDOM(editorConfig).outerHTML).toBe( + await editor.update(() => { + const listNode = $createListNode('bullet', 1); + expect(listNode.createDOM(editorConfig).outerHTML).toBe( '<ul class="my-ul-list-class my-ul-list-class-1"></ul>', - ); - expect( + ); + expect( listNode.createDOM({ namespace: '', theme: { list: {}, }, }).outerHTML, - ).toBe('<ul></ul>'); - expect( + ).toBe('<ul></ul>'); + expect( listNode.createDOM({ namespace: '', theme: {}, }).outerHTML, - ).toBe('<ul></ul>'); - }); + ).toBe('<ul></ul>'); }); + }); - test('ListNode.createDOM() correctly applies classes to a nested ListNode', async () => { - const {editor} = testEnv; + test('ListNode.createDOM() correctly applies classes to a nested ListNode', async () => { + const {editor} = createTestContext(); - await editor.update(() => { - const listNode1 = $createListNode('bullet'); - const listNode2 = $createListNode('bullet'); - const listNode3 = $createListNode('bullet'); - const listNode4 = $createListNode('bullet'); - const listNode5 = $createListNode('bullet'); - const listNode6 = $createListNode('bullet'); - const listNode7 = $createListNode('bullet'); + await editor.update(() => { + const listNode1 = $createListNode('bullet'); + const listNode2 = $createListNode('bullet'); + const listNode3 = $createListNode('bullet'); + const listNode4 = $createListNode('bullet'); + const listNode5 = $createListNode('bullet'); + const listNode6 = $createListNode('bullet'); + const listNode7 = $createListNode('bullet'); - const listItem1 = $createListItemNode(); - const listItem2 = $createListItemNode(); - const listItem3 = $createListItemNode(); - const listItem4 = $createListItemNode(); + const listItem1 = $createListItemNode(); + const listItem2 = $createListItemNode(); + const listItem3 = $createListItemNode(); + const listItem4 = $createListItemNode(); - listNode1.append(listItem1); - listItem1.append(listNode2); - listNode2.append(listItem2); - listItem2.append(listNode3); - listNode3.append(listItem3); - listItem3.append(listNode4); - listNode4.append(listItem4); - listNode4.append(listNode5); - listNode5.append(listNode6); - listNode6.append(listNode7); + listNode1.append(listItem1); + listItem1.append(listNode2); + listNode2.append(listItem2); + listItem2.append(listNode3); + listNode3.append(listItem3); + listItem3.append(listNode4); + listNode4.append(listItem4); + listNode4.append(listNode5); + listNode5.append(listNode6); + listNode6.append(listNode7); - expect(listNode1.createDOM(editorConfig).outerHTML).toBe( + expect(listNode1.createDOM(editorConfig).outerHTML).toBe( '<ul class="my-ul-list-class my-ul-list-class-1"></ul>', - ); - expect( + ); + expect( listNode1.createDOM({ namespace: '', theme: { list: {}, }, }).outerHTML, - ).toBe('<ul></ul>'); - expect( + ).toBe('<ul></ul>'); + expect( listNode1.createDOM({ namespace: '', theme: {}, }).outerHTML, - ).toBe('<ul></ul>'); - expect(listNode2.createDOM(editorConfig).outerHTML).toBe( + ).toBe('<ul></ul>'); + expect(listNode2.createDOM(editorConfig).outerHTML).toBe( '<ul class="my-ul-list-class my-ul-list-class-2"></ul>', - ); - expect(listNode3.createDOM(editorConfig).outerHTML).toBe( + ); + expect(listNode3.createDOM(editorConfig).outerHTML).toBe( '<ul class="my-ul-list-class my-ul-list-class-3"></ul>', - ); - expect(listNode4.createDOM(editorConfig).outerHTML).toBe( + ); + expect(listNode4.createDOM(editorConfig).outerHTML).toBe( '<ul class="my-ul-list-class my-ul-list-class-4"></ul>', - ); - expect(listNode5.createDOM(editorConfig).outerHTML).toBe( + ); + expect(listNode5.createDOM(editorConfig).outerHTML).toBe( '<ul class="my-ul-list-class my-ul-list-class-5"></ul>', - ); - expect(listNode6.createDOM(editorConfig).outerHTML).toBe( + ); + expect(listNode6.createDOM(editorConfig).outerHTML).toBe( '<ul class="my-ul-list-class my-ul-list-class-6"></ul>', - ); - expect(listNode7.createDOM(editorConfig).outerHTML).toBe( + ); + expect(listNode7.createDOM(editorConfig).outerHTML).toBe( '<ul class="my-ul-list-class my-ul-list-class-7"></ul>', - ); - expect( + ); + expect( listNode5.createDOM({ namespace: '', theme: { @@ -176,123 +176,135 @@ describe('LexicalListNode tests', () => { }, }, }).outerHTML, - ).toBe('<ul class="my-ul-list-class my-ul-list-class-2"></ul>'); - }); + ).toBe('<ul class="my-ul-list-class my-ul-list-class-2"></ul>'); }); + }); - test('ListNode.updateDOM()', async () => { - const {editor} = testEnv; + test('ListNode.updateDOM()', async () => { + const {editor} = createTestContext(); - await editor.update(() => { - const listNode = $createListNode('bullet', 1); - const domElement = listNode.createDOM(editorConfig); + await editor.update(() => { + const listNode = $createListNode('bullet', 1); + const domElement = listNode.createDOM(editorConfig); - expect(domElement.outerHTML).toBe( + expect(domElement.outerHTML).toBe( '<ul class="my-ul-list-class my-ul-list-class-1"></ul>', - ); + ); - const newListNode = $createListNode('number', 1); - const result = newListNode.updateDOM( + const newListNode = $createListNode('number', 1); + const result = newListNode.updateDOM( listNode, domElement, editorConfig, - ); + ); - expect(result).toBe(true); - expect(domElement.outerHTML).toBe( + expect(result).toBe(true); + expect(domElement.outerHTML).toBe( '<ul class="my-ul-list-class my-ul-list-class-1"></ul>', - ); - }); - }); - - test('ListNode.append() should properly transform a ListItemNode', async () => { - const {editor} = testEnv; - - await editor.update(() => { - const listNode = new ListNode('bullet', 1); - const listItemNode = new ListItemNode(); - const textNode = new TextNode('Hello'); - - listItemNode.append(textNode); - const nodesToAppend = [listItemNode]; - - expect(listNode.append(...nodesToAppend)).toBe(listNode); - expect(listNode.getFirstChild()).toBe(listItemNode); - expect(listNode.getFirstChild()?.getTextContent()).toBe('Hello'); - }); - }); - - test('ListNode.append() should properly transform a ListNode', async () => { - const {editor} = testEnv; - - await editor.update(() => { - const listNode = new ListNode('bullet', 1); - const nestedListNode = new ListNode('bullet', 1); - const listItemNode = new ListItemNode(); - const textNode = new TextNode('Hello'); - - listItemNode.append(textNode); - nestedListNode.append(listItemNode); - - const nodesToAppend = [nestedListNode]; - - expect(listNode.append(...nodesToAppend)).toBe(listNode); - expect($isListItemNode(listNode.getFirstChild())).toBe(true); - expect(listNode.getFirstChild<ListItemNode>()!.getFirstChild()).toBe( - nestedListNode, - ); - }); - }); - - test('ListNode.append() should properly transform a ParagraphNode', async () => { - const {editor} = testEnv; - - await editor.update(() => { - const listNode = new ListNode('bullet', 1); - const paragraph = new ParagraphNode(); - const textNode = new TextNode('Hello'); - paragraph.append(textNode); - const nodesToAppend = [paragraph]; - - expect(listNode.append(...nodesToAppend)).toBe(listNode); - expect($isListItemNode(listNode.getFirstChild())).toBe(true); - expect(listNode.getFirstChild()?.getTextContent()).toBe('Hello'); - }); - }); - - test('$createListNode()', async () => { - const {editor} = testEnv; - - await editor.update(() => { - const listNode = $createListNode('bullet', 1); - const createdListNode = $createListNode('bullet'); - - expect(listNode.__type).toEqual(createdListNode.__type); - expect(listNode.__parent).toEqual(createdListNode.__parent); - expect(listNode.__tag).toEqual(createdListNode.__tag); - expect(listNode.__key).not.toEqual(createdListNode.__key); - }); - }); - - test('$isListNode()', async () => { - const {editor} = testEnv; - - await editor.update(() => { - const listNode = $createListNode('bullet', 1); - - expect($isListNode(listNode)).toBe(true); - }); - }); - - test('$createListNode() with tag name (backward compatibility)', async () => { - const {editor} = testEnv; - - await editor.update(() => { - const numberList = $createListNode('number', 1); - const bulletList = $createListNode('bullet', 1); - expect(numberList.__listType).toBe('number'); - expect(bulletList.__listType).toBe('bullet'); - }); + ); }); }); + + test('ListNode.append() should properly transform a ListItemNode', async () => { + const {editor} = createTestContext(); + + await editor.update(() => { + const listNode = new ListNode('bullet', 1); + const listItemNode = new ListItemNode(); + const textNode = new TextNode('Hello'); + + listItemNode.append(textNode); + const nodesToAppend = [listItemNode]; + + expect(listNode.append(...nodesToAppend)).toBe(listNode); + expect(listNode.getFirstChild()).toBe(listItemNode); + expect(listNode.getFirstChild()?.getTextContent()).toBe('Hello'); + }); + }); + + test('ListNode.append() should properly transform a ListNode', async () => { + const {editor} = createTestContext(); + + await editor.update(() => { + const listNode = new ListNode('bullet', 1); + const nestedListNode = new ListNode('bullet', 1); + const listItemNode = new ListItemNode(); + const textNode = new TextNode('Hello'); + + listItemNode.append(textNode); + nestedListNode.append(listItemNode); + + const nodesToAppend = [nestedListNode]; + + expect(listNode.append(...nodesToAppend)).toBe(listNode); + expect($isListItemNode(listNode.getFirstChild())).toBe(true); + expect(listNode.getFirstChild<ListItemNode>()!.getFirstChild()).toBe( + nestedListNode, + ); + }); + }); + + test('ListNode.append() should properly transform a ParagraphNode', async () => { + const {editor} = createTestContext(); + + await editor.update(() => { + const listNode = new ListNode('bullet', 1); + const paragraph = new ParagraphNode(); + const textNode = new TextNode('Hello'); + paragraph.append(textNode); + const nodesToAppend = [paragraph]; + + expect(listNode.append(...nodesToAppend)).toBe(listNode); + expect($isListItemNode(listNode.getFirstChild())).toBe(true); + expect(listNode.getFirstChild()?.getTextContent()).toBe('Hello'); + }); + }); + + test('$createListNode()', async () => { + const {editor} = createTestContext(); + + await editor.update(() => { + const listNode = $createListNode('bullet', 1); + const createdListNode = $createListNode('bullet'); + + expect(listNode.__type).toEqual(createdListNode.__type); + expect(listNode.__parent).toEqual(createdListNode.__parent); + expect(listNode.__tag).toEqual(createdListNode.__tag); + expect(listNode.__key).not.toEqual(createdListNode.__key); + }); + }); + + test('$isListNode()', async () => { + const {editor} = createTestContext(); + + await editor.update(() => { + const listNode = $createListNode('bullet', 1); + + expect($isListNode(listNode)).toBe(true); + }); + }); + + test('$createListNode() with tag name (backward compatibility)', async () => { + const {editor} = createTestContext(); + + await editor.update(() => { + const numberList = $createListNode('number', 1); + const bulletList = $createListNode('bullet', 1); + expect(numberList.__listType).toBe('number'); + expect(bulletList.__listType).toBe('bullet'); + }); + }); + + test('importDOM handles old editor expected task list format', async () => { + const {editor} = createTestContext(); + + let list!: ListNode; + editor.update(() => { + const nodes = $htmlToBlockNodes(editor, `<ul><li class="task-list-item"><input checked="" disabled="" type="checkbox"> A</li></ul>`); + list = nodes[0] as ListNode; + }); + + expect(list).toBeInstanceOf(ListNode); + expect(list.getListType()).toBe('check'); + }); }); diff --git a/resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts b/resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts index 736c3573c..cd4235f2f 100644 --- a/resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts +++ b/resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts @@ -1,7 +1,7 @@ import { createTestContext, destroyFromContext, dispatchKeydownEventForNode, - dispatchKeydownEventForSelectedNode, + dispatchKeydownEventForSelectedNode, expectNodeShapeToMatch, } from "lexical/__tests__/utils"; import { $createParagraphNode, $createTextNode, @@ -13,6 +13,7 @@ import {registerKeyboardHandling} from "../keyboard-handling"; import {registerRichText} from "@lexical/rich-text"; import {EditorUiContext} from "../../ui/framework/core"; import {$createListItemNode, $createListNode, ListItemNode, ListNode} from "@lexical/list"; +import {$createImageNode, ImageNode} from "@lexical/rich-text/LexicalImageNode"; describe('Keyboard-handling service tests', () => { @@ -127,4 +128,34 @@ describe('Keyboard-handling service tests', () => { expect(selectedNode?.getKey()).toBe(innerList.getChildren()[0].getKey()); }); }); + + test('Images: up on selected image creates new paragraph if none above', () => { + let image!: ImageNode; + editor.updateAndCommit(() => { + const root = $getRoot(); + const imageWrap = $createParagraphNode(); + image = $createImageNode('https://example.com/cat.png'); + imageWrap.append(image); + root.append(imageWrap); + image.select(); + }); + + expectNodeShapeToMatch(editor, [{ + type: 'paragraph', + children: [ + {type: 'image'} + ], + }]); + + dispatchKeydownEventForNode(image, editor, 'ArrowUp'); + + expectNodeShapeToMatch(editor, [{ + type: 'paragraph', + }, { + type: 'paragraph', + children: [ + {type: 'image'} + ], + }]); + }); }); \ No newline at end of file diff --git a/resources/js/wysiwyg/services/keyboard-handling.ts b/resources/js/wysiwyg/services/keyboard-handling.ts index 0ef0b81bf..a7f1ec7f0 100644 --- a/resources/js/wysiwyg/services/keyboard-handling.ts +++ b/resources/js/wysiwyg/services/keyboard-handling.ts @@ -79,22 +79,19 @@ function focusAdjacentOrInsertForSingleSelectNode(editor: LexicalEditor, event: const nearestBlock = $getNearestNodeBlockParent(node) || node; let target = after ? nearestBlock.getNextSibling() : nearestBlock.getPreviousSibling(); - requestAnimationFrame(() => { - editor.update(() => { - if (!target) { - target = $createParagraphNode(); - if (after) { - nearestBlock.insertAfter(target) - } else { - nearestBlock.insertBefore(target); - } + editor.update(() => { + if (!target) { + target = $createParagraphNode(); + if (after) { + nearestBlock.insertAfter(target) + } else { + nearestBlock.insertBefore(target); } + } - target.selectStart(); - }); + target.selectStart(); }); - return true; }