From 97b201f61f98aaccf779d08634c247c8cfbbfbb5 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 14 Dec 2024 12:35:13 +0000 Subject: [PATCH 01/12] Lexical: Added auto links on enter/space --- resources/js/wysiwyg/index.ts | 2 + .../services/__tests__/auto-links.test.ts | 91 +++++++++++++++++++ resources/js/wysiwyg/services/auto-links.ts | 74 +++++++++++++++ resources/js/wysiwyg/utils/selection.ts | 4 + 4 files changed, 171 insertions(+) create mode 100644 resources/js/wysiwyg/services/__tests__/auto-links.test.ts create mode 100644 resources/js/wysiwyg/services/auto-links.ts diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index 9066b402f..510ab1f92 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -15,6 +15,7 @@ import {el} from "./utils/dom"; import {registerShortcuts} from "./services/shortcuts"; import {registerNodeResizer} from "./ui/framework/helpers/node-resizer"; import {registerKeyboardHandling} from "./services/keyboard-handling"; +import {registerAutoLinks} from "./services/auto-links"; export function createPageEditorInstance(container: HTMLElement, htmlContent: string, options: Record = {}): SimpleWysiwygEditorInterface { const config: CreateEditorArgs = { @@ -64,6 +65,7 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st registerTaskListHandler(editor, editArea), registerDropPasteHandling(context), registerNodeResizer(context), + registerAutoLinks(editor), ); listenToCommonEvents(editor); diff --git a/resources/js/wysiwyg/services/__tests__/auto-links.test.ts b/resources/js/wysiwyg/services/__tests__/auto-links.test.ts new file mode 100644 index 000000000..d3b120b70 --- /dev/null +++ b/resources/js/wysiwyg/services/__tests__/auto-links.test.ts @@ -0,0 +1,91 @@ +import {initializeUnitTest} from "lexical/__tests__/utils"; +import {SerializedLinkNode} from "@lexical/link"; +import { + $getRoot, + ParagraphNode, + SerializedParagraphNode, + SerializedTextNode, + TextNode +} from "lexical"; +import {registerAutoLinks} from "../auto-links"; + +describe('Auto-link service tests', () => { + initializeUnitTest((testEnv) => { + + test('space after link in text', async () => { + const {editor} = testEnv; + + registerAutoLinks(editor); + let pNode!: ParagraphNode; + + editor.update(() => { + pNode = new ParagraphNode(); + const text = new TextNode('Some https://example.com?test=true text'); + pNode.append(text); + $getRoot().append(pNode); + + text.select(35, 35); + }); + + editor.commitUpdates(); + + const pDomEl = editor.getElementByKey(pNode.getKey()); + const event = new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + key: ' ', + keyCode: 62, + }); + pDomEl?.dispatchEvent(event); + + editor.commitUpdates(); + + const paragraph = editor!.getEditorState().toJSON().root + .children[0] as SerializedParagraphNode; + expect(paragraph.children[1].type).toBe('link'); + + const link = paragraph.children[1] as SerializedLinkNode; + expect(link.url).toBe('https://example.com?test=true'); + const linkText = link.children[0] as SerializedTextNode; + expect(linkText.text).toBe('https://example.com?test=true'); + }); + + test('enter after link in text', async () => { + const {editor} = testEnv; + + registerAutoLinks(editor); + let pNode!: ParagraphNode; + + editor.update(() => { + pNode = new ParagraphNode(); + const text = new TextNode('Some https://example.com?test=true text'); + pNode.append(text); + $getRoot().append(pNode); + + text.select(35, 35); + }); + + editor.commitUpdates(); + + const pDomEl = editor.getElementByKey(pNode.getKey()); + const event = new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + key: 'Enter', + keyCode: 66, + }); + pDomEl?.dispatchEvent(event); + + editor.commitUpdates(); + + const paragraph = editor!.getEditorState().toJSON().root + .children[0] as SerializedParagraphNode; + expect(paragraph.children[1].type).toBe('link'); + + const link = paragraph.children[1] as SerializedLinkNode; + expect(link.url).toBe('https://example.com?test=true'); + const linkText = link.children[0] as SerializedTextNode; + expect(linkText.text).toBe('https://example.com?test=true'); + }); + }); +}); \ No newline at end of file diff --git a/resources/js/wysiwyg/services/auto-links.ts b/resources/js/wysiwyg/services/auto-links.ts new file mode 100644 index 000000000..44a78ec85 --- /dev/null +++ b/resources/js/wysiwyg/services/auto-links.ts @@ -0,0 +1,74 @@ +import { + $getSelection, BaseSelection, + COMMAND_PRIORITY_NORMAL, + KEY_ENTER_COMMAND, + KEY_SPACE_COMMAND, + LexicalEditor, + TextNode +} from "lexical"; +import {$getTextNodeFromSelection} from "../utils/selection"; +import {$createLinkNode, LinkNode} from "@lexical/link"; + + +function isLinkText(text: string): boolean { + const lower = text.toLowerCase(); + if (!lower.startsWith('http')) { + return false; + } + + const linkRegex = /(http|https):\/\/(\S+)\.\S+$/; + return linkRegex.test(text); +} + + +function handlePotentialLinkEvent(node: TextNode, selection: BaseSelection, editor: LexicalEditor) { + const selectionRange = selection.getStartEndPoints(); + if (!selectionRange) { + return; + } + + const cursorPoint = selectionRange[0].offset - 1; + const nodeText = node.getTextContent(); + const rTrimText = nodeText.slice(0, cursorPoint); + const priorSpaceIndex = rTrimText.lastIndexOf(' '); + const startIndex = priorSpaceIndex + 1; + const textSegment = nodeText.slice(startIndex, cursorPoint); + + if (!isLinkText(textSegment)) { + return; + } + + editor.update(() => { + const linkNode: LinkNode = $createLinkNode(textSegment); + linkNode.append(new TextNode(textSegment)); + + const splits = node.splitText(startIndex, cursorPoint); + const targetIndex = splits.length === 3 ? 1 : 0; + const targetText = splits[targetIndex]; + if (targetText) { + targetText.replace(linkNode); + } + }); +} + + +export function registerAutoLinks(editor: LexicalEditor): () => void { + + const handler = (payload: KeyboardEvent): boolean => { + const selection = $getSelection(); + const textNode = $getTextNodeFromSelection(selection); + if (textNode && selection) { + handlePotentialLinkEvent(textNode, selection, editor); + } + + return false; + }; + + const unregisterSpace = editor.registerCommand(KEY_SPACE_COMMAND, handler, COMMAND_PRIORITY_NORMAL); + const unregisterEnter = editor.registerCommand(KEY_ENTER_COMMAND, handler, COMMAND_PRIORITY_NORMAL); + + return (): void => { + unregisterSpace(); + unregisterEnter(); + }; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/utils/selection.ts b/resources/js/wysiwyg/utils/selection.ts index 28e729e92..167ab32ad 100644 --- a/resources/js/wysiwyg/utils/selection.ts +++ b/resources/js/wysiwyg/utils/selection.ts @@ -51,6 +51,10 @@ export function $getNodeFromSelection(selection: BaseSelection | null, matcher: return null; } +export function $getTextNodeFromSelection(selection: BaseSelection | null): TextNode|null { + return $getNodeFromSelection(selection, $isTextNode) as TextNode|null; +} + export function $selectionContainsTextFormat(selection: BaseSelection | null, format: TextFormatType): boolean { if (!selection) { return false; From a71aa241ada17bfc5497c59914774260003b3339 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 14 Dec 2024 15:17:33 +0000 Subject: [PATCH 02/12] Lexical: Added dark mode styles, fixed autolink range --- .../services/__tests__/auto-links.test.ts | 4 +- resources/js/wysiwyg/services/auto-links.ts | 2 +- resources/sass/_editor.scss | 47 ++++++++++++------- 3 files changed, 33 insertions(+), 20 deletions(-) diff --git a/resources/js/wysiwyg/services/__tests__/auto-links.test.ts b/resources/js/wysiwyg/services/__tests__/auto-links.test.ts index d3b120b70..30dc92565 100644 --- a/resources/js/wysiwyg/services/__tests__/auto-links.test.ts +++ b/resources/js/wysiwyg/services/__tests__/auto-links.test.ts @@ -24,7 +24,7 @@ describe('Auto-link service tests', () => { pNode.append(text); $getRoot().append(pNode); - text.select(35, 35); + text.select(34, 34); }); editor.commitUpdates(); @@ -62,7 +62,7 @@ describe('Auto-link service tests', () => { pNode.append(text); $getRoot().append(pNode); - text.select(35, 35); + text.select(34, 34); }); editor.commitUpdates(); diff --git a/resources/js/wysiwyg/services/auto-links.ts b/resources/js/wysiwyg/services/auto-links.ts index 44a78ec85..1c3b1c730 100644 --- a/resources/js/wysiwyg/services/auto-links.ts +++ b/resources/js/wysiwyg/services/auto-links.ts @@ -27,7 +27,7 @@ function handlePotentialLinkEvent(node: TextNode, selection: BaseSelection, edit return; } - const cursorPoint = selectionRange[0].offset - 1; + const cursorPoint = selectionRange[0].offset; const nodeText = node.getTextContent(); const rTrimText = nodeText.slice(0, cursorPoint); const priorSpaceIndex = rTrimText.lastIndexOf(' '); diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index e273f1942..bdf6ea44c 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -8,18 +8,20 @@ // Main UI elements .editor-container { - background-color: #FFF; + @include mixins.lightDark(background-color, #FFF, #222); position: relative; &.fullscreen { z-index: 500; } } + .editor-toolbar-main { display: flex; flex-wrap: wrap; justify-content: center; border-top: 1px solid #DDD; border-bottom: 1px solid #DDD; + @include mixins.lightDark(border-color, #DDD, #000); } body.editor-is-fullscreen { @@ -46,6 +48,7 @@ body.editor-is-fullscreen { font-size: 12px; padding: 4px; color: #444; + @include mixins.lightDark(color, #444, #999); border-radius: 4px; display: flex; align-items: center; @@ -54,6 +57,7 @@ body.editor-is-fullscreen { } .editor-button:hover { background-color: #EEE; + @include mixins.lightDark(background-color, #EEE, #333); cursor: pointer; color: #000; } @@ -63,7 +67,7 @@ body.editor-is-fullscreen { opacity: .6; } .editor-button-active, .editor-button-active:hover { - background-color: #ceebff; + @include mixins.lightDark(background-color, #ceebff, #444); color: #000; } .editor-button-long { @@ -75,7 +79,7 @@ body.editor-is-fullscreen { } .editor-button-text { font-weight: 400; - color: #000; + @include mixins.lightDark(color, #000, #AAA); font-size: 14px; flex: 1; padding-inline-end: 4px; @@ -126,7 +130,8 @@ body.editor-is-fullscreen { } } &:hover { - outline: 1px solid #DDD; + outline: 1px solid; + @include mixins.lightDark(outline-color, #DDD, #111); outline-offset: -3px; } } @@ -137,11 +142,14 @@ body.editor-is-fullscreen { } .editor-dropdown-menu { position: absolute; - background-color: #FFF; - box-shadow: 0 0 6px 0 rgba(0, 0, 0, 0.15); + border: 1px solid; + @include mixins.lightDark(background-color, #FFF, #292929); + @include mixins.lightDark(border-color, #FFF, #333); + @include mixins.lightDark(box-shadow, 0 0 6px 0 rgba(0, 0, 0, 0.15), 0 1px 4px 0 rgba(0, 0, 0, 0.4)); z-index: 99; display: flex; flex-direction: row; + border-radius: 3px; } .editor-dropdown-menu-vertical { display: flex; @@ -163,8 +171,8 @@ body.editor-is-fullscreen { .editor-separator { display: block; height: 1px; - background-color: #DDD; opacity: .8; + @include mixins.lightDark(background-color, #DDD, #000); } .editor-format-menu-toggle { @@ -199,6 +207,7 @@ body.editor-is-fullscreen { display: flex; border-inline: 1px solid #DDD; padding-inline: 4px; + @include mixins.lightDark(border-color, #DDD, #000); &:first-child { border-inline-start: none; } @@ -212,11 +221,12 @@ body.editor-is-fullscreen { .editor-context-toolbar { position: fixed; - background-color: #FFF; border: 1px solid #DDD; + @include mixins.lightDark(background-color, #FFF, #222); + @include mixins.lightDark(border-color, #DDD, #333); + @include mixins.lightDark(box-shadow, 0 2px 4px 0 rgba(0, 0, 0, 0.12), 0 1px 4px 0 rgba(0, 0, 0, 0.4)); padding: .2rem; border-radius: 4px; - box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.12); display: flex; flex-direction: row; &:before { @@ -226,9 +236,10 @@ body.editor-is-fullscreen { width: 8px; height: 8px; position: absolute; - background-color: #FFF; + @include mixins.lightDark(background-color, #FFF, #222); border-top: 1px solid #DDD; border-left: 1px solid #DDD; + @include mixins.lightDark(border-color, #DDD, #333); transform: rotate(45deg); left: 50%; margin-left: -4px; @@ -252,7 +263,7 @@ body.editor-is-fullscreen { height: 100%; } .editor-modal { - background-color: #FFF; + @include mixins.lightDark(background-color, #FFF, #222); border-radius: 4px; overflow: hidden; box-shadow: 0 0 15px 0 rgba(0, 0, 0, 0.3); @@ -314,7 +325,8 @@ body.editor-is-fullscreen { display: flex; } .editor-table-creator-cell { - border: 1px solid #DDD; + border: 1px solid; + @include mixins.lightDark(border-color, #DDD, #000); width: 15px; height: 15px; cursor: pointer; @@ -347,7 +359,7 @@ body.editor-is-fullscreen { height: 10px; border: 2px solid var(--editor-color-primary); z-index: 3; - background-color: #FFF; + @include mixins.lightDark(background-color, #FFF, #000); user-select: none; &.nw { inset-inline-start: -5px; @@ -477,10 +489,11 @@ body.editor-is-fullscreen { display: block; width: 100%; min-width: 250px; - border: 1px solid #DDD; + border: 1px solid; + @include mixins.lightDark(border-color, #DDD, #000); padding: .5rem; border-radius: 4px; - color: #444; + @include mixins.lightDark(color, #444, #BBB); } textarea.editor-form-field-input { font-family: var(--font-code); @@ -557,7 +570,7 @@ textarea.editor-form-field-input { .editor-form-tab-control { font-weight: bold; font-size: 14px; - color: #444; + @include mixins.lightDark(color, #444, #666); border-bottom: 2px solid transparent; position: relative; cursor: pointer; @@ -565,7 +578,7 @@ textarea.editor-form-field-input { text-align: start; &[aria-selected="true"] { border-color: var(--editor-color-primary); - color: var(--editor-color-primary); + color: var(--editor-color-primary) !important; } &[aria-selected="true"]:after, &:hover:after { background-color: var(--editor-color-primary); From 5f07f31c9fc21c4f82b757eb2e78027ff3ad6337 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 15 Dec 2024 14:03:08 +0000 Subject: [PATCH 03/12] Lexical: Added mobile toolbar support Adds dynamic and fixed (out of DOM order) positioning with location adjustment depending on space. Also adds smarter hiding to prevent disappearing when mouse leaves but within the same space as the toggle. --- .../ui/framework/blocks/button-with-menu.ts | 1 + .../ui/framework/blocks/dropdown-button.ts | 3 ++ .../wysiwyg/ui/framework/helpers/dropdowns.ts | 52 +++++++++++++++++-- resources/js/wysiwyg/ui/toolbars.ts | 4 +- resources/sass/_editor.scss | 8 +++ resources/sass/_pages.scss | 1 + 6 files changed, 62 insertions(+), 7 deletions(-) diff --git a/resources/js/wysiwyg/ui/framework/blocks/button-with-menu.ts b/resources/js/wysiwyg/ui/framework/blocks/button-with-menu.ts index 30dd237f6..2aec7c335 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/button-with-menu.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/button-with-menu.ts @@ -16,6 +16,7 @@ export class EditorButtonWithMenu extends EditorContainerUiElement { button: {label: 'Menu', icon: caretDownIcon}, showOnHover: false, direction: 'vertical', + showAside: false, }, menuItems); this.addChildren(this.dropdownButton); } diff --git a/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts b/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts index cba141f6c..d7f02d573 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts @@ -7,12 +7,14 @@ import {EditorMenuButton} from "./menu-button"; export type EditorDropdownButtonOptions = { showOnHover?: boolean; direction?: 'vertical'|'horizontal'; + showAside?: boolean; button: EditorBasicButtonDefinition|EditorButton; }; const defaultOptions: EditorDropdownButtonOptions = { showOnHover: false, direction: 'horizontal', + showAside: undefined, button: {label: 'Menu'}, } @@ -65,6 +67,7 @@ export class EditorDropdownButton extends EditorContainerUiElement { handleDropdown({toggle: button, menu : menu, showOnHover: this.options.showOnHover, + showAside: typeof this.options.showAside === 'boolean' ? this.options.showAside : (this.options.direction === 'vertical'), onOpen : () => { this.open = true; this.getContext().manager.triggerStateUpdateForElement(this.button); diff --git a/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts b/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts index e8cef3c8d..ccced6858 100644 --- a/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts +++ b/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts @@ -1,20 +1,48 @@ - - - interface HandleDropdownParams { toggle: HTMLElement; menu: HTMLElement; showOnHover?: boolean, onOpen?: Function | undefined; onClose?: Function | undefined; + showAside?: boolean; +} + +function positionMenu(menu: HTMLElement, toggle: HTMLElement, showAside: boolean) { + const toggleRect = toggle.getBoundingClientRect(); + const menuBounds = menu.getBoundingClientRect(); + + menu.style.position = 'fixed'; + + if (showAside) { + let targetLeft = toggleRect.right; + const isRightOOB = toggleRect.right + menuBounds.width > window.innerWidth; + if (isRightOOB) { + targetLeft = Math.max(toggleRect.left - menuBounds.width, 0); + } + + menu.style.top = toggleRect.top + 'px'; + menu.style.left = targetLeft + 'px'; + } else { + const isRightOOB = toggleRect.left + menuBounds.width > window.innerWidth; + let targetLeft = toggleRect.left; + if (isRightOOB) { + targetLeft = Math.max(toggleRect.right - menuBounds.width, 0); + } + + menu.style.top = toggleRect.bottom + 'px'; + menu.style.left = targetLeft + 'px'; + } } export function handleDropdown(options: HandleDropdownParams) { - const {menu, toggle, onClose, onOpen, showOnHover} = options; + const {menu, toggle, onClose, onOpen, showOnHover, showAside} = options; let clickListener: Function|null = null; const hide = () => { menu.hidden = true; + menu.style.removeProperty('position'); + menu.style.removeProperty('left'); + menu.style.removeProperty('top'); if (clickListener) { window.removeEventListener('click', clickListener as EventListener); } @@ -25,6 +53,7 @@ export function handleDropdown(options: HandleDropdownParams) { const show = () => { menu.hidden = false + positionMenu(menu, toggle, Boolean(showAside)); clickListener = (event: MouseEvent) => { if (!toggle.contains(event.target as HTMLElement) && !menu.contains(event.target as HTMLElement)) { hide(); @@ -44,5 +73,18 @@ export function handleDropdown(options: HandleDropdownParams) { toggle.addEventListener('mouseenter', toggleShowing); } - menu.parentElement?.addEventListener('mouseleave', hide); + menu.parentElement?.addEventListener('mouseleave', (event: MouseEvent) => { + + // Prevent mouseleave hiding if withing the same bounds of the toggle. + // Avoids hiding in the event the mouse is interrupted by a high z-index + // item like a browser scrollbar. + const toggleBounds = toggle.getBoundingClientRect(); + const withinX = event.clientX <= toggleBounds.right && event.clientX >= toggleBounds.left; + const withinY = event.clientY <= toggleBounds.bottom && event.clientY >= toggleBounds.top; + const withinToggle = withinX && withinY; + + if (!withinToggle) { + hide(); + } + }); } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/toolbars.ts index 35146e5a4..886e1394b 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/toolbars.ts @@ -149,8 +149,8 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai new EditorOverflowContainer(4, [ new EditorButton(link), - new EditorDropdownButton({button: table, direction: 'vertical'}, [ - new EditorDropdownButton({button: {label: 'Insert', format: 'long'}, showOnHover: true}, [ + new EditorDropdownButton({button: table, direction: 'vertical', showAside: false}, [ + new EditorDropdownButton({button: {label: 'Insert', format: 'long'}, showOnHover: true, showAside: true}, [ new EditorTableCreator(), ]), new EditorSeparator(), diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index bdf6ea44c..e48131837 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -24,6 +24,14 @@ @include mixins.lightDark(border-color, #DDD, #000); } +@include mixins.smaller-than(vars.$bp-xl) { + .editor-toolbar-main { + overflow-x: scroll; + flex-wrap: nowrap; + justify-content: start; + } +} + body.editor-is-fullscreen { overflow: hidden; .edit-area { diff --git a/resources/sass/_pages.scss b/resources/sass/_pages.scss index 17bcfcfbf..45e58ffc8 100755 --- a/resources/sass/_pages.scss +++ b/resources/sass/_pages.scss @@ -26,6 +26,7 @@ width: 100%; border-radius: 8px; box-shadow: vars.$bs-card; + min-width: 300px; @include mixins.lightDark(background-color, #FFF, #333) } From 2f119d3033c9543a22109c34d2b77ef99e5486af Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 15 Dec 2024 15:29:00 +0000 Subject: [PATCH 04/12] Lexical: Adjusted modals and content area for mobile sizes --- resources/sass/_editor.scss | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index e48131837..3eef4c2c6 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -48,6 +48,7 @@ body.editor-is-fullscreen { .editor-content-wrap { position: relative; overflow-y: scroll; + padding-inline: vars.$s; flex: 1; } @@ -275,6 +276,9 @@ body.editor-is-fullscreen { border-radius: 4px; overflow: hidden; box-shadow: 0 0 15px 0 rgba(0, 0, 0, 0.3); + margin: vars.$xs; + max-height: 100%; + overflow-y: auto; } .editor-modal-header { display: flex; @@ -490,19 +494,29 @@ body.editor-is-fullscreen { /** * Form elements */ +$inputWidth: 260px; + .editor-form-field-wrapper { margin-bottom: .5rem; } .editor-form-field-input { display: block; - width: 100%; - min-width: 250px; + width: $inputWidth; + min-width: 100px; + max-width: 100%; border: 1px solid; @include mixins.lightDark(border-color, #DDD, #000); padding: .5rem; border-radius: 4px; @include mixins.lightDark(color, #444, #BBB); } + +@include mixins.smaller-than(vars.$bp-xs) { + .editor-form-field-input { + min-width: 160px; + } +} + textarea.editor-form-field-input { font-family: var(--font-code); width: 350px; @@ -575,6 +589,17 @@ textarea.editor-form-field-input { align-items: stretch; gap: .25rem; } + +@include mixins.smaller-than(vars.$bp-m) { + .editor-form-tab-container { + flex-direction: column; + gap: .5rem; + } + .editor-form-tab-controls { + flex-direction: row; + } +} + .editor-form-tab-control { font-weight: bold; font-size: 14px; @@ -601,7 +626,8 @@ textarea.editor-form-field-input { } } .editor-form-tab-contents { - width: 360px; + width: $inputWidth; + max-width: 100%; } .editor-action-input-container { display: flex; @@ -612,6 +638,9 @@ textarea.editor-form-field-input { .editor-button { margin-bottom: 12px; } + input { + width: $inputWidth - 40px; + } } // Editor theme styles From 3f86937f74f2e5dc1e11fc2fd84694ce0793308f Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 15 Dec 2024 17:11:02 +0000 Subject: [PATCH 05/12] Lexical: Made summary part of details node To provide more control of the summary as part of details. To support, added a way to ignore elements during import DOM, allowing up to read summaries when parsing details without duplicate nodes involved. --- .../js/wysiwyg/lexical/core/LexicalNode.ts | 7 +- resources/js/wysiwyg/lexical/html/index.ts | 5 + .../lexical/rich-text/LexicalDetailsNode.ts | 98 ++++++++----------- resources/js/wysiwyg/nodes.ts | 4 +- 4 files changed, 54 insertions(+), 60 deletions(-) diff --git a/resources/js/wysiwyg/lexical/core/LexicalNode.ts b/resources/js/wysiwyg/lexical/core/LexicalNode.ts index c6bc2e642..a6c9b6023 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalNode.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalNode.ts @@ -142,10 +142,15 @@ export type DOMConversionMap = Record< >; type NodeName = string; +/** + * Output for a DOM conversion. + * Node can be set to 'ignore' to ignore the conversion and handling of the DOMNode + * including all its children. + */ export type DOMConversionOutput = { after?: (childLexicalNodes: Array) => Array; forChild?: DOMChildConversion; - node: null | LexicalNode | Array; + node: null | LexicalNode | Array | 'ignore'; }; export type DOMExportOutputMap = Map< diff --git a/resources/js/wysiwyg/lexical/html/index.ts b/resources/js/wysiwyg/lexical/html/index.ts index 3e962ec72..5c3cb6cce 100644 --- a/resources/js/wysiwyg/lexical/html/index.ts +++ b/resources/js/wysiwyg/lexical/html/index.ts @@ -217,6 +217,11 @@ function $createNodesFromDOM( if (transformOutput !== null) { postTransform = transformOutput.after; const transformNodes = transformOutput.node; + + if (transformNodes === 'ignore') { + return lexicalNodes; + } + currentLexicalNode = Array.isArray(transformNodes) ? transformNodes[transformNodes.length - 1] : transformNodes; diff --git a/resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts b/resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts index 178b0d953..18d471103 100644 --- a/resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts +++ b/resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts @@ -5,18 +5,19 @@ import { LexicalEditor, LexicalNode, SerializedElementNode, Spread, - EditorConfig, + EditorConfig, DOMExportOutput, } from 'lexical'; -import {el} from "../../utils/dom"; import {extractDirectionFromElement} from "lexical/nodes/common"; export type SerializedDetailsNode = Spread<{ id: string; + summary: string; }, SerializedElementNode> export class DetailsNode extends ElementNode { __id: string = ''; + __summary: string = ''; static getType() { return 'details'; @@ -32,10 +33,21 @@ export class DetailsNode extends ElementNode { return self.__id; } + setSummary(summary: string) { + const self = this.getWritable(); + self.__summary = summary; + } + + getSummary(): string { + const self = this.getLatest(); + return self.__summary; + } + static clone(node: DetailsNode): DetailsNode { const newNode = new DetailsNode(node.__key); newNode.__id = node.__id; newNode.__dir = node.__dir; + newNode.__summary = node.__summary; return newNode; } @@ -49,6 +61,11 @@ export class DetailsNode extends ElementNode { el.setAttribute('dir', this.__dir); } + const summary = document.createElement('summary'); + summary.textContent = this.__summary; + summary.setAttribute('contenteditable', 'false'); + el.append(summary); + return el; } @@ -71,20 +88,42 @@ export class DetailsNode extends ElementNode { node.setDirection(extractDirectionFromElement(element)); } + const summaryElem = Array.from(element.children).find(e => e.nodeName === 'SUMMARY'); + node.setSummary(summaryElem?.textContent || ''); + return {node}; }, priority: 3, }; }, + summary(node: HTMLElement): DOMConversion|null { + return { + conversion: (element: HTMLElement): DOMConversionOutput|null => { + return {node: 'ignore'}; + }, + priority: 3, + }; + }, }; } + exportDOM(editor: LexicalEditor): DOMExportOutput { + const element = this.createDOM(editor._config, editor); + const editable = element.querySelectorAll('[contenteditable]'); + for (const elem of editable) { + elem.removeAttribute('contenteditable'); + } + + return {element}; + } + exportJSON(): SerializedDetailsNode { return { ...super.exportJSON(), type: 'details', version: 1, id: this.__id, + summary: this.__summary, }; } @@ -104,58 +143,3 @@ export function $createDetailsNode() { export function $isDetailsNode(node: LexicalNode | null | undefined): node is DetailsNode { return node instanceof DetailsNode; } - -export class SummaryNode extends ElementNode { - - static getType() { - return 'summary'; - } - - static clone(node: SummaryNode) { - return new SummaryNode(node.__key); - } - - createDOM(_config: EditorConfig, _editor: LexicalEditor) { - return el('summary'); - } - - updateDOM(prevNode: DetailsNode, dom: HTMLElement) { - return false; - } - - static importDOM(): DOMConversionMap|null { - return { - summary(node: HTMLElement): DOMConversion|null { - return { - conversion: (element: HTMLElement): DOMConversionOutput|null => { - return { - node: new SummaryNode(), - }; - }, - priority: 3, - }; - }, - }; - } - - exportJSON(): SerializedElementNode { - return { - ...super.exportJSON(), - type: 'summary', - version: 1, - }; - } - - static importJSON(serializedNode: SerializedElementNode): SummaryNode { - return $createSummaryNode(); - } - -} - -export function $createSummaryNode(): SummaryNode { - return new SummaryNode(); -} - -export function $isSummaryNode(node: LexicalNode | null | undefined): node is SummaryNode { - return node instanceof SummaryNode; -} diff --git a/resources/js/wysiwyg/nodes.ts b/resources/js/wysiwyg/nodes.ts index eb836bdce..8a47f322d 100644 --- a/resources/js/wysiwyg/nodes.ts +++ b/resources/js/wysiwyg/nodes.ts @@ -8,7 +8,7 @@ import { } from "lexical"; import {LinkNode} from "@lexical/link"; import {ImageNode} from "@lexical/rich-text/LexicalImageNode"; -import {DetailsNode, SummaryNode} from "@lexical/rich-text/LexicalDetailsNode"; +import {DetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; import {ListItemNode, ListNode} from "@lexical/list"; import {TableCellNode, TableNode, TableRowNode} from "@lexical/table"; import {HorizontalRuleNode} from "@lexical/rich-text/LexicalHorizontalRuleNode"; @@ -34,7 +34,7 @@ export function getNodesForPageEditor(): (KlassConstructor | TableCellNode, ImageNode, // TODO - Alignment HorizontalRuleNode, - DetailsNode, SummaryNode, + DetailsNode, CodeBlockNode, DiagramNode, MediaNode, // TODO - Alignment From 5887322178c0fd95319ed104abee3bd4733c5d0d Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 15 Dec 2024 18:13:49 +0000 Subject: [PATCH 06/12] Lexical: Added details toolbar Includes unwrap and toggle open actions. --- resources/icons/editor/details-toggle.svg | 1 + .../lexical/rich-text/LexicalDetailsNode.ts | 33 ++++++++++- .../js/wysiwyg/ui/defaults/buttons/objects.ts | 59 ++++++++++++++++++- .../js/wysiwyg/ui/defaults/forms/objects.ts | 34 +++++++++++ resources/js/wysiwyg/ui/defaults/modals.ts | 6 +- resources/js/wysiwyg/ui/index.ts | 7 ++- resources/js/wysiwyg/ui/toolbars.ts | 10 +++- 7 files changed, 144 insertions(+), 6 deletions(-) create mode 100644 resources/icons/editor/details-toggle.svg diff --git a/resources/icons/editor/details-toggle.svg b/resources/icons/editor/details-toggle.svg new file mode 100644 index 000000000..37194e059 --- /dev/null +++ b/resources/icons/editor/details-toggle.svg @@ -0,0 +1 @@ + diff --git a/resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts b/resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts index 18d471103..3c845359a 100644 --- a/resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts +++ b/resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts @@ -18,6 +18,7 @@ export type SerializedDetailsNode = Spread<{ export class DetailsNode extends ElementNode { __id: string = ''; __summary: string = ''; + __open: boolean = false; static getType() { return 'details'; @@ -43,11 +44,22 @@ export class DetailsNode extends ElementNode { return self.__summary; } + setOpen(open: boolean) { + const self = this.getWritable(); + self.__open = open; + } + + getOpen(): boolean { + const self = this.getLatest(); + return self.__open; + } + static clone(node: DetailsNode): DetailsNode { const newNode = new DetailsNode(node.__key); newNode.__id = node.__id; newNode.__dir = node.__dir; newNode.__summary = node.__summary; + newNode.__open = node.__open; return newNode; } @@ -61,17 +73,34 @@ export class DetailsNode extends ElementNode { el.setAttribute('dir', this.__dir); } + if (this.__open) { + el.setAttribute('open', 'true'); + } + const summary = document.createElement('summary'); summary.textContent = this.__summary; summary.setAttribute('contenteditable', 'false'); + summary.addEventListener('click', event => { + event.preventDefault(); + _editor.update(() => { + this.select(); + }) + }); + el.append(summary); return el; } updateDOM(prevNode: DetailsNode, dom: HTMLElement) { + + if (prevNode.__open !== this.__open) { + dom.toggleAttribute('open', this.__open); + } + return prevNode.__id !== this.__id - || prevNode.__dir !== this.__dir; + || prevNode.__dir !== this.__dir + || prevNode.__summary !== this.__summary; } static importDOM(): DOMConversionMap|null { @@ -114,6 +143,8 @@ export class DetailsNode extends ElementNode { elem.removeAttribute('contenteditable'); } + element.removeAttribute('open'); + return {element}; } diff --git a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts index f9c029ff1..6612c0dc4 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts @@ -19,6 +19,9 @@ import editIcon from "@icons/edit.svg"; import diagramIcon from "@icons/editor/diagram.svg"; import {$createDiagramNode, DiagramNode} from "@lexical/rich-text/LexicalDiagramNode"; import detailsIcon from "@icons/editor/details.svg"; +import detailsToggleIcon from "@icons/editor/details-toggle.svg"; +import tableDeleteIcon from "@icons/editor/table-delete.svg"; +import tagIcon from "@icons/tag.svg"; import mediaIcon from "@icons/editor/media.svg"; import {$createDetailsNode, $isDetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; import {$isMediaNode, MediaNode} from "@lexical/rich-text/LexicalMediaNode"; @@ -29,7 +32,7 @@ import { } from "../../../utils/selection"; import {$isDiagramNode, $openDrawingEditorForNode, showDiagramManagerForInsert} from "../../../utils/diagrams"; import {$createLinkedImageNodeFromImageData, showImageManager} from "../../../utils/images"; -import {$showImageForm, $showLinkForm} from "../forms/objects"; +import {$showDetailsForm, $showImageForm, $showLinkForm} from "../forms/objects"; import {formatCodeBlock} from "../../../utils/formats"; export const link: EditorButtonDefinition = { @@ -216,4 +219,58 @@ export const details: EditorButtonDefinition = { isActive(selection: BaseSelection | null): boolean { return $selectionContainsNodeType(selection, $isDetailsNode); } +} + +export const detailsEditLabel: EditorButtonDefinition = { + label: 'Edit label', + icon: tagIcon, + action(context: EditorUiContext) { + context.editor.getEditorState().read(() => { + const details = $getNodeFromSelection($getSelection(), $isDetailsNode); + if ($isDetailsNode(details)) { + $showDetailsForm(details, context); + } + }) + }, + isActive(selection: BaseSelection | null): boolean { + return false; + } +} + +export const detailsToggle: EditorButtonDefinition = { + label: 'Toggle open/closed', + icon: detailsToggleIcon, + action(context: EditorUiContext) { + context.editor.update(() => { + const details = $getNodeFromSelection($getSelection(), $isDetailsNode); + if ($isDetailsNode(details)) { + details.setOpen(!details.getOpen()); + context.manager.triggerLayoutUpdate(); + } + }) + }, + isActive(selection: BaseSelection | null): boolean { + return false; + } +} + +export const detailsUnwrap: EditorButtonDefinition = { + label: 'Unwrap', + icon: tableDeleteIcon, + action(context: EditorUiContext) { + context.editor.update(() => { + const details = $getNodeFromSelection($getSelection(), $isDetailsNode); + if ($isDetailsNode(details)) { + const children = details.getChildren(); + for (const child of children) { + details.insertBefore(child); + } + details.remove(); + context.manager.triggerLayoutUpdate(); + } + }) + }, + isActive(selection: BaseSelection | null): boolean { + return false; + } } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/forms/objects.ts b/resources/js/wysiwyg/ui/defaults/forms/objects.ts index f00a08bb5..21d333c3a 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/objects.ts @@ -19,6 +19,7 @@ import searchIcon from "@icons/search.svg"; import {showLinkSelector} from "../../../utils/links"; import {LinkField} from "../../framework/blocks/link-field"; import {insertOrUpdateLink} from "../../../utils/formats"; +import {$isDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; export function $showImageForm(image: ImageNode, context: EditorUiContext) { const imageModal: EditorFormModal = context.manager.createModal('image'); @@ -262,4 +263,37 @@ export const media: EditorFormDefinition = { } }, ], +}; + +export function $showDetailsForm(details: DetailsNode|null, context: EditorUiContext) { + const linkModal = context.manager.createModal('details'); + if (!details) { + return; + } + + linkModal.show({ + summary: details.getSummary() + }); +} + +export const details: EditorFormDefinition = { + submitText: 'Save', + async action(formData, context: EditorUiContext) { + context.editor.update(() => { + const node = $getNodeFromSelection($getSelection(), $isDetailsNode); + const summary = (formData.get('summary') || '').toString().trim(); + if ($isDetailsNode(node)) { + node.setSummary(summary); + } + }); + + return true; + }, + fields: [ + { + label: 'Toggle label', + name: 'summary', + type: 'text', + }, + ], }; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/modals.ts b/resources/js/wysiwyg/ui/defaults/modals.ts index c43923778..da3859266 100644 --- a/resources/js/wysiwyg/ui/defaults/modals.ts +++ b/resources/js/wysiwyg/ui/defaults/modals.ts @@ -1,5 +1,5 @@ import {EditorFormModalDefinition} from "../framework/modals"; -import {image, link, media} from "./forms/objects"; +import {details, image, link, media} from "./forms/objects"; import {source} from "./forms/controls"; import {cellProperties, rowProperties, tableProperties} from "./forms/tables"; @@ -32,4 +32,8 @@ export const modals: Record = { title: 'Table Properties', form: tableProperties, }, + details: { + title: 'Edit collapsible block', + form: details, + } }; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/index.ts b/resources/js/wysiwyg/ui/index.ts index 3811f44b9..40df43347 100644 --- a/resources/js/wysiwyg/ui/index.ts +++ b/resources/js/wysiwyg/ui/index.ts @@ -1,6 +1,6 @@ import {LexicalEditor} from "lexical"; import { - getCodeToolbarContent, + getCodeToolbarContent, getDetailsToolbarContent, getImageToolbarContent, getLinkToolbarContent, getMainEditorFullToolbar, getTableToolbarContent @@ -56,7 +56,6 @@ export function buildEditorUI(container: HTMLElement, element: HTMLElement, scro selector: '.editor-code-block-wrap', content: getCodeToolbarContent(), }); - manager.registerContextToolbar('table', { selector: 'td,th', content: getTableToolbarContent(), @@ -64,6 +63,10 @@ export function buildEditorUI(container: HTMLElement, element: HTMLElement, scro return originalTarget.closest('table') as HTMLTableElement; } }); + manager.registerContextToolbar('details', { + selector: 'details', + content: getDetailsToolbarContent(), + }); // Register image decorator listener manager.registerDecoratorType('code', CodeBlockDecorator); diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/toolbars.ts index 886e1394b..1230cbdd2 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/toolbars.ts @@ -68,7 +68,7 @@ import { } from "./defaults/buttons/lists"; import { codeBlock, - details, + details, detailsEditLabel, detailsToggle, detailsUnwrap, diagram, diagramManager, editCodeBlock, horizontalRule, @@ -253,4 +253,12 @@ export function getTableToolbarContent(): EditorUiElement[] { new EditorButton(deleteColumn), ]), ]; +} + +export function getDetailsToolbarContent(): EditorUiElement[] { + return [ + new EditorButton(detailsEditLabel), + new EditorButton(detailsToggle), + new EditorButton(detailsUnwrap), + ]; } \ No newline at end of file From 8486775edf9d7979f9d6f1f555460bf8678d6653 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 16 Dec 2024 14:30:06 +0000 Subject: [PATCH 07/12] Lexical: Added mulitple methods to escape details block Enter on empty last line, or down on last empty line, will focus on the next node after details, or created a new paragraph to focus on if needed. --- .../js/wysiwyg/services/keyboard-handling.ts | 109 +++++++++++++++++- 1 file changed, 106 insertions(+), 3 deletions(-) diff --git a/resources/js/wysiwyg/services/keyboard-handling.ts b/resources/js/wysiwyg/services/keyboard-handling.ts index 6a1345fac..08eed7645 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, + COMMAND_PRIORITY_LOW, KEY_ARROW_DOWN_COMMAND, KEY_BACKSPACE_COMMAND, KEY_DELETE_COMMAND, KEY_ENTER_COMMAND, KEY_TAB_COMMAND, @@ -13,9 +13,10 @@ import { import {$isImageNode} from "@lexical/rich-text/LexicalImageNode"; import {$isMediaNode} from "@lexical/rich-text/LexicalMediaNode"; import {getLastSelection} from "../utils/selection"; -import {$getNearestNodeBlockParent} from "../utils/nodes"; +import {$getNearestNodeBlockParent, $getParentOfType} from "../utils/nodes"; import {$setInsetForSelection} from "../utils/lists"; import {$isListItemNode} from "@lexical/list"; +import {$isDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; function isSingleSelectedNode(nodes: LexicalNode[]): boolean { if (nodes.length === 1) { @@ -28,6 +29,10 @@ function isSingleSelectedNode(nodes: LexicalNode[]): boolean { return false; } +/** + * Delete the current node in the selection if the selection contains a single + * selected node (like image, media etc...). + */ function deleteSingleSelectedNode(editor: LexicalEditor) { const selectionNodes = getLastSelection(editor)?.getNodes() || []; if (isSingleSelectedNode(selectionNodes)) { @@ -37,6 +42,10 @@ function deleteSingleSelectedNode(editor: LexicalEditor) { } } +/** + * Insert a new empty node after the selection if the selection contains a single + * selected node (like image, media etc...). + */ function insertAfterSingleSelectedNode(editor: LexicalEditor, event: KeyboardEvent|null): boolean { const selectionNodes = getLastSelection(editor)?.getNodes() || []; if (isSingleSelectedNode(selectionNodes)) { @@ -58,6 +67,94 @@ function insertAfterSingleSelectedNode(editor: LexicalEditor, event: KeyboardEve return false; } +/** + * 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. + */ +function insertAfterDetails(editor: LexicalEditor, event: KeyboardEvent|null): boolean { + const scenario = getDetailsScenario(editor); + if (scenario === null || scenario.detailsSibling) { + return false; + } + + editor.update(() => { + const newParagraph = $createParagraphNode(); + scenario.parentDetails.insertAfter(newParagraph); + newParagraph.select(); + }); + event?.preventDefault(); + + return true; +} + +/** + * If within a details block, move after it, creating a new node if required, if we're on + * the last empty block element within the details node. + */ +function moveAfterDetailsOnEmptyLine(editor: LexicalEditor, event: KeyboardEvent|null): boolean { + const scenario = getDetailsScenario(editor); + if (scenario === null) { + return false; + } + + if (scenario.parentBlock.getTextContent() !== '') { + return false; + } + + event?.preventDefault() + + const nextSibling = scenario.parentDetails.getNextSibling(); + editor.update(() => { + if (nextSibling) { + nextSibling.selectStart(); + } else { + const newParagraph = $createParagraphNode(); + scenario.parentDetails.insertAfter(newParagraph); + newParagraph.select(); + } + scenario.parentBlock.remove(); + }); + + return true; +} + +/** + * Get the common nodes used for a details node scenario, relative to current selection. + * Returns null if not found, or if the parent block is not the last in the parent details node. + */ +function getDetailsScenario(editor: LexicalEditor): { + parentDetails: DetailsNode; + parentBlock: LexicalNode; + detailsSibling: LexicalNode | null +} | null { + const selection = getLastSelection(editor); + const firstNode = selection?.getNodes()[0]; + if (!firstNode) { + return null; + } + + const block = $getNearestNodeBlockParent(firstNode); + const details = $getParentOfType(firstNode, $isDetailsNode); + if (!$isDetailsNode(details) || block === null) { + return null; + } + + if (block.getKey() !== details.getLastChild()?.getKey()) { + return null; + } + + const nextSibling = details.getNextSibling(); + return { + parentDetails: details, + parentBlock: block, + detailsSibling: nextSibling, + } +} + +/** + * Inset the nodes within selection when a range of nodes is selected + * or if a list node is selected. + */ function handleInsetOnTab(editor: LexicalEditor, event: KeyboardEvent|null): boolean { const change = event?.shiftKey ? -40 : 40; const selection = $getSelection(); @@ -85,17 +182,23 @@ export function registerKeyboardHandling(context: EditorUiContext): () => void { }, COMMAND_PRIORITY_LOW); const unregisterEnter = context.editor.registerCommand(KEY_ENTER_COMMAND, (event): boolean => { - return insertAfterSingleSelectedNode(context.editor, event); + return insertAfterSingleSelectedNode(context.editor, event) + || moveAfterDetailsOnEmptyLine(context.editor, event); }, COMMAND_PRIORITY_LOW); const unregisterTab = context.editor.registerCommand(KEY_TAB_COMMAND, (event): boolean => { return handleInsetOnTab(context.editor, event); }, COMMAND_PRIORITY_LOW); + const unregisterDown = context.editor.registerCommand(KEY_ARROW_DOWN_COMMAND, (event): boolean => { + return insertAfterDetails(context.editor, event); + }, COMMAND_PRIORITY_LOW); + return () => { unregisterBackspace(); unregisterDelete(); unregisterEnter(); unregisterTab(); + unregisterDown(); }; } \ No newline at end of file From e50cd33277026a93983b696014c6468a4d34fe6f Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 16 Dec 2024 16:24:47 +0000 Subject: [PATCH 08/12] 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. --- dev/build/svg-blank-transform.js | 14 +++ jest.config.ts | 1 + resources/js/wysiwyg/index.ts | 36 +------ .../js/wysiwyg/lexical/core/LexicalEditor.ts | 8 ++ .../lexical/core/__tests__/utils/index.ts | 46 +++++++++ .../unit/LexicalHeadlessEditor.test.ts | 1 - .../__tests__/unit/LexicalDetailsNode.test.ts | 40 ++++++++ .../__tests__/keyboard-handling.test.ts | 95 +++++++++++++++++++ .../js/wysiwyg/services/common-events.ts | 14 ++- resources/js/wysiwyg/ui/framework/manager.ts | 18 +++- 10 files changed, 239 insertions(+), 34 deletions(-) create mode 100644 dev/build/svg-blank-transform.js create mode 100644 resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalDetailsNode.test.ts create mode 100644 resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts 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) { From ace8af077dfa5173c24cdf8b50eb82ccbd1dbf7e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 17 Dec 2024 14:44:10 +0000 Subject: [PATCH 09/12] Lexical: Improved list tab handling, Improved test utils - Made tab work on empty list items - Improved select preservation on single list item tab - Altered test context creation for more standard testing --- .../lexical/core/__tests__/utils/index.ts | 32 +++- .../__tests__/keyboard-handling.test.ts | 164 +++++++++++------- .../js/wysiwyg/services/keyboard-handling.ts | 11 +- resources/js/wysiwyg/utils/lists.ts | 17 +- 4 files changed, 154 insertions(+), 70 deletions(-) diff --git a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts index 2fc57315b..7815d4f0d 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts @@ -472,16 +472,34 @@ export function createTestHeadlessEditor( }); } -export function createTestContext(env: TestEnv): EditorUiContext { +export function createTestContext(): EditorUiContext { + + const container = document.createElement('div'); + document.body.appendChild(container); + + const scrollWrap = document.createElement('div'); + const editorDOM = document.createElement('div'); + editorDOM.setAttribute('contenteditable', 'true'); + + scrollWrap.append(editorDOM); + container.append(scrollWrap); + + const editor = createTestEditor({ + namespace: 'testing', + theme: {}, + }); + + editor.setRootElement(editorDOM); + const context = { - containerDOM: document.createElement('div'), - editor: env.editor, - editorDOM: document.createElement('div'), + containerDOM: container, + editor: editor, + editorDOM: editorDOM, error(text: string | Error): void { }, manager: new EditorUIManager(), options: {}, - scrollDOM: document.createElement('div'), + scrollDOM: scrollWrap, translate(text: string): string { return ""; } @@ -492,6 +510,10 @@ export function createTestContext(env: TestEnv): EditorUiContext { return context; } +export function destroyFromContext(context: EditorUiContext) { + context.containerDOM.remove(); +} + export function $assertRangeSelection(selection: unknown): RangeSelection { if (!$isRangeSelection(selection)) { throw new Error(`Expected RangeSelection, got ${selection}`); diff --git a/resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts b/resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts index 14a1ea973..0ab6935fb 100644 --- a/resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts +++ b/resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts @@ -1,95 +1,135 @@ import { - createTestContext, + createTestContext, destroyFromContext, dispatchKeydownEventForNode, dispatchKeydownEventForSelectedNode, - initializeUnitTest } from "lexical/__tests__/utils"; import { $createParagraphNode, $createTextNode, - $getRoot, LexicalNode, - ParagraphNode, + $getRoot, $getSelection, LexicalEditor, LexicalNode, + ParagraphNode, TextNode, } from "lexical"; import {$createDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; 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"; describe('Keyboard-handling service tests', () => { - initializeUnitTest((testEnv) => { - test('Details: down key on last lines creates new sibling node', () => { - const {editor} = testEnv; + let context!: EditorUiContext; + let editor!: LexicalEditor; - registerRichText(editor); - registerKeyboardHandling(createTestContext(testEnv)); + beforeEach(() => { + context = createTestContext(); + editor = context.editor; + registerRichText(editor); + registerKeyboardHandling(context); + }); - let lastRootChild!: LexicalNode|null; - let detailsPara!: ParagraphNode; + afterEach(() => { + destroyFromContext(context); + }); - editor.updateAndCommit(() => { - const root = $getRoot() - const details = $createDetailsNode(); - detailsPara = $createParagraphNode(); - details.append(detailsPara); - $getRoot().append(details); - detailsPara.select(); + test('Details: down key on last lines creates new sibling node', () => { + let lastRootChild!: LexicalNode|null; + let detailsPara!: ParagraphNode; - lastRootChild = root.getLastChild(); - }); + editor.updateAndCommit(() => { + const root = $getRoot() + const details = $createDetailsNode(); + detailsPara = $createParagraphNode(); + details.append(detailsPara); + $getRoot().append(details); + detailsPara.select(); - expect(lastRootChild).toBeInstanceOf(DetailsNode); - - dispatchKeydownEventForNode(detailsPara, editor, 'ArrowDown'); - editor.commitUpdates(); - - editor.getEditorState().read(() => { - lastRootChild = $getRoot().getLastChild(); - }); - - expect(lastRootChild).toBeInstanceOf(ParagraphNode); + lastRootChild = root.getLastChild(); }); - test('Details: enter on last empy block creates new sibling node', () => { - const {editor} = testEnv; + expect(lastRootChild).toBeInstanceOf(DetailsNode); - registerRichText(editor); - registerKeyboardHandling(createTestContext(testEnv)); + dispatchKeydownEventForNode(detailsPara, editor, 'ArrowDown'); + editor.commitUpdates(); - let lastRootChild!: LexicalNode|null; - let detailsPara!: ParagraphNode; + editor.getEditorState().read(() => { + lastRootChild = $getRoot().getLastChild(); + }); - 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(); + expect(lastRootChild).toBeInstanceOf(ParagraphNode); + }); - lastRootChild = root.getLastChild(); - }); + test('Details: enter on last empty block creates new sibling node', () => { + registerRichText(editor); - expect(lastRootChild).toBeInstanceOf(DetailsNode); + let lastRootChild!: LexicalNode|null; + let detailsPara!: ParagraphNode; - dispatchKeydownEventForNode(detailsPara, editor, 'Enter'); - editor.commitUpdates(); + 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(); - dispatchKeydownEventForSelectedNode(editor, 'Enter'); - editor.commitUpdates(); + lastRootChild = root.getLastChild(); + }); - let detailsChildren!: LexicalNode[]; - let lastDetailsText!: string; + expect(lastRootChild).toBeInstanceOf(DetailsNode); - editor.getEditorState().read(() => { - detailsChildren = (lastRootChild as DetailsNode).getChildren(); - lastRootChild = $getRoot().getLastChild(); - lastDetailsText = detailsChildren[0].getTextContent(); - }); + dispatchKeydownEventForNode(detailsPara, editor, 'Enter'); + editor.commitUpdates(); - expect(lastRootChild).toBeInstanceOf(ParagraphNode); - expect(detailsChildren).toHaveLength(1); - expect(lastDetailsText).toBe('Hello!'); + 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!'); + }); + + test('Lists: tab on empty list item insets item', () => { + + let list!: ListNode; + let listItemB!: ListItemNode; + + editor.updateAndCommit(() => { + const root = $getRoot(); + list = $createListNode('bullet'); + const listItemA = $createListItemNode(); + listItemA.append($createTextNode('Hello!')); + listItemB = $createListItemNode(); + list.append(listItemA, listItemB); + root.append(list); + listItemB.selectStart(); + }); + + dispatchKeydownEventForNode(listItemB, editor, 'Tab'); + editor.commitUpdates(); + + editor.getEditorState().read(() => { + const list = $getRoot().getChildren()[0] as ListNode; + const listChild = list.getChildren()[0] as ListItemNode; + const children = listChild.getChildren(); + expect(children).toHaveLength(2); + expect(children[0]).toBeInstanceOf(TextNode); + expect(children[0].getTextContent()).toBe('Hello!'); + expect(children[1]).toBeInstanceOf(ListNode); + + const innerList = children[1] as ListNode; + const selectedNode = $getSelection()?.getNodes()[0]; + expect(selectedNode).toBeInstanceOf(ListItemNode); + expect(selectedNode?.getKey()).toBe(innerList.getChildren()[0].getKey()); }); }); }); \ 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 08eed7645..ff6117b2b 100644 --- a/resources/js/wysiwyg/services/keyboard-handling.ts +++ b/resources/js/wysiwyg/services/keyboard-handling.ts @@ -151,6 +151,15 @@ function getDetailsScenario(editor: LexicalEditor): { } } +function $isSingleListItem(nodes: LexicalNode[]): boolean { + if (nodes.length !== 1) { + return false; + } + + const node = nodes[0]; + return $isListItemNode(node) || $isListItemNode(node.getParent()); +} + /** * Inset the nodes within selection when a range of nodes is selected * or if a list node is selected. @@ -159,7 +168,7 @@ function handleInsetOnTab(editor: LexicalEditor, event: KeyboardEvent|null): boo const change = event?.shiftKey ? -40 : 40; const selection = $getSelection(); const nodes = selection?.getNodes() || []; - if (nodes.length > 1 || (nodes.length === 1 && $isListItemNode(nodes[0].getParent()))) { + if (nodes.length > 1 || $isSingleListItem(nodes)) { editor.update(() => { $setInsetForSelection(editor, change); }); diff --git a/resources/js/wysiwyg/utils/lists.ts b/resources/js/wysiwyg/utils/lists.ts index 646f341c2..2fc1c5f6b 100644 --- a/resources/js/wysiwyg/utils/lists.ts +++ b/resources/js/wysiwyg/utils/lists.ts @@ -1,4 +1,4 @@ -import {$getSelection, BaseSelection, LexicalEditor} from "lexical"; +import {$createTextNode, $getSelection, BaseSelection, LexicalEditor, TextNode} from "lexical"; import {$getBlockElementNodesInSelection, $selectNodes, $toggleSelection} from "./selection"; import {nodeHasInset} from "./nodes"; import {$createListItemNode, $createListNode, $isListItemNode, $isListNode, ListItemNode} from "@lexical/list"; @@ -93,6 +93,7 @@ function $reduceDedupeListItems(listItems: (ListItemNode|null)[]): ListItemNode[ export function $setInsetForSelection(editor: LexicalEditor, change: number): void { const selection = $getSelection(); + const selectionBounds = selection?.getStartEndPoints(); const listItemsInSelection = getListItemsForSelection(selection); const isListSelection = listItemsInSelection.length > 0 && !listItemsInSelection.includes(null); @@ -110,7 +111,19 @@ export function $setInsetForSelection(editor: LexicalEditor, change: number): vo alteredListItems.reverse(); } - $selectNodes(alteredListItems); + if (alteredListItems.length === 1 && selectionBounds) { + // Retain selection range if moving just one item + const listItem = alteredListItems[0] as ListItemNode; + let child = listItem.getChildren()[0] as TextNode; + if (!child) { + child = $createTextNode(''); + listItem.append(child); + } + child.select(selectionBounds[0].offset, selectionBounds[1].offset); + } else { + $selectNodes(alteredListItems); + } + return; } From fca8f928a380350a1a6441530a722f4578f8ae14 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 17 Dec 2024 16:50:03 +0000 Subject: [PATCH 10/12] Lexical: Aligned new empty item behaviour for nested lists - Makes enter on empty nested list item un-nest instead of just creating new list items. - Also updated existing lists tests to use newer helper setup. --- .../lexical/core/__tests__/utils/index.ts | 1 + .../lexical/list/LexicalListItemNode.ts | 11 +- .../unit/LexicalListItemNode.test.ts | 1235 +++++++++-------- .../__tests__/keyboard-handling.test.ts | 5 - 4 files changed, 635 insertions(+), 617 deletions(-) diff --git a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts index 7815d4f0d..d90853b7c 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts @@ -776,6 +776,7 @@ export function dispatchKeydownEventForNode(node: LexicalNode, editor: LexicalEd key, }); nodeDomEl?.dispatchEvent(event); + editor.commitUpdates(); } export function dispatchKeydownEventForSelectedNode(editor: LexicalEditor, key: string) { diff --git a/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts b/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts index 33b021298..239c49a8c 100644 --- a/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts +++ b/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts @@ -271,11 +271,18 @@ export class ListItemNode extends ElementNode { insertNewAfter( _: RangeSelection, restoreSelection = true, - ): ListItemNode | ParagraphNode { + ): ListItemNode | ParagraphNode | null { if (this.getTextContent().trim() === '' && this.isLastChild()) { const list = this.getParentOrThrow(); - if (!$isListItemNode(list.getParent())) { + const parentListItem = list.getParent(); + if ($isListItemNode(parentListItem)) { + // Un-nest list item if empty nested item + parentListItem.insertAfter(this); + this.selectStart(); + return null; + } else { + // Insert empty paragraph after list if adding after last empty child const paragraph = $createParagraphNode(); list.insertAfter(paragraph, restoreSelection); this.remove(); diff --git a/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts b/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts index 567714bcd..10ff0fc66 100644 --- a/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts +++ b/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts @@ -9,13 +9,13 @@ import { $createParagraphNode, $createRangeSelection, - $getRoot, + $getRoot, LexicalEditor, TextNode, } from 'lexical'; import { + createTestContext, destroyFromContext, expectHtmlToBeEqual, html, - initializeUnitTest, } from 'lexical/__tests__/utils'; import { @@ -24,49 +24,49 @@ import { ListItemNode, ListNode, } from '../..'; - -const editorConfig = Object.freeze({ - namespace: '', - theme: { - list: { - listitem: 'my-listItem-item-class', - nested: { - listitem: 'my-nested-list-listItem-class', - }, - }, - }, -}); +import {EditorUiContext} from "../../../../ui/framework/core"; +import {$htmlToBlockNodes} from "../../../../utils/nodes"; describe('LexicalListItemNode tests', () => { - initializeUnitTest((testEnv) => { - test('ListItemNode.constructor', async () => { - const {editor} = testEnv; - await editor.update(() => { - const listItemNode = new ListItemNode(); + let context!: EditorUiContext; + let editor!: LexicalEditor; - expect(listItemNode.getType()).toBe('listitem'); + beforeEach(() => { + context = createTestContext(); + editor = context.editor; + }); - expect(listItemNode.getTextContent()).toBe(''); - }); + afterEach(() => { + destroyFromContext(context); + }); - expect(() => new ListItemNode()).toThrow(); + test('ListItemNode.constructor', async () => { + + await editor.update(() => { + const listItemNode = new ListItemNode(); + + expect(listItemNode.getType()).toBe('listitem'); + + expect(listItemNode.getTextContent()).toBe(''); }); - test('ListItemNode.createDOM()', async () => { - const {editor} = testEnv; + expect(() => new ListItemNode()).toThrow(); + }); - await editor.update(() => { - const listItemNode = new ListItemNode(); + test('ListItemNode.createDOM()', async () => { - expectHtmlToBeEqual( - listItemNode.createDOM(editorConfig).outerHTML, + await editor.update(() => { + const listItemNode = new ListItemNode(); + + expectHtmlToBeEqual( + listItemNode.createDOM(editor._config).outerHTML, html`
  • `, - ); + ); - expectHtmlToBeEqual( + expectHtmlToBeEqual( listItemNode.createDOM({ namespace: '', theme: {}, @@ -74,108 +74,105 @@ describe('LexicalListItemNode tests', () => { html`
  • `, + ); + }); + }); + + describe('ListItemNode.updateDOM()', () => { + test('base', async () => { + + await editor.update(() => { + const listItemNode = new ListItemNode(); + + const domElement = listItemNode.createDOM(editor._config); + + expectHtmlToBeEqual( + domElement.outerHTML, + html` +
  • + `, + ); + const newListItemNode = new ListItemNode(); + + const result = newListItemNode.updateDOM( + listItemNode, + domElement, + editor._config, + ); + + expect(result).toBe(false); + + expectHtmlToBeEqual( + domElement.outerHTML, + html` +
  • + `, ); }); }); - describe('ListItemNode.updateDOM()', () => { - test('base', async () => { - const {editor} = testEnv; + test('nested list', async () => { - await editor.update(() => { - const listItemNode = new ListItemNode(); + await editor.update(() => { + const parentListNode = new ListNode('bullet', 1); + const parentlistItemNode = new ListItemNode(); - const domElement = listItemNode.createDOM(editorConfig); + parentListNode.append(parentlistItemNode); + const domElement = parentlistItemNode.createDOM(editor._config); - expectHtmlToBeEqual( + expectHtmlToBeEqual( domElement.outerHTML, html`
  • `, - ); - const newListItemNode = new ListItemNode(); - - const result = newListItemNode.updateDOM( - listItemNode, - domElement, - editorConfig, - ); - - expect(result).toBe(false); - - expectHtmlToBeEqual( - domElement.outerHTML, - html` -
  • - `, - ); - }); - }); - - test('nested list', async () => { - const {editor} = testEnv; - - await editor.update(() => { - const parentListNode = new ListNode('bullet', 1); - const parentlistItemNode = new ListItemNode(); - - parentListNode.append(parentlistItemNode); - const domElement = parentlistItemNode.createDOM(editorConfig); - - expectHtmlToBeEqual( - domElement.outerHTML, - html` -
  • - `, - ); - const nestedListNode = new ListNode('bullet', 1); - nestedListNode.append(new ListItemNode()); - parentlistItemNode.append(nestedListNode); - const result = parentlistItemNode.updateDOM( + ); + const nestedListNode = new ListNode('bullet', 1); + nestedListNode.append(new ListItemNode()); + parentlistItemNode.append(nestedListNode); + const result = parentlistItemNode.updateDOM( parentlistItemNode, domElement, - editorConfig, - ); + editor._config, + ); - expect(result).toBe(false); + expect(result).toBe(false); - expectHtmlToBeEqual( + expectHtmlToBeEqual( domElement.outerHTML, html`
  • `, - ); - }); + ); }); }); + }); - describe('ListItemNode.replace()', () => { - let listNode: ListNode; - let listItemNode1: ListItemNode; - let listItemNode2: ListItemNode; - let listItemNode3: ListItemNode; + describe('ListItemNode.replace()', () => { + let listNode: ListNode; + let listItemNode1: ListItemNode; + let listItemNode2: ListItemNode; + let listItemNode3: ListItemNode; - beforeEach(async () => { - const {editor} = testEnv; + beforeEach(async () => { - await editor.update(() => { - const root = $getRoot(); - listNode = new ListNode('bullet', 1); - listItemNode1 = new ListItemNode(); + await editor.update(() => { + const root = $getRoot(); + listNode = new ListNode('bullet', 1); + listItemNode1 = new ListItemNode(); - listItemNode1.append(new TextNode('one')); - listItemNode2 = new ListItemNode(); + listItemNode1.append(new TextNode('one')); + listItemNode2 = new ListItemNode(); - listItemNode2.append(new TextNode('two')); - listItemNode3 = new ListItemNode(); + listItemNode2.append(new TextNode('two')); + listItemNode3 = new ListItemNode(); - listItemNode3.append(new TextNode('three')); - root.append(listNode); - listNode.append(listItemNode1, listItemNode2, listItemNode3); - }); + listItemNode3.append(new TextNode('three')); + root.append(listNode); + listNode.append(listItemNode1, listItemNode2, listItemNode3); + }); - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
    {
    `, - ); + ); + }); + + test('another list item node', async () => { + + await editor.update(() => { + const newListItemNode = new ListItemNode(); + + newListItemNode.append(new TextNode('bar')); + listItemNode1.replace(newListItemNode); }); - test('another list item node', async () => { - const {editor} = testEnv; - - await editor.update(() => { - const newListItemNode = new ListItemNode(); - - newListItemNode.append(new TextNode('bar')); - listItemNode1.replace(newListItemNode); - }); - - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
    {
    `, - ); + ); + }); + + test('first list item with a non list item node', async () => { + + await editor.update(() => { + return; }); - test('first list item with a non list item node', async () => { - const {editor} = testEnv; - - await editor.update(() => { - return; - }); - - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
    {
    `, - ); + ); - await editor.update(() => { - const paragraphNode = $createParagraphNode(); - listItemNode1.replace(paragraphNode); - }); + await editor.update(() => { + const paragraphNode = $createParagraphNode(); + listItemNode1.replace(paragraphNode); + }); - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
    {
    `, - ); - }); - - test('last list item with a non list item node', async () => { - const {editor} = testEnv; - - await editor.update(() => { - const paragraphNode = $createParagraphNode(); - listItemNode3.replace(paragraphNode); - }); - - expectHtmlToBeEqual( - testEnv.outerHTML, - html` -
    -
      -
    • - one -
    • -
    • - two -
    • -
    -


    -
    - `, - ); - }); - - test('middle list item with a non list item node', async () => { - const {editor} = testEnv; - - await editor.update(() => { - const paragraphNode = $createParagraphNode(); - listItemNode2.replace(paragraphNode); - }); - - expectHtmlToBeEqual( - testEnv.outerHTML, - html` -
    -
      -
    • - one -
    • -
    -


    -
      -
    • - three -
    • -
    -
    - `, - ); - }); - - test('the only list item with a non list item node', async () => { - const {editor} = testEnv; - - await editor.update(() => { - listItemNode2.remove(); - listItemNode3.remove(); - }); - - expectHtmlToBeEqual( - testEnv.outerHTML, - html` -
    -
      -
    • - one -
    • -
    -
    - `, - ); - - await editor.update(() => { - const paragraphNode = $createParagraphNode(); - listItemNode1.replace(paragraphNode); - }); - - expectHtmlToBeEqual( - testEnv.outerHTML, - html` -
    -


    -
    - `, - ); - }); + ); }); - describe('ListItemNode.remove()', () => { - // - A - // - x - // - B - test('siblings are not nested', async () => { - const {editor} = testEnv; - let x: ListItemNode; + test('last list item with a non list item node', async () => { - await editor.update(() => { - const root = $getRoot(); - const parent = new ListNode('bullet', 1); + await editor.update(() => { + const paragraphNode = $createParagraphNode(); + listItemNode3.replace(paragraphNode); + }); - const A_listItem = new ListItemNode(); - A_listItem.append(new TextNode('A')); + expectHtmlToBeEqual( + context.editorDOM.outerHTML, + html` +
    +
      +
    • + one +
    • +
    • + two +
    • +
    +


    +
    + `, + ); + }); - x = new ListItemNode(); - x.append(new TextNode('x')); + test('middle list item with a non list item node', async () => { - const B_listItem = new ListItemNode(); - B_listItem.append(new TextNode('B')); + await editor.update(() => { + const paragraphNode = $createParagraphNode(); + listItemNode2.replace(paragraphNode); + }); - parent.append(A_listItem, x, B_listItem); - root.append(parent); - }); + expectHtmlToBeEqual( + context.editorDOM.outerHTML, + html` +
    +
      +
    • + one +
    • +
    +


    +
      +
    • + three +
    • +
    +
    + `, + ); + }); - expectHtmlToBeEqual( - testEnv.outerHTML, + test('the only list item with a non list item node', async () => { + + await editor.update(() => { + listItemNode2.remove(); + listItemNode3.remove(); + }); + + expectHtmlToBeEqual( + context.editorDOM.outerHTML, + html` +
    +
      +
    • + one +
    • +
    +
    + `, + ); + + await editor.update(() => { + const paragraphNode = $createParagraphNode(); + listItemNode1.replace(paragraphNode); + }); + + expectHtmlToBeEqual( + context.editorDOM.outerHTML, + html` +
    +


    +
    + `, + ); + }); + }); + + describe('ListItemNode.remove()', () => { + // - A + // - x + // - B + test('siblings are not nested', async () => { + let x: ListItemNode; + + await editor.update(() => { + const root = $getRoot(); + const parent = new ListNode('bullet', 1); + + const A_listItem = new ListItemNode(); + A_listItem.append(new TextNode('A')); + + x = new ListItemNode(); + x.append(new TextNode('x')); + + const B_listItem = new ListItemNode(); + B_listItem.append(new TextNode('B')); + + parent.append(A_listItem, x, B_listItem); + root.append(parent); + }); + + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
    {
    `, - ); + ); - await editor.update(() => x.remove()); + await editor.update(() => x.remove()); - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
    {
    `, - ); + ); + }); + + // - A + // - x + // - B + test('the previous sibling is nested', async () => { + let x: ListItemNode; + + await editor.update(() => { + const root = $getRoot(); + const parent = new ListNode('bullet', 1); + + const A_listItem = new ListItemNode(); + const A_nestedList = new ListNode('bullet', 1); + const A_nestedListItem = new ListItemNode(); + A_listItem.append(A_nestedList); + A_nestedList.append(A_nestedListItem); + A_nestedListItem.append(new TextNode('A')); + + x = new ListItemNode(); + x.append(new TextNode('x')); + + const B_listItem = new ListItemNode(); + B_listItem.append(new TextNode('B')); + + parent.append(A_listItem, x, B_listItem); + root.append(parent); }); - // - A - // - x - // - B - test('the previous sibling is nested', async () => { - const {editor} = testEnv; - let x: ListItemNode; - - await editor.update(() => { - const root = $getRoot(); - const parent = new ListNode('bullet', 1); - - const A_listItem = new ListItemNode(); - const A_nestedList = new ListNode('bullet', 1); - const A_nestedListItem = new ListItemNode(); - A_listItem.append(A_nestedList); - A_nestedList.append(A_nestedListItem); - A_nestedListItem.append(new TextNode('A')); - - x = new ListItemNode(); - x.append(new TextNode('x')); - - const B_listItem = new ListItemNode(); - B_listItem.append(new TextNode('B')); - - parent.append(A_listItem, x, B_listItem); - root.append(parent); - }); - - expectHtmlToBeEqual( - testEnv.innerHTML, + expectHtmlToBeEqual( + context.editorDOM.innerHTML, html`
    • @@ -504,12 +494,12 @@ describe('LexicalListItemNode tests', () => {
    `, - ); + ); - await editor.update(() => x.remove()); + await editor.update(() => x.remove()); - expectHtmlToBeEqual( - testEnv.innerHTML, + expectHtmlToBeEqual( + context.editorDOM.innerHTML, html`
    • @@ -524,39 +514,38 @@ describe('LexicalListItemNode tests', () => {
    `, - ); + ); + }); + + // - A + // - x + // - B + test('the next sibling is nested', async () => { + let x: ListItemNode; + + await editor.update(() => { + const root = $getRoot(); + const parent = new ListNode('bullet', 1); + + const A_listItem = new ListItemNode(); + A_listItem.append(new TextNode('A')); + + x = new ListItemNode(); + x.append(new TextNode('x')); + + const B_listItem = new ListItemNode(); + const B_nestedList = new ListNode('bullet', 1); + const B_nestedListItem = new ListItemNode(); + B_listItem.append(B_nestedList); + B_nestedList.append(B_nestedListItem); + B_nestedListItem.append(new TextNode('B')); + + parent.append(A_listItem, x, B_listItem); + root.append(parent); }); - // - A - // - x - // - B - test('the next sibling is nested', async () => { - const {editor} = testEnv; - let x: ListItemNode; - - await editor.update(() => { - const root = $getRoot(); - const parent = new ListNode('bullet', 1); - - const A_listItem = new ListItemNode(); - A_listItem.append(new TextNode('A')); - - x = new ListItemNode(); - x.append(new TextNode('x')); - - const B_listItem = new ListItemNode(); - const B_nestedList = new ListNode('bullet', 1); - const B_nestedListItem = new ListItemNode(); - B_listItem.append(B_nestedList); - B_nestedList.append(B_nestedListItem); - B_nestedListItem.append(new TextNode('B')); - - parent.append(A_listItem, x, B_listItem); - root.append(parent); - }); - - expectHtmlToBeEqual( - testEnv.innerHTML, + expectHtmlToBeEqual( + context.editorDOM.innerHTML, html`
    • @@ -574,12 +563,12 @@ describe('LexicalListItemNode tests', () => {
    `, - ); + ); - await editor.update(() => x.remove()); + await editor.update(() => x.remove()); - expectHtmlToBeEqual( - testEnv.innerHTML, + expectHtmlToBeEqual( + context.editorDOM.innerHTML, html`
    • @@ -594,43 +583,42 @@ describe('LexicalListItemNode tests', () => {
    `, - ); + ); + }); + + // - A + // - x + // - B + test('both siblings are nested', async () => { + let x: ListItemNode; + + await editor.update(() => { + const root = $getRoot(); + const parent = new ListNode('bullet', 1); + + const A_listItem = new ListItemNode(); + const A_nestedList = new ListNode('bullet', 1); + const A_nestedListItem = new ListItemNode(); + A_listItem.append(A_nestedList); + A_nestedList.append(A_nestedListItem); + A_nestedListItem.append(new TextNode('A')); + + x = new ListItemNode(); + x.append(new TextNode('x')); + + const B_listItem = new ListItemNode(); + const B_nestedList = new ListNode('bullet', 1); + const B_nestedListItem = new ListItemNode(); + B_listItem.append(B_nestedList); + B_nestedList.append(B_nestedListItem); + B_nestedListItem.append(new TextNode('B')); + + parent.append(A_listItem, x, B_listItem); + root.append(parent); }); - // - A - // - x - // - B - test('both siblings are nested', async () => { - const {editor} = testEnv; - let x: ListItemNode; - - await editor.update(() => { - const root = $getRoot(); - const parent = new ListNode('bullet', 1); - - const A_listItem = new ListItemNode(); - const A_nestedList = new ListNode('bullet', 1); - const A_nestedListItem = new ListItemNode(); - A_listItem.append(A_nestedList); - A_nestedList.append(A_nestedListItem); - A_nestedListItem.append(new TextNode('A')); - - x = new ListItemNode(); - x.append(new TextNode('x')); - - const B_listItem = new ListItemNode(); - const B_nestedList = new ListNode('bullet', 1); - const B_nestedListItem = new ListItemNode(); - B_listItem.append(B_nestedList); - B_nestedList.append(B_nestedListItem); - B_nestedListItem.append(new TextNode('B')); - - parent.append(A_listItem, x, B_listItem); - root.append(parent); - }); - - expectHtmlToBeEqual( - testEnv.innerHTML, + expectHtmlToBeEqual( + context.editorDOM.innerHTML, html`
    • @@ -652,12 +640,12 @@ describe('LexicalListItemNode tests', () => {
    `, - ); + ); - await editor.update(() => x.remove()); + await editor.update(() => x.remove()); - expectHtmlToBeEqual( - testEnv.innerHTML, + expectHtmlToBeEqual( + context.editorDOM.innerHTML, html`
    • @@ -672,51 +660,50 @@ describe('LexicalListItemNode tests', () => {
    `, - ); + ); + }); + + // - A1 + // - A2 + // - x + // - B + test('the previous sibling is nested deeper than the next sibling', async () => { + let x: ListItemNode; + + await editor.update(() => { + const root = $getRoot(); + const parent = new ListNode('bullet', 1); + + const A_listItem = new ListItemNode(); + const A_nestedList = new ListNode('bullet', 1); + const A_nestedListItem1 = new ListItemNode(); + const A_nestedListItem2 = new ListItemNode(); + const A_deeplyNestedList = new ListNode('bullet', 1); + const A_deeplyNestedListItem = new ListItemNode(); + A_listItem.append(A_nestedList); + A_nestedList.append(A_nestedListItem1); + A_nestedList.append(A_nestedListItem2); + A_nestedListItem1.append(new TextNode('A1')); + A_nestedListItem2.append(A_deeplyNestedList); + A_deeplyNestedList.append(A_deeplyNestedListItem); + A_deeplyNestedListItem.append(new TextNode('A2')); + + x = new ListItemNode(); + x.append(new TextNode('x')); + + const B_listItem = new ListItemNode(); + const B_nestedList = new ListNode('bullet', 1); + const B_nestedlistItem = new ListItemNode(); + B_listItem.append(B_nestedList); + B_nestedList.append(B_nestedlistItem); + B_nestedlistItem.append(new TextNode('B')); + + parent.append(A_listItem, x, B_listItem); + root.append(parent); }); - // - A1 - // - A2 - // - x - // - B - test('the previous sibling is nested deeper than the next sibling', async () => { - const {editor} = testEnv; - let x: ListItemNode; - - await editor.update(() => { - const root = $getRoot(); - const parent = new ListNode('bullet', 1); - - const A_listItem = new ListItemNode(); - const A_nestedList = new ListNode('bullet', 1); - const A_nestedListItem1 = new ListItemNode(); - const A_nestedListItem2 = new ListItemNode(); - const A_deeplyNestedList = new ListNode('bullet', 1); - const A_deeplyNestedListItem = new ListItemNode(); - A_listItem.append(A_nestedList); - A_nestedList.append(A_nestedListItem1); - A_nestedList.append(A_nestedListItem2); - A_nestedListItem1.append(new TextNode('A1')); - A_nestedListItem2.append(A_deeplyNestedList); - A_deeplyNestedList.append(A_deeplyNestedListItem); - A_deeplyNestedListItem.append(new TextNode('A2')); - - x = new ListItemNode(); - x.append(new TextNode('x')); - - const B_listItem = new ListItemNode(); - const B_nestedList = new ListNode('bullet', 1); - const B_nestedlistItem = new ListItemNode(); - B_listItem.append(B_nestedList); - B_nestedList.append(B_nestedlistItem); - B_nestedlistItem.append(new TextNode('B')); - - parent.append(A_listItem, x, B_listItem); - root.append(parent); - }); - - expectHtmlToBeEqual( - testEnv.innerHTML, + expectHtmlToBeEqual( + context.editorDOM.innerHTML, html`
    • @@ -745,12 +732,12 @@ describe('LexicalListItemNode tests', () => {
    `, - ); + ); - await editor.update(() => x.remove()); + await editor.update(() => x.remove()); - expectHtmlToBeEqual( - testEnv.innerHTML, + expectHtmlToBeEqual( + context.editorDOM.innerHTML, html`
    • @@ -772,51 +759,50 @@ describe('LexicalListItemNode tests', () => {
    `, - ); + ); + }); + + // - A + // - x + // - B1 + // - B2 + test('the next sibling is nested deeper than the previous sibling', async () => { + let x: ListItemNode; + + await editor.update(() => { + const root = $getRoot(); + const parent = new ListNode('bullet', 1); + + const A_listItem = new ListItemNode(); + const A_nestedList = new ListNode('bullet', 1); + const A_nestedListItem = new ListItemNode(); + A_listItem.append(A_nestedList); + A_nestedList.append(A_nestedListItem); + A_nestedListItem.append(new TextNode('A')); + + x = new ListItemNode(); + x.append(new TextNode('x')); + + const B_listItem = new ListItemNode(); + const B_nestedList = new ListNode('bullet', 1); + const B_nestedListItem1 = new ListItemNode(); + const B_nestedListItem2 = new ListItemNode(); + const B_deeplyNestedList = new ListNode('bullet', 1); + const B_deeplyNestedListItem = new ListItemNode(); + B_listItem.append(B_nestedList); + B_nestedList.append(B_nestedListItem1); + B_nestedList.append(B_nestedListItem2); + B_nestedListItem1.append(B_deeplyNestedList); + B_nestedListItem2.append(new TextNode('B2')); + B_deeplyNestedList.append(B_deeplyNestedListItem); + B_deeplyNestedListItem.append(new TextNode('B1')); + + parent.append(A_listItem, x, B_listItem); + root.append(parent); }); - // - A - // - x - // - B1 - // - B2 - test('the next sibling is nested deeper than the previous sibling', async () => { - const {editor} = testEnv; - let x: ListItemNode; - - await editor.update(() => { - const root = $getRoot(); - const parent = new ListNode('bullet', 1); - - const A_listItem = new ListItemNode(); - const A_nestedList = new ListNode('bullet', 1); - const A_nestedListItem = new ListItemNode(); - A_listItem.append(A_nestedList); - A_nestedList.append(A_nestedListItem); - A_nestedListItem.append(new TextNode('A')); - - x = new ListItemNode(); - x.append(new TextNode('x')); - - const B_listItem = new ListItemNode(); - const B_nestedList = new ListNode('bullet', 1); - const B_nestedListItem1 = new ListItemNode(); - const B_nestedListItem2 = new ListItemNode(); - const B_deeplyNestedList = new ListNode('bullet', 1); - const B_deeplyNestedListItem = new ListItemNode(); - B_listItem.append(B_nestedList); - B_nestedList.append(B_nestedListItem1); - B_nestedList.append(B_nestedListItem2); - B_nestedListItem1.append(B_deeplyNestedList); - B_nestedListItem2.append(new TextNode('B2')); - B_deeplyNestedList.append(B_deeplyNestedListItem); - B_deeplyNestedListItem.append(new TextNode('B1')); - - parent.append(A_listItem, x, B_listItem); - root.append(parent); - }); - - expectHtmlToBeEqual( - testEnv.innerHTML, + expectHtmlToBeEqual( + context.editorDOM.innerHTML, html`
    • @@ -845,12 +831,12 @@ describe('LexicalListItemNode tests', () => {
    `, - ); + ); - await editor.update(() => x.remove()); + await editor.update(() => x.remove()); - expectHtmlToBeEqual( - testEnv.innerHTML, + expectHtmlToBeEqual( + context.editorDOM.innerHTML, html`
    • @@ -872,59 +858,58 @@ describe('LexicalListItemNode tests', () => {
    `, - ); + ); + }); + + // - A1 + // - A2 + // - x + // - B1 + // - B2 + test('both siblings are deeply nested', async () => { + let x: ListItemNode; + + await editor.update(() => { + const root = $getRoot(); + const parent = new ListNode('bullet', 1); + + const A_listItem = new ListItemNode(); + const A_nestedList = new ListNode('bullet', 1); + const A_nestedListItem1 = new ListItemNode(); + const A_nestedListItem2 = new ListItemNode(); + const A_deeplyNestedList = new ListNode('bullet', 1); + const A_deeplyNestedListItem = new ListItemNode(); + A_listItem.append(A_nestedList); + A_nestedList.append(A_nestedListItem1); + A_nestedList.append(A_nestedListItem2); + A_nestedListItem1.append(new TextNode('A1')); + A_nestedListItem2.append(A_deeplyNestedList); + A_deeplyNestedList.append(A_deeplyNestedListItem); + A_deeplyNestedListItem.append(new TextNode('A2')); + + x = new ListItemNode(); + x.append(new TextNode('x')); + + const B_listItem = new ListItemNode(); + const B_nestedList = new ListNode('bullet', 1); + const B_nestedListItem1 = new ListItemNode(); + const B_nestedListItem2 = new ListItemNode(); + const B_deeplyNestedList = new ListNode('bullet', 1); + const B_deeplyNestedListItem = new ListItemNode(); + B_listItem.append(B_nestedList); + B_nestedList.append(B_nestedListItem1); + B_nestedList.append(B_nestedListItem2); + B_nestedListItem1.append(B_deeplyNestedList); + B_nestedListItem2.append(new TextNode('B2')); + B_deeplyNestedList.append(B_deeplyNestedListItem); + B_deeplyNestedListItem.append(new TextNode('B1')); + + parent.append(A_listItem, x, B_listItem); + root.append(parent); }); - // - A1 - // - A2 - // - x - // - B1 - // - B2 - test('both siblings are deeply nested', async () => { - const {editor} = testEnv; - let x: ListItemNode; - - await editor.update(() => { - const root = $getRoot(); - const parent = new ListNode('bullet', 1); - - const A_listItem = new ListItemNode(); - const A_nestedList = new ListNode('bullet', 1); - const A_nestedListItem1 = new ListItemNode(); - const A_nestedListItem2 = new ListItemNode(); - const A_deeplyNestedList = new ListNode('bullet', 1); - const A_deeplyNestedListItem = new ListItemNode(); - A_listItem.append(A_nestedList); - A_nestedList.append(A_nestedListItem1); - A_nestedList.append(A_nestedListItem2); - A_nestedListItem1.append(new TextNode('A1')); - A_nestedListItem2.append(A_deeplyNestedList); - A_deeplyNestedList.append(A_deeplyNestedListItem); - A_deeplyNestedListItem.append(new TextNode('A2')); - - x = new ListItemNode(); - x.append(new TextNode('x')); - - const B_listItem = new ListItemNode(); - const B_nestedList = new ListNode('bullet', 1); - const B_nestedListItem1 = new ListItemNode(); - const B_nestedListItem2 = new ListItemNode(); - const B_deeplyNestedList = new ListNode('bullet', 1); - const B_deeplyNestedListItem = new ListItemNode(); - B_listItem.append(B_nestedList); - B_nestedList.append(B_nestedListItem1); - B_nestedList.append(B_nestedListItem2); - B_nestedListItem1.append(B_deeplyNestedList); - B_nestedListItem2.append(new TextNode('B2')); - B_deeplyNestedList.append(B_deeplyNestedListItem); - B_deeplyNestedListItem.append(new TextNode('B1')); - - parent.append(A_listItem, x, B_listItem); - root.append(parent); - }); - - expectHtmlToBeEqual( - testEnv.innerHTML, + expectHtmlToBeEqual( + context.editorDOM.innerHTML, html`
    • @@ -960,12 +945,12 @@ describe('LexicalListItemNode tests', () => {
    `, - ); + ); - await editor.update(() => x.remove()); + await editor.update(() => x.remove()); - expectHtmlToBeEqual( - testEnv.innerHTML, + expectHtmlToBeEqual( + context.editorDOM.innerHTML, html`
    • @@ -990,37 +975,36 @@ describe('LexicalListItemNode tests', () => {
    `, - ); - }); + ); }); + }); - describe('ListItemNode.insertNewAfter(): non-empty list items', () => { - let listNode: ListNode; - let listItemNode1: ListItemNode; - let listItemNode2: ListItemNode; - let listItemNode3: ListItemNode; + describe('ListItemNode.insertNewAfter(): non-empty list items', () => { + let listNode: ListNode; + let listItemNode1: ListItemNode; + let listItemNode2: ListItemNode; + let listItemNode3: ListItemNode; - beforeEach(async () => { - const {editor} = testEnv; + beforeEach(async () => { - await editor.update(() => { - const root = $getRoot(); - listNode = new ListNode('bullet', 1); - listItemNode1 = new ListItemNode(); + await editor.update(() => { + const root = $getRoot(); + listNode = new ListNode('bullet', 1); + listItemNode1 = new ListItemNode(); - listItemNode2 = new ListItemNode(); + listItemNode2 = new ListItemNode(); - listItemNode3 = new ListItemNode(); + listItemNode3 = new ListItemNode(); - root.append(listNode); - listNode.append(listItemNode1, listItemNode2, listItemNode3); - listItemNode1.append(new TextNode('one')); - listItemNode2.append(new TextNode('two')); - listItemNode3.append(new TextNode('three')); - }); + root.append(listNode); + listNode.append(listItemNode1, listItemNode2, listItemNode3); + listItemNode1.append(new TextNode('one')); + listItemNode2.append(new TextNode('two')); + listItemNode3.append(new TextNode('three')); + }); - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
    {
    `, - ); + ); + }); + + test('first list item', async () => { + + await editor.update(() => { + listItemNode1.insertNewAfter($createRangeSelection()); }); - test('first list item', async () => { - const {editor} = testEnv; - - await editor.update(() => { - listItemNode1.insertNewAfter($createRangeSelection()); - }); - - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
    {
    `, - ); + ); + }); + + test('last list item', async () => { + + await editor.update(() => { + listItemNode3.insertNewAfter($createRangeSelection()); }); - test('last list item', async () => { - const {editor} = testEnv; - - await editor.update(() => { - listItemNode3.insertNewAfter($createRangeSelection()); - }); - - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
    {
    `, - ); + ); + }); + + test('middle list item', async () => { + + await editor.update(() => { + listItemNode3.insertNewAfter($createRangeSelection()); }); - test('middle list item', async () => { - const {editor} = testEnv; - - await editor.update(() => { - listItemNode3.insertNewAfter($createRangeSelection()); - }); - - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
    {
    `, - ); + ); + }); + + test('the only list item', async () => { + await editor.update(() => { + listItemNode2.remove(); + listItemNode3.remove(); }); - test('the only list item', async () => { - const {editor} = testEnv; - - await editor.update(() => { - listItemNode2.remove(); - listItemNode3.remove(); - }); - - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
    {
    `, - ); + ); - await editor.update(() => { - listItemNode1.insertNewAfter($createRangeSelection()); - }); + await editor.update(() => { + listItemNode1.insertNewAfter($createRangeSelection()); + }); - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
    {
    `, - ); - }); + ); }); + }); - test('$createListItemNode()', async () => { - const {editor} = testEnv; + describe('ListItemNode.insertNewAfter()', () => { + test('new items after empty nested items un-nests the current item instead of creating new', () => { + let nestedItem!: ListItemNode; + const input = `
      +
    • + Item A +
      • Nested item A
      +
    • +
    • Item B
    • +
    `; - await editor.update(() => { - const listItemNode = new ListItemNode(); - - const createdListItemNode = $createListItemNode(); - - expect(listItemNode.__type).toEqual(createdListItemNode.__type); - expect(listItemNode.__parent).toEqual(createdListItemNode.__parent); - expect(listItemNode.__key).not.toEqual(createdListItemNode.__key); + editor.updateAndCommit(() => { + const root = $getRoot(); + root.append(...$htmlToBlockNodes(editor, input)); + const list = root.getFirstChild() as ListNode; + const itemA = list.getFirstChild() as ListItemNode; + const nestedList = itemA.getLastChild() as ListNode; + nestedItem = nestedList.getFirstChild() as ListItemNode; + nestedList.selectEnd(); }); + + editor.updateAndCommit(() => { + nestedItem.insertNewAfter($createRangeSelection()); + const newItem = nestedItem.getNextSibling() as ListItemNode; + newItem.insertNewAfter($createRangeSelection()); + }); + + expectHtmlToBeEqual( + context.editorDOM.innerHTML, + html`
      +
    • + Item A +
      • Nested item A
      +
    • +

    • +
    • Item B
    • +
    `, + ); }); + }); - test('$isListItemNode()', async () => { - const {editor} = testEnv; + test('$createListItemNode()', async () => { + await editor.update(() => { + const listItemNode = new ListItemNode(); - await editor.update(() => { - const listItemNode = new ListItemNode(); + const createdListItemNode = $createListItemNode(); - expect($isListItemNode(listItemNode)).toBe(true); - }); + expect(listItemNode.__type).toEqual(createdListItemNode.__type); + expect(listItemNode.__parent).toEqual(createdListItemNode.__parent); + expect(listItemNode.__key).not.toEqual(createdListItemNode.__key); + }); + }); + + test('$isListItemNode()', async () => { + await editor.update(() => { + const listItemNode = new ListItemNode(); + + expect($isListItemNode(listItemNode)).toBe(true); }); }); }); diff --git a/resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts b/resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts index 0ab6935fb..736c3573c 100644 --- a/resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts +++ b/resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts @@ -48,7 +48,6 @@ describe('Keyboard-handling service tests', () => { expect(lastRootChild).toBeInstanceOf(DetailsNode); dispatchKeydownEventForNode(detailsPara, editor, 'ArrowDown'); - editor.commitUpdates(); editor.getEditorState().read(() => { lastRootChild = $getRoot().getLastChild(); @@ -79,10 +78,7 @@ describe('Keyboard-handling service tests', () => { expect(lastRootChild).toBeInstanceOf(DetailsNode); dispatchKeydownEventForNode(detailsPara, editor, 'Enter'); - editor.commitUpdates(); - dispatchKeydownEventForSelectedNode(editor, 'Enter'); - editor.commitUpdates(); let detailsChildren!: LexicalNode[]; let lastDetailsText!: string; @@ -115,7 +111,6 @@ describe('Keyboard-handling service tests', () => { }); dispatchKeydownEventForNode(listItemB, editor, 'Tab'); - editor.commitUpdates(); editor.getEditorState().read(() => { const list = $getRoot().getChildren()[0] as ListNode; From f4005a139b0ac102674cad0185fa6c32265e68f5 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 17 Dec 2024 18:07:46 +0000 Subject: [PATCH 11/12] Lexical: Adjusted handling of child/sibling list items on nesting Sibling/child items will now remain at the same visual level during nesting/un-nested, so only the selected item level is visually altered. Also added new model-based editor content matching system for tests. --- .../lexical/core/__tests__/utils/index.ts | 43 +++++- .../js/wysiwyg/utils/__tests__/lists.test.ts | 124 ++++++++++++++++++ resources/js/wysiwyg/utils/lists.ts | 22 ++++ 3 files changed, 183 insertions(+), 6 deletions(-) create mode 100644 resources/js/wysiwyg/utils/__tests__/lists.test.ts diff --git a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts index d90853b7c..b13bba697 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts @@ -30,18 +30,14 @@ import { TextNode, } from 'lexical'; -import { - CreateEditorArgs, - HTMLConfig, - LexicalNodeReplacement, -} from '../../LexicalEditor'; +import {CreateEditorArgs, HTMLConfig, LexicalNodeReplacement,} from '../../LexicalEditor'; 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"; +import {turtle} from "@codemirror/legacy-modes/mode/turtle"; type TestEnv = { @@ -764,6 +760,41 @@ export function expectHtmlToBeEqual(expected: string, actual: string): void { expect(formatHtml(expected)).toBe(formatHtml(actual)); } +type nodeTextShape = { + text: string; +}; + +type nodeShape = { + type: string; + children?: (nodeShape|nodeTextShape)[]; +} + +export function getNodeShape(node: SerializedLexicalNode): nodeShape|nodeTextShape { + // @ts-ignore + const children: SerializedLexicalNode[] = (node.children || []); + + const shape: nodeShape = { + type: node.type, + }; + + if (shape.type === 'text') { + // @ts-ignore + return {text: node.text} + } + + if (children.length > 0) { + shape.children = children.map(c => getNodeShape(c)); + } + + return shape; +} + +export function expectNodeShapeToMatch(editor: LexicalEditor, expected: nodeShape[]) { + const json = editor.getEditorState().toJSON(); + const shape = getNodeShape(json.root) as nodeShape; + expect(shape.children).toMatchObject(expected); +} + function formatHtml(s: string): string { return s.replace(/>\s+<').replace(/\s*\n\s*/g, ' ').trim(); } diff --git a/resources/js/wysiwyg/utils/__tests__/lists.test.ts b/resources/js/wysiwyg/utils/__tests__/lists.test.ts new file mode 100644 index 000000000..20dcad240 --- /dev/null +++ b/resources/js/wysiwyg/utils/__tests__/lists.test.ts @@ -0,0 +1,124 @@ +import { + createTestContext, destroyFromContext, + dispatchKeydownEventForNode, expectNodeShapeToMatch, +} from "lexical/__tests__/utils"; +import { + $createParagraphNode, $getRoot, LexicalEditor, LexicalNode, + ParagraphNode, +} from "lexical"; +import {$createDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; +import {EditorUiContext} from "../../ui/framework/core"; +import {$htmlToBlockNodes} from "../nodes"; +import {ListItemNode, ListNode} from "@lexical/list"; +import {$nestListItem, $unnestListItem} from "../lists"; + +describe('List Utils', () => { + + let context!: EditorUiContext; + let editor!: LexicalEditor; + + beforeEach(() => { + context = createTestContext(); + editor = context.editor; + }); + + afterEach(() => { + destroyFromContext(context); + }); + + describe('$nestListItem', () => { + test('nesting handles child items to leave at the same level', () => { + const input = `
      +
    • Inner A
    • +
    • Inner B
        +
      • Inner C
      • +
    • +
    `; + let list!: ListNode; + + editor.updateAndCommit(() => { + $getRoot().append(...$htmlToBlockNodes(editor, input)); + list = $getRoot().getFirstChild() as ListNode; + }); + + editor.updateAndCommit(() => { + $nestListItem(list.getChildren()[1] as ListItemNode); + }); + + expectNodeShapeToMatch(editor, [ + { + type: 'list', + children: [ + { + type: 'listitem', + children: [ + {text: 'Inner A'}, + { + type: 'list', + children: [ + {type: 'listitem', children: [{text: 'Inner B'}]}, + {type: 'listitem', children: [{text: 'Inner C'}]}, + ] + } + ] + }, + ] + } + ]); + }); + }); + + describe('$unnestListItem', () => { + test('middle in nested list converts to new parent item at same place', () => { + const input = `
      +
    • Nested list:
        +
      • Inner A
      • +
      • Inner B
      • +
      • Inner C
      • +
    • +
    `; + let innerList!: ListNode; + + editor.updateAndCommit(() => { + $getRoot().append(...$htmlToBlockNodes(editor, input)); + innerList = (($getRoot().getFirstChild() as ListNode).getFirstChild() as ListItemNode).getLastChild() as ListNode; + }); + + editor.updateAndCommit(() => { + $unnestListItem(innerList.getChildren()[1] as ListItemNode); + }); + + expectNodeShapeToMatch(editor, [ + { + type: 'list', + children: [ + { + type: 'listitem', + children: [ + {text: 'Nested list:'}, + { + type: 'list', + children: [ + {type: 'listitem', children: [{text: 'Inner A'}]}, + ], + } + ], + }, + { + type: 'listitem', + children: [ + {text: 'Inner B'}, + { + type: 'list', + children: [ + {type: 'listitem', children: [{text: 'Inner C'}]}, + ], + } + ], + } + ] + } + ]); + }); + }); +}); \ No newline at end of file diff --git a/resources/js/wysiwyg/utils/lists.ts b/resources/js/wysiwyg/utils/lists.ts index 2fc1c5f6b..005b05f98 100644 --- a/resources/js/wysiwyg/utils/lists.ts +++ b/resources/js/wysiwyg/utils/lists.ts @@ -10,6 +10,9 @@ export function $nestListItem(node: ListItemNode): ListItemNode { return node; } + const nodeChildList = node.getChildren().filter(n => $isListNode(n))[0] || null; + const nodeChildItems = nodeChildList?.getChildren() || []; + const listItems = list.getChildren() as ListItemNode[]; const nodeIndex = listItems.findIndex((n) => n.getKey() === node.getKey()); const isFirst = nodeIndex === 0; @@ -27,6 +30,13 @@ export function $nestListItem(node: ListItemNode): ListItemNode { node.remove(); } + if (nodeChildList) { + for (const child of nodeChildItems) { + newListItem.insertAfter(child); + } + nodeChildList.remove(); + } + return newListItem; } @@ -38,6 +48,8 @@ export function $unnestListItem(node: ListItemNode): ListItemNode { return node; } + const laterSiblings = node.getNextSiblings(); + parentListItem.insertAfter(node); if (list.getChildren().length === 0) { list.remove(); @@ -47,6 +59,16 @@ export function $unnestListItem(node: ListItemNode): ListItemNode { parentListItem.remove(); } + if (laterSiblings.length > 0) { + const childList = $createListNode(list.getListType()); + childList.append(...laterSiblings); + node.append(childList); + } + + if (list.getChildrenSize() === 0) { + list.remove(); + } + return node; } From ebe2ca7faff580ccb8064920a24fb2fad6412105 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 17 Dec 2024 22:40:28 +0000 Subject: [PATCH 12/12] Lexical: Added about button/view Re-used existing route and moved tinymce help to its own different route. Added test to cover. Added new external-content block to support in editor UI. --- lang/en/editor.php | 2 + .../icons/editor/{help.svg => about.svg} | 0 resources/js/wysiwyg-tinymce/plugins-about.js | 2 +- .../wysiwyg/ui/defaults/buttons/controls.ts | 15 +- .../js/wysiwyg/ui/defaults/forms/controls.ts | 17 +- resources/js/wysiwyg/ui/defaults/modals.ts | 6 +- .../js/wysiwyg/ui/{ => defaults}/toolbars.ts | 39 +-- .../ui/framework/blocks/external-content.ts | 29 ++ resources/js/wysiwyg/ui/index.ts | 2 +- resources/sass/_editor.scss | 7 + resources/views/help/tinymce.blade.php | 146 +++++++++ resources/views/help/wysiwyg.blade.php | 280 +++++++++--------- routes/web.php | 1 + tests/Meta/HelpTest.php | 12 +- 14 files changed, 388 insertions(+), 170 deletions(-) rename resources/icons/editor/{help.svg => about.svg} (100%) rename resources/js/wysiwyg/ui/{ => defaults}/toolbars.ts (88%) create mode 100644 resources/js/wysiwyg/ui/framework/blocks/external-content.ts create mode 100644 resources/views/help/tinymce.blade.php diff --git a/lang/en/editor.php b/lang/en/editor.php index de9aa0ece..a61b46042 100644 --- a/lang/en/editor.php +++ b/lang/en/editor.php @@ -163,6 +163,8 @@ return [ 'about' => 'About the editor', 'about_title' => 'About the WYSIWYG Editor', 'editor_license' => 'Editor License & Copyright', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'This editor is built using :tinyLink which is provided under the MIT license.', 'editor_tiny_license_link' => 'The copyright and license details of TinyMCE can be found here.', 'save_continue' => 'Save Page & Continue', diff --git a/resources/icons/editor/help.svg b/resources/icons/editor/about.svg similarity index 100% rename from resources/icons/editor/help.svg rename to resources/icons/editor/about.svg diff --git a/resources/js/wysiwyg-tinymce/plugins-about.js b/resources/js/wysiwyg-tinymce/plugins-about.js index 096b4f968..75cf476cf 100644 --- a/resources/js/wysiwyg-tinymce/plugins-about.js +++ b/resources/js/wysiwyg-tinymce/plugins-about.js @@ -4,7 +4,7 @@ function register(editor) { const aboutDialog = { title: 'About the WYSIWYG Editor', - url: window.baseUrl('/help/wysiwyg'), + url: window.baseUrl('/help/tinymce'), }; editor.ui.registry.addButton('about', { diff --git a/resources/js/wysiwyg/ui/defaults/buttons/controls.ts b/resources/js/wysiwyg/ui/defaults/buttons/controls.ts index 77223dac3..5e3200539 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/controls.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/controls.ts @@ -11,8 +11,9 @@ import { } from "lexical"; import redoIcon from "@icons/editor/redo.svg"; import sourceIcon from "@icons/editor/source-view.svg"; -import {getEditorContentAsHtml} from "../../../utils/actions"; import fullscreenIcon from "@icons/editor/fullscreen.svg"; +import aboutIcon from "@icons/editor/about.svg"; +import {getEditorContentAsHtml} from "../../../utils/actions"; export const undo: EditorButtonDefinition = { label: 'Undo', @@ -80,4 +81,16 @@ export const fullscreen: EditorButtonDefinition = { isActive(selection, context: EditorUiContext) { return context.containerDOM.classList.contains('fullscreen'); } +}; + +export const about: EditorButtonDefinition = { + label: 'About the editor', + icon: aboutIcon, + async action(context: EditorUiContext, button: EditorButton) { + const modal = context.manager.createModal('about'); + modal.show({}); + }, + isActive(selection, context: EditorUiContext) { + return false; + } }; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/forms/controls.ts b/resources/js/wysiwyg/ui/defaults/forms/controls.ts index fc461f662..8e7219d67 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/controls.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/controls.ts @@ -1,6 +1,7 @@ import {EditorFormDefinition} from "../../framework/forms"; -import {EditorUiContext} from "../../framework/core"; +import {EditorUiContext, EditorUiElement} from "../../framework/core"; import {setEditorContentFromHtml} from "../../../utils/actions"; +import {ExternalContent} from "../../framework/blocks/external-content"; export const source: EditorFormDefinition = { submitText: 'Save', @@ -15,4 +16,18 @@ export const source: EditorFormDefinition = { type: 'textarea', }, ], +}; + +export const about: EditorFormDefinition = { + submitText: 'Close', + async action() { + return true; + }, + fields: [ + { + build(): EditorUiElement { + return new ExternalContent('/help/wysiwyg'); + } + } + ], }; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/modals.ts b/resources/js/wysiwyg/ui/defaults/modals.ts index da3859266..830a42935 100644 --- a/resources/js/wysiwyg/ui/defaults/modals.ts +++ b/resources/js/wysiwyg/ui/defaults/modals.ts @@ -1,6 +1,6 @@ import {EditorFormModalDefinition} from "../framework/modals"; import {details, image, link, media} from "./forms/objects"; -import {source} from "./forms/controls"; +import {about, source} from "./forms/controls"; import {cellProperties, rowProperties, tableProperties} from "./forms/tables"; export const modals: Record = { @@ -35,5 +35,9 @@ export const modals: Record = { details: { title: 'Edit collapsible block', form: details, + }, + about: { + title: 'About the WYSIWYG Editor', + form: about, } }; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/defaults/toolbars.ts similarity index 88% rename from resources/js/wysiwyg/ui/toolbars.ts rename to resources/js/wysiwyg/ui/defaults/toolbars.ts index 1230cbdd2..61baa3c32 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/defaults/toolbars.ts @@ -1,12 +1,12 @@ -import {EditorButton} from "./framework/buttons"; -import {EditorContainerUiElement, EditorSimpleClassContainer, EditorUiContext, EditorUiElement} from "./framework/core"; -import {EditorFormatMenu} from "./framework/blocks/format-menu"; -import {FormatPreviewButton} from "./framework/blocks/format-preview-button"; -import {EditorDropdownButton} from "./framework/blocks/dropdown-button"; -import {EditorColorPicker} from "./framework/blocks/color-picker"; -import {EditorTableCreator} from "./framework/blocks/table-creator"; -import {EditorColorButton} from "./framework/blocks/color-button"; -import {EditorOverflowContainer} from "./framework/blocks/overflow-container"; +import {EditorButton} from "../framework/buttons"; +import {EditorContainerUiElement, EditorSimpleClassContainer, EditorUiContext, EditorUiElement} from "../framework/core"; +import {EditorFormatMenu} from "../framework/blocks/format-menu"; +import {FormatPreviewButton} from "../framework/blocks/format-preview-button"; +import {EditorDropdownButton} from "../framework/blocks/dropdown-button"; +import {EditorColorPicker} from "../framework/blocks/color-picker"; +import {EditorTableCreator} from "../framework/blocks/table-creator"; +import {EditorColorButton} from "../framework/blocks/color-button"; +import {EditorOverflowContainer} from "../framework/blocks/overflow-container"; import { cellProperties, clearTableFormatting, copyColumn, @@ -29,8 +29,8 @@ import { rowProperties, splitCell, table, tableProperties -} from "./defaults/buttons/tables"; -import {fullscreen, redo, source, undo} from "./defaults/buttons/controls"; +} from "./buttons/tables"; +import {about, fullscreen, redo, source, undo} from "./buttons/controls"; import { blockquote, dangerCallout, h2, @@ -41,7 +41,7 @@ import { paragraph, successCallout, warningCallout -} from "./defaults/buttons/block-formats"; +} from "./buttons/block-formats"; import { bold, clearFormating, code, highlightColor, @@ -50,7 +50,7 @@ import { superscript, textColor, underline -} from "./defaults/buttons/inline-formats"; +} from "./buttons/inline-formats"; import { alignCenter, alignJustify, @@ -58,14 +58,14 @@ import { alignRight, directionLTR, directionRTL -} from "./defaults/buttons/alignments"; +} from "./buttons/alignments"; import { bulletList, indentDecrease, indentIncrease, numberList, taskList -} from "./defaults/buttons/lists"; +} from "./buttons/lists"; import { codeBlock, details, detailsEditLabel, detailsToggle, detailsUnwrap, @@ -75,10 +75,10 @@ import { image, link, media, unlink -} from "./defaults/buttons/objects"; -import {el} from "../utils/dom"; -import {EditorButtonWithMenu} from "./framework/blocks/button-with-menu"; -import {EditorSeparator} from "./framework/blocks/separator"; +} from "./buttons/objects"; +import {el} from "../../utils/dom"; +import {EditorButtonWithMenu} from "../framework/blocks/button-with-menu"; +import {EditorSeparator} from "../framework/blocks/separator"; export function getMainEditorFullToolbar(context: EditorUiContext): EditorContainerUiElement { @@ -201,6 +201,7 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai // Meta elements new EditorOverflowContainer(3, [ new EditorButton(source), + new EditorButton(about), new EditorButton(fullscreen), // Test diff --git a/resources/js/wysiwyg/ui/framework/blocks/external-content.ts b/resources/js/wysiwyg/ui/framework/blocks/external-content.ts new file mode 100644 index 000000000..b53c43e55 --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/blocks/external-content.ts @@ -0,0 +1,29 @@ +import {EditorUiElement} from "../core"; +import {el} from "../../../utils/dom"; + +export class ExternalContent extends EditorUiElement { + + /** + * The URL for HTML to be loaded from. + */ + protected url: string = ''; + + constructor(url: string) { + super(); + this.url = url; + } + + buildDOM(): HTMLElement { + const wrapper = el('div', { + class: 'editor-external-content', + }); + + window.$http.get(this.url).then(resp => { + if (typeof resp.data === 'string') { + wrapper.innerHTML = resp.data; + } + }); + + return wrapper; + } +} diff --git a/resources/js/wysiwyg/ui/index.ts b/resources/js/wysiwyg/ui/index.ts index 40df43347..fda37085e 100644 --- a/resources/js/wysiwyg/ui/index.ts +++ b/resources/js/wysiwyg/ui/index.ts @@ -4,7 +4,7 @@ import { getImageToolbarContent, getLinkToolbarContent, getMainEditorFullToolbar, getTableToolbarContent -} from "./toolbars"; +} from "./defaults/toolbars"; import {EditorUIManager} from "./framework/manager"; import {EditorUiContext} from "./framework/core"; import {CodeBlockDecorator} from "./decorators/code-block"; diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index 3eef4c2c6..2446c1416 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -350,6 +350,13 @@ body.editor-is-fullscreen { text-align: center; padding: 0.2em; } +.editor-external-content { + min-width: 500px; + min-height: 500px; + h4:first-child { + margin-top: 0; + } +} // In-editor elements .editor-image-wrap { diff --git a/resources/views/help/tinymce.blade.php b/resources/views/help/tinymce.blade.php new file mode 100644 index 000000000..8ff59c8d6 --- /dev/null +++ b/resources/views/help/tinymce.blade.php @@ -0,0 +1,146 @@ +@extends('layouts.plain') +@section('document-class', 'bg-white ' . (setting()->getForCurrentUser('dark-mode-enabled') ? 'dark-mode ' : '')) + +@section('content') +
    + +

    {{ trans('editor.editor_license') }}

    +

    + {!! trans('editor.editor_tiny_license', ['tinyLink' => 'TinyMCE']) !!} +
    + {{ trans('editor.editor_tiny_license_link') }} +

    + +

    {{ trans('editor.shortcuts') }}

    + +

    {{ trans('editor.shortcuts_intro') }}

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    {{ trans('editor.shortcut') }} {{ trans('editor.windows_linux') }}{{ trans('editor.shortcut') }} {{ trans('editor.mac') }}{{ trans('editor.description') }}
    Ctrl+SCmd+S{{ trans('entities.pages_edit_save_draft') }}
    Ctrl+EnterCmd+Enter{{ trans('editor.save_continue') }}
    Ctrl+BCmd+B{{ trans('editor.bold') }}
    Ctrl+ICmd+I{{ trans('editor.italic') }}
    + Ctrl+1
    + Ctrl+2
    + Ctrl+3
    + Ctrl+4 +
    + Cmd+1
    + Cmd+2
    + Cmd+3
    + Cmd+4 +
    + {{ trans('editor.header_large') }}
    + {{ trans('editor.header_medium') }}
    + {{ trans('editor.header_small') }}
    + {{ trans('editor.header_tiny') }} +
    + Ctrl+5
    + Ctrl+D +
    + Cmd+5
    + Cmd+D +
    {{ trans('editor.paragraph') }}
    + Ctrl+6
    + Ctrl+Q +
    + Cmd+6
    + Cmd+Q +
    {{ trans('editor.blockquote') }}
    + Ctrl+7
    + Ctrl+E +
    + Cmd+7
    + Cmd+E +
    {{ trans('editor.insert_code_block') }}
    + Ctrl+8
    + Ctrl+Shift+E +
    + Cmd+8
    + Cmd+Shift+E +
    {{ trans('editor.inline_code') }}
    Ctrl+9Cmd+9 + {{ trans('editor.callouts') }}
    + {{ trans('editor.callouts_cycle') }} +
    + Ctrl+O
    + Ctrl+P +
    + Cmd+O
    + Cmd+P +
    + {{ trans('editor.list_numbered') }}
    + {{ trans('editor.list_bullet') }} +
    + Ctrl+Shift+K + + Cmd+Shift+K + {{ trans('editor.link_selector') }}
    + +
    +@endsection + diff --git a/resources/views/help/wysiwyg.blade.php b/resources/views/help/wysiwyg.blade.php index 8ff59c8d6..4fc00b0e1 100644 --- a/resources/views/help/wysiwyg.blade.php +++ b/resources/views/help/wysiwyg.blade.php @@ -1,146 +1,138 @@ -@extends('layouts.plain') -@section('document-class', 'bg-white ' . (setting()->getForCurrentUser('dark-mode-enabled') ? 'dark-mode ' : '')) +

    {{ trans('editor.shortcuts') }}

    -@section('content') -
    - -

    {{ trans('editor.editor_license') }}

    -

    - {!! trans('editor.editor_tiny_license', ['tinyLink' => 'TinyMCE']) !!} -
    - {{ trans('editor.editor_tiny_license_link') }} -

    - -

    {{ trans('editor.shortcuts') }}

    - -

    {{ trans('editor.shortcuts_intro') }}

    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    {{ trans('editor.shortcut') }} {{ trans('editor.windows_linux') }}{{ trans('editor.shortcut') }} {{ trans('editor.mac') }}{{ trans('editor.description') }}
    Ctrl+SCmd+S{{ trans('entities.pages_edit_save_draft') }}
    Ctrl+EnterCmd+Enter{{ trans('editor.save_continue') }}
    Ctrl+BCmd+B{{ trans('editor.bold') }}
    Ctrl+ICmd+I{{ trans('editor.italic') }}
    - Ctrl+1
    - Ctrl+2
    - Ctrl+3
    - Ctrl+4 -
    - Cmd+1
    - Cmd+2
    - Cmd+3
    - Cmd+4 -
    - {{ trans('editor.header_large') }}
    - {{ trans('editor.header_medium') }}
    - {{ trans('editor.header_small') }}
    - {{ trans('editor.header_tiny') }} -
    - Ctrl+5
    - Ctrl+D -
    - Cmd+5
    - Cmd+D -
    {{ trans('editor.paragraph') }}
    - Ctrl+6
    - Ctrl+Q -
    - Cmd+6
    - Cmd+Q -
    {{ trans('editor.blockquote') }}
    - Ctrl+7
    - Ctrl+E -
    - Cmd+7
    - Cmd+E -
    {{ trans('editor.insert_code_block') }}
    - Ctrl+8
    - Ctrl+Shift+E -
    - Cmd+8
    - Cmd+Shift+E -
    {{ trans('editor.inline_code') }}
    Ctrl+9Cmd+9 - {{ trans('editor.callouts') }}
    - {{ trans('editor.callouts_cycle') }} -
    - Ctrl+O
    - Ctrl+P -
    - Cmd+O
    - Cmd+P -
    - {{ trans('editor.list_numbered') }}
    - {{ trans('editor.list_bullet') }} -
    - Ctrl+Shift+K - - Cmd+Shift+K - {{ trans('editor.link_selector') }}
    - -
    -@endsection +

    {{ trans('editor.shortcuts_intro') }}

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    {{ trans('editor.shortcut') }} {{ trans('editor.windows_linux') }}{{ trans('editor.shortcut') }} {{ trans('editor.mac') }}{{ trans('editor.description') }}
    Ctrl+SCmd+S{{ trans('entities.pages_edit_save_draft') }}
    Ctrl+EnterCmd+Enter{{ trans('editor.save_continue') }}
    Ctrl+BCmd+B{{ trans('editor.bold') }}
    Ctrl+ICmd+I{{ trans('editor.italic') }}
    + Ctrl+1
    + Ctrl+2
    + Ctrl+3
    + Ctrl+4 +
    + Cmd+1
    + Cmd+2
    + Cmd+3
    + Cmd+4 +
    + {{ trans('editor.header_large') }}
    + {{ trans('editor.header_medium') }}
    + {{ trans('editor.header_small') }}
    + {{ trans('editor.header_tiny') }} +
    + Ctrl+5
    + Ctrl+D +
    + Cmd+5
    + Cmd+D +
    {{ trans('editor.paragraph') }}
    + Ctrl+6
    + Ctrl+Q +
    + Cmd+6
    + Cmd+Q +
    {{ trans('editor.blockquote') }}
    + Ctrl+7
    + Ctrl+E +
    + Cmd+7
    + Cmd+E +
    {{ trans('editor.insert_code_block') }}
    + Ctrl+8
    + Ctrl+Shift+E +
    + Cmd+8
    + Cmd+Shift+E +
    {{ trans('editor.inline_code') }}
    Ctrl+9Cmd+9 + {{ trans('editor.callouts') }}
    + {{ trans('editor.callouts_cycle') }} +
    + Ctrl+O
    + Ctrl+P +
    + Cmd+O
    + Cmd+P +
    + {{ trans('editor.list_numbered') }}
    + {{ trans('editor.list_bullet') }} +
    + Ctrl+Shift+K + + Cmd+Shift+K + {{ trans('editor.link_selector') }}
    +

    {{ trans('editor.editor_license') }}

    +

    + {!! trans('editor.editor_lexical_license', ['lexicalLink' => 'Lexical']) !!} +
    + Copyright (c) Meta Platforms, Inc. and affiliates. +
    + {{ trans('editor.editor_lexical_license_link') }} +

    \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index 85f833528..318147ef5 100644 --- a/routes/web.php +++ b/routes/web.php @@ -361,6 +361,7 @@ Route::get('/password/reset/{token}', [AccessControllers\ResetPasswordController Route::post('/password/reset', [AccessControllers\ResetPasswordController::class, 'reset'])->middleware('throttle:public'); // Metadata routes +Route::view('/help/tinymce', 'help.tinymce'); Route::view('/help/wysiwyg', 'help.wysiwyg'); Route::fallback([MetaController::class, 'notFound'])->name('fallback'); diff --git a/tests/Meta/HelpTest.php b/tests/Meta/HelpTest.php index e1de96bc8..acce65394 100644 --- a/tests/Meta/HelpTest.php +++ b/tests/Meta/HelpTest.php @@ -6,9 +6,9 @@ use Tests\TestCase; class HelpTest extends TestCase { - public function test_wysiwyg_help_shows_tiny_and_tiny_license_link() + public function test_tinymce_help_shows_tiny_and_tiny_license_link() { - $resp = $this->get('/help/wysiwyg'); + $resp = $this->get('/help/tinymce'); $resp->assertOk(); $this->withHtml($resp)->assertElementExists('a[href="https://www.tiny.cloud/"]'); $this->withHtml($resp)->assertElementExists('a[href="' . url('/libs/tinymce/license.txt') . '"]'); @@ -22,4 +22,12 @@ class HelpTest extends TestCase $contents = file_get_contents($expectedPath); $this->assertStringContainsString('MIT License', $contents); } + + public function test_wysiwyg_help_shows_lexical_and_licenses_link() + { + $resp = $this->get('/help/wysiwyg'); + $resp->assertOk(); + $this->withHtml($resp)->assertElementExists('a[href="https://lexical.dev/"]'); + $this->withHtml($resp)->assertElementExists('a[href="' . url('/licenses') . '"]'); + } }