From 32809193704b06dd8dbe2eb98bb1fe329138a0e7 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 25 May 2025 13:21:13 +0100 Subject: [PATCH 1/9] Lexical: Improved diagram selection and keyboard usage Fixes issues where drawings could not be removed via backspace or delete. --- resources/js/wysiwyg/services/keyboard-handling.ts | 3 ++- resources/js/wysiwyg/ui/decorators/diagram.ts | 14 ++++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/resources/js/wysiwyg/services/keyboard-handling.ts b/resources/js/wysiwyg/services/keyboard-handling.ts index a7f1ec7f0..41a917ecb 100644 --- a/resources/js/wysiwyg/services/keyboard-handling.ts +++ b/resources/js/wysiwyg/services/keyboard-handling.ts @@ -17,11 +17,12 @@ import {$getNearestNodeBlockParent, $getParentOfType} from "../utils/nodes"; import {$setInsetForSelection} from "../utils/lists"; import {$isListItemNode} from "@lexical/list"; import {$isDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; +import {$isDiagramNode} from "../utils/diagrams"; function isSingleSelectedNode(nodes: LexicalNode[]): boolean { if (nodes.length === 1) { const node = nodes[0]; - if ($isDecoratorNode(node) || $isImageNode(node) || $isMediaNode(node)) { + if ($isDecoratorNode(node) || $isImageNode(node) || $isMediaNode(node) || $isDiagramNode(node)) { return true; } } diff --git a/resources/js/wysiwyg/ui/decorators/diagram.ts b/resources/js/wysiwyg/ui/decorators/diagram.ts index d53bcb482..52a73ad72 100644 --- a/resources/js/wysiwyg/ui/decorators/diagram.ts +++ b/resources/js/wysiwyg/ui/decorators/diagram.ts @@ -1,6 +1,6 @@ import {EditorDecorator} from "../framework/decorator"; import {EditorUiContext} from "../framework/core"; -import {BaseSelection} from "lexical"; +import {BaseSelection, CLICK_COMMAND, COMMAND_PRIORITY_NORMAL} from "lexical"; import {DiagramNode} from "@lexical/rich-text/LexicalDiagramNode"; import {$selectionContainsNode, $selectSingleNode} from "../../utils/selection"; import {$openDrawingEditorForNode} from "../../utils/diagrams"; @@ -12,11 +12,17 @@ export class DiagramDecorator extends EditorDecorator { setup(context: EditorUiContext, element: HTMLElement) { const diagramNode = this.getNode(); element.classList.add('editor-diagram'); - element.addEventListener('click', event => { + + context.editor.registerCommand(CLICK_COMMAND, (event: MouseEvent): boolean => { + if (!element.contains(event.target as HTMLElement)) { + return false; + } + context.editor.update(() => { $selectSingleNode(this.getNode()); - }) - }); + }); + return true; + }, COMMAND_PRIORITY_NORMAL); element.addEventListener('dblclick', event => { context.editor.getEditorState().read(() => { From 1243108e0fbaef6a0b678a872eb9fbf2f269a57a Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 25 May 2025 16:28:42 +0100 Subject: [PATCH 2/9] Lexical: Updated dropdown handling to match tinymce behaviour Now toolbars stay open on mouse-out, and close on other toolbar open, outside click or an accepted action. To support: - Added new system to track and manage open dropdowns. - Added way for buttons to optionally emit events upon actions. - Added way to listen for events. - Used the above to control when dropdowns should hide on action, since some dont (like overflow containers and split dropdown buttons). --- .../ui/framework/blocks/dropdown-button.ts | 13 +- .../ui/framework/blocks/format-menu.ts | 7 +- .../ui/framework/blocks/overflow-container.ts | 1 + resources/js/wysiwyg/ui/framework/buttons.ts | 12 +- resources/js/wysiwyg/ui/framework/core.ts | 15 +++ .../wysiwyg/ui/framework/helpers/dropdowns.ts | 112 ++++++++++++------ resources/js/wysiwyg/ui/framework/manager.ts | 3 + 7 files changed, 120 insertions(+), 43 deletions(-) diff --git a/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts b/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts index d7f02d573..45cb74dd4 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts @@ -1,4 +1,3 @@ -import {handleDropdown} from "../helpers/dropdowns"; import {EditorContainerUiElement, EditorUiElement} from "../core"; import {EditorBasicButtonDefinition, EditorButton} from "../buttons"; import {el} from "../../../utils/dom"; @@ -8,6 +7,7 @@ export type EditorDropdownButtonOptions = { showOnHover?: boolean; direction?: 'vertical'|'horizontal'; showAside?: boolean; + hideOnAction?: boolean; button: EditorBasicButtonDefinition|EditorButton; }; @@ -15,6 +15,7 @@ const defaultOptions: EditorDropdownButtonOptions = { showOnHover: false, direction: 'horizontal', showAside: undefined, + hideOnAction: true, button: {label: 'Menu'}, } @@ -40,7 +41,7 @@ export class EditorDropdownButton extends EditorContainerUiElement { }, isActive: () => { return this.open; - } + }, }); } @@ -65,7 +66,7 @@ export class EditorDropdownButton extends EditorContainerUiElement { class: 'editor-dropdown-menu-container', }, [button, menu]); - handleDropdown({toggle: button, menu : menu, + this.getContext().manager.dropdowns.handle({toggle: button, menu : menu, showOnHover: this.options.showOnHover, showAside: typeof this.options.showAside === 'boolean' ? this.options.showAside : (this.options.direction === 'vertical'), onOpen : () => { @@ -76,6 +77,12 @@ export class EditorDropdownButton extends EditorContainerUiElement { this.getContext().manager.triggerStateUpdateForElement(this.button); }}); + if (this.options.hideOnAction) { + this.onEvent('button-action', () => { + this.getContext().manager.dropdowns.closeAll(); + }, wrapper); + } + return wrapper; } } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/blocks/format-menu.ts b/resources/js/wysiwyg/ui/framework/blocks/format-menu.ts index d666954bf..5d6294935 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/format-menu.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/format-menu.ts @@ -1,6 +1,5 @@ import {EditorUiStateUpdate, EditorContainerUiElement} from "../core"; import {EditorButton} from "../buttons"; -import {handleDropdown} from "../helpers/dropdowns"; import {el} from "../../../utils/dom"; export class EditorFormatMenu extends EditorContainerUiElement { @@ -20,7 +19,11 @@ export class EditorFormatMenu extends EditorContainerUiElement { class: 'editor-format-menu editor-dropdown-menu-container', }, [toggle, menu]); - handleDropdown({toggle : toggle, menu : menu}); + this.getContext().manager.dropdowns.handle({toggle : toggle, menu : menu}); + + this.onEvent('button-action', () => { + this.getContext().manager.dropdowns.closeAll(); + }, wrapper); return wrapper; } diff --git a/resources/js/wysiwyg/ui/framework/blocks/overflow-container.ts b/resources/js/wysiwyg/ui/framework/blocks/overflow-container.ts index cd0780534..1c9664505 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/overflow-container.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/overflow-container.ts @@ -19,6 +19,7 @@ export class EditorOverflowContainer extends EditorContainerUiElement { label: 'More', icon: moreHorizontal, }, + hideOnAction: false, }, []); this.addChildren(this.overflowButton); } diff --git a/resources/js/wysiwyg/ui/framework/buttons.ts b/resources/js/wysiwyg/ui/framework/buttons.ts index cf114aa02..0e1cab0f5 100644 --- a/resources/js/wysiwyg/ui/framework/buttons.ts +++ b/resources/js/wysiwyg/ui/framework/buttons.ts @@ -10,7 +10,12 @@ export interface EditorBasicButtonDefinition { } export interface EditorButtonDefinition extends EditorBasicButtonDefinition { - action: (context: EditorUiContext, button: EditorButton) => void; + /** + * The action to perform when the button is used. + * This can return false to indicate that the completion of the action should + * NOT be communicated to parent UI elements, which is what occurs by default. + */ + action: (context: EditorUiContext, button: EditorButton) => void|false; isActive: (selection: BaseSelection|null, context: EditorUiContext) => boolean; isDisabled?: (selection: BaseSelection|null, context: EditorUiContext) => boolean; setup?: (context: EditorUiContext, button: EditorButton) => void; @@ -78,7 +83,10 @@ export class EditorButton extends EditorUiElement { } protected onClick() { - this.definition.action(this.getContext(), this); + const result = this.definition.action(this.getContext(), this); + if (result !== false) { + this.emitEvent('button-action'); + } } protected updateActiveState(selection: BaseSelection|null) { diff --git a/resources/js/wysiwyg/ui/framework/core.ts b/resources/js/wysiwyg/ui/framework/core.ts index 90ce4ebf9..ca2ba40c6 100644 --- a/resources/js/wysiwyg/ui/framework/core.ts +++ b/resources/js/wysiwyg/ui/framework/core.ts @@ -67,6 +67,21 @@ export abstract class EditorUiElement { updateState(state: EditorUiStateUpdate): void { return; } + + emitEvent(name: string, data: object = {}): void { + if (this.dom) { + this.dom.dispatchEvent(new CustomEvent('editor::' + name, {detail: data, bubbles: true})); + } + } + + onEvent(name: string, callback: (data: object) => any, listenTarget: HTMLElement|null = null): void { + const target = listenTarget || this.dom; + if (target) { + target.addEventListener('editor::' + name, ((event: CustomEvent) => { + callback(event.detail); + }) as EventListener); + } + } } export class EditorContainerUiElement extends EditorUiElement { diff --git a/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts b/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts index ccced6858..751c1b3f2 100644 --- a/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts +++ b/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts @@ -34,57 +34,97 @@ function positionMenu(menu: HTMLElement, toggle: HTMLElement, showAside: boolean } } -export function handleDropdown(options: HandleDropdownParams) { - const {menu, toggle, onClose, onOpen, showOnHover, showAside} = options; - let clickListener: Function|null = null; +export class DropDownManager { - const hide = () => { + protected dropdownOptions: WeakMap = new WeakMap(); + protected openDropdowns: Set = new Set(); + + constructor() { + this.onMenuMouseOver = this.onMenuMouseOver.bind(this); + + window.addEventListener('click', (event: MouseEvent) => { + const target = event.target as HTMLElement; + this.closeAllNotContainingElement(target); + }); + } + + protected closeAllNotContainingElement(element: HTMLElement): void { + for (const menu of this.openDropdowns) { + if (!menu.parentElement?.contains(element)) { + this.closeDropdown(menu); + } + } + } + + protected onMenuMouseOver(event: MouseEvent): void { + const target = event.target as HTMLElement; + this.closeAllNotContainingElement(target); + } + + /** + * Close all open dropdowns. + */ + public closeAll(): void { + for (const menu of this.openDropdowns) { + this.closeDropdown(menu); + } + } + + protected closeDropdown(menu: HTMLElement): void { menu.hidden = true; menu.style.removeProperty('position'); menu.style.removeProperty('left'); menu.style.removeProperty('top'); - if (clickListener) { - window.removeEventListener('click', clickListener as EventListener); - } + + this.openDropdowns.delete(menu); + menu.removeEventListener('mouseover', this.onMenuMouseOver); + + const onClose = this.getOptions(menu).onClose; if (onClose) { onClose(); } - }; + } - const show = () => { + protected openDropdown(menu: HTMLElement): void { + const {toggle, showAside, onOpen} = this.getOptions(menu); 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(); - } - } - window.addEventListener('click', clickListener as EventListener); + + this.openDropdowns.add(menu); + menu.addEventListener('mouseover', this.onMenuMouseOver); + if (onOpen) { onOpen(); } - }; - - const toggleShowing = (event: MouseEvent) => { - menu.hasAttribute('hidden') ? show() : hide(); - }; - toggle.addEventListener('click', toggleShowing); - if (showOnHover) { - toggle.addEventListener('mouseenter', toggleShowing); } - 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(); + protected getOptions(menu: HTMLElement): HandleDropdownParams { + const options = this.dropdownOptions.get(menu); + if (!options) { + throw new Error(`Can't find options for dropdown menu`); } - }); + + return options; + } + + /** + * Add handling for a new dropdown. + */ + public handle(options: HandleDropdownParams) { + const {menu, toggle, showOnHover} = options; + + // Register dropdown + this.dropdownOptions.set(menu, options); + + // Configure default events + const toggleShowing = (event: MouseEvent) => { + menu.hasAttribute('hidden') ? this.openDropdown(menu) : this.closeDropdown(menu); + }; + toggle.addEventListener('click', toggleShowing); + if (showOnHover) { + toggle.addEventListener('mouseenter', () => { + this.openDropdown(menu); + }); + } + } } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/manager.ts b/resources/js/wysiwyg/ui/framework/manager.ts index 0f501d9fa..c80291fb7 100644 --- a/resources/js/wysiwyg/ui/framework/manager.ts +++ b/resources/js/wysiwyg/ui/framework/manager.ts @@ -6,6 +6,7 @@ import {DecoratorListener} from "lexical/LexicalEditor"; import type {NodeKey} from "lexical/LexicalNode"; import {EditorContextToolbar, EditorContextToolbarDefinition} from "./toolbars"; import {getLastSelection, setLastSelection} from "../../utils/selection"; +import {DropDownManager} from "./helpers/dropdowns"; export type SelectionChangeHandler = (selection: BaseSelection|null) => void; @@ -21,6 +22,8 @@ export class EditorUIManager { protected activeContextToolbars: EditorContextToolbar[] = []; protected selectionChangeHandlers: Set = new Set(); + public dropdowns: DropDownManager = new DropDownManager(); + setContext(context: EditorUiContext) { this.context = context; this.setupEventListeners(context); From 2a324755414e18d96a472425533e0c033ffebe98 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 26 May 2025 14:48:13 +0100 Subject: [PATCH 3/9] Lexical: Made a range of selection improvements Updated up/down handling to create where a selection candidate does not exist, to apply to a wider scenario via the selectPrevious/Next methods. Updated DOM selection change handling to identify single selections within decorated nodes to select them in full, instead of losing selection due to partial selection of their contents. Updated table selection handling so that our colgroups are ignored for internal selection focus handling. --- .../js/wysiwyg/lexical/core/LexicalNode.ts | 5 ++-- .../wysiwyg/lexical/core/LexicalSelection.ts | 14 +++++++++- .../table/LexicalTableSelectionHelpers.ts | 5 ++++ .../js/wysiwyg/services/keyboard-handling.ts | 27 +++++++++---------- resources/js/wysiwyg/ui/framework/manager.ts | 1 + resources/js/wysiwyg/utils/nodes.ts | 13 ++++++++- 6 files changed, 46 insertions(+), 19 deletions(-) diff --git a/resources/js/wysiwyg/lexical/core/LexicalNode.ts b/resources/js/wysiwyg/lexical/core/LexicalNode.ts index 7306e6bca..e54cd1066 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalNode.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalNode.ts @@ -48,6 +48,7 @@ import { internalMarkNodeAsDirty, removeFromParent, } from './LexicalUtils'; +import {$insertAndSelectNewEmptyAdjacentNode} from "../../utils/nodes"; export type NodeMap = Map; @@ -1130,7 +1131,7 @@ export class LexicalNode { const prevSibling = this.getPreviousSibling(); const parent = this.getParentOrThrow(); if (prevSibling === null) { - return parent.select(0, 0); + return $insertAndSelectNewEmptyAdjacentNode(this, false); } if ($isElementNode(prevSibling)) { return prevSibling.select(); @@ -1152,7 +1153,7 @@ export class LexicalNode { const nextSibling = this.getNextSibling(); const parent = this.getParentOrThrow(); if (nextSibling === null) { - return parent.select(); + return $insertAndSelectNewEmptyAdjacentNode(this, true); } if ($isElementNode(nextSibling)) { return nextSibling.select(0, 0); diff --git a/resources/js/wysiwyg/lexical/core/LexicalSelection.ts b/resources/js/wysiwyg/lexical/core/LexicalSelection.ts index db18cfc4a..297286a4b 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalSelection.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalSelection.ts @@ -17,7 +17,7 @@ import invariant from 'lexical/shared/invariant'; import { $createLineBreakNode, $createParagraphNode, - $createTextNode, + $createTextNode, $getNearestNodeFromDOMNode, $isDecoratorNode, $isElementNode, $isLineBreakNode, @@ -63,6 +63,7 @@ import { toggleTextFormatType, } from './LexicalUtils'; import {$createTabNode, $isTabNode} from './nodes/LexicalTabNode'; +import {$selectSingleNode} from "../../utils/selection"; export type TextPointType = { _selection: BaseSelection; @@ -2568,6 +2569,17 @@ export function updateDOMSelection( } if (!$isRangeSelection(nextSelection)) { + + // If the DOM selection enters a decorator node update the selection to a single node selection + if (activeElement !== null && domSelection.isCollapsed && focusDOMNode instanceof Node) { + const node = $getNearestNodeFromDOMNode(focusDOMNode); + if ($isDecoratorNode(node)) { + domSelection.removeAllRanges(); + $selectSingleNode(node); + return; + } + } + // We don't remove selection if the prevSelection is null because // of editor.setRootElement(). If this occurs on init when the // editor is already focused, then this can cause the editor to diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts b/resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts index e098a21e4..d9164a778 100644 --- a/resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts +++ b/resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts @@ -917,6 +917,11 @@ export function getTable(tableElement: HTMLElement): TableDOMTable { while (currentNode != null) { const nodeMame = currentNode.nodeName; + if (nodeMame === 'COLGROUP') { + currentNode = currentNode.nextSibling; + continue; + } + if (nodeMame === 'TD' || nodeMame === 'TH') { const elem = currentNode as HTMLElement; const cell = { diff --git a/resources/js/wysiwyg/services/keyboard-handling.ts b/resources/js/wysiwyg/services/keyboard-handling.ts index 41a917ecb..39818acb0 100644 --- a/resources/js/wysiwyg/services/keyboard-handling.ts +++ b/resources/js/wysiwyg/services/keyboard-handling.ts @@ -47,16 +47,21 @@ function deleteSingleSelectedNode(editor: LexicalEditor) { * Insert a new empty node before/after the selection if the selection contains a single * selected node (like image, media etc...). */ -function insertAfterSingleSelectedNode(editor: LexicalEditor, event: KeyboardEvent|null): boolean { +function insertAdjacentToSingleSelectedNode(editor: LexicalEditor, event: KeyboardEvent|null): boolean { const selectionNodes = getLastSelection(editor)?.getNodes() || []; if (isSingleSelectedNode(selectionNodes)) { const node = selectionNodes[0]; const nearestBlock = $getNearestNodeBlockParent(node) || node; + const insertBefore = event?.shiftKey === true; if (nearestBlock) { requestAnimationFrame(() => { editor.update(() => { const newParagraph = $createParagraphNode(); - nearestBlock.insertAfter(newParagraph); + if (insertBefore) { + nearestBlock.insertBefore(newParagraph); + } else { + nearestBlock.insertAfter(newParagraph); + } newParagraph.select(); }); }); @@ -75,22 +80,14 @@ function focusAdjacentOrInsertForSingleSelectNode(editor: LexicalEditor, event: } event?.preventDefault(); - const node = selectionNodes[0]; - const nearestBlock = $getNearestNodeBlockParent(node) || node; - let target = after ? nearestBlock.getNextSibling() : nearestBlock.getPreviousSibling(); editor.update(() => { - if (!target) { - target = $createParagraphNode(); - if (after) { - nearestBlock.insertAfter(target) - } else { - nearestBlock.insertBefore(target); - } + if (after) { + node.selectNext(); + } else { + node.selectPrevious(); } - - target.selectStart(); }); return true; @@ -220,7 +217,7 @@ 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 insertAdjacentToSingleSelectedNode(context.editor, event) || moveAfterDetailsOnEmptyLine(context.editor, event); }, COMMAND_PRIORITY_LOW); diff --git a/resources/js/wysiwyg/ui/framework/manager.ts b/resources/js/wysiwyg/ui/framework/manager.ts index c80291fb7..2d15b341b 100644 --- a/resources/js/wysiwyg/ui/framework/manager.ts +++ b/resources/js/wysiwyg/ui/framework/manager.ts @@ -244,6 +244,7 @@ export class EditorUIManager { if (selectionChange) { editor.update(() => { const selection = $getSelection(); + // console.log('manager::selection', selection); this.triggerStateUpdate({ editor, selection, }); diff --git a/resources/js/wysiwyg/utils/nodes.ts b/resources/js/wysiwyg/utils/nodes.ts index 591232ea3..ebf01e39d 100644 --- a/resources/js/wysiwyg/utils/nodes.ts +++ b/resources/js/wysiwyg/utils/nodes.ts @@ -6,7 +6,7 @@ import { $isTextNode, ElementNode, LexicalEditor, - LexicalNode + LexicalNode, RangeSelection } from "lexical"; import {LexicalNodeMatcher} from "../nodes"; import {$generateNodesFromDOM} from "@lexical/html"; @@ -118,6 +118,17 @@ export function $sortNodes(nodes: LexicalNode[]): LexicalNode[] { return sorted; } +export function $insertAndSelectNewEmptyAdjacentNode(node: LexicalNode, after: boolean): RangeSelection { + const target = $createParagraphNode(); + if (after) { + node.insertAfter(target) + } else { + node.insertBefore(target); + } + + return target.select(); +} + export function nodeHasAlignment(node: object): node is NodeHasAlignment { return '__alignment' in node; } From c4f7368c1c18d868aec59665218103f678a7843d Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 26 May 2025 15:19:11 +0100 Subject: [PATCH 4/9] Lexical: Fixed table column resizing changes not appearing Also fixed some resizer zindex issues. --- resources/js/wysiwyg/lexical/table/LexicalTableNode.ts | 4 ++-- resources/sass/_editor.scss | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts b/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts index a10361475..105764be2 100644 --- a/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts +++ b/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts @@ -54,7 +54,7 @@ export class TableNode extends CommonBlockNode { static clone(node: TableNode): TableNode { const newNode = new TableNode(node.__key); copyCommonBlockProperties(node, newNode); - newNode.__colWidths = node.__colWidths; + newNode.__colWidths = [...node.__colWidths]; newNode.__styles = new Map(node.__styles); return newNode; } @@ -169,7 +169,7 @@ export class TableNode extends CommonBlockNode { getColWidths(): string[] { const self = this.getLatest(); - return self.__colWidths; + return [...self.__colWidths]; } getStyles(): StyleMap { diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index 35f11c5a2..4112f6288 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -422,7 +422,7 @@ body.editor-is-fullscreen { .editor-table-marker { position: fixed; background-color: var(--editor-color-primary); - z-index: 99; + z-index: 3; user-select: none; opacity: 0; &:hover, &.active { From a43a1832f5bd625d844903fd125ae39cbc50d5d5 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 26 May 2025 18:02:53 +0100 Subject: [PATCH 5/9] Lexical: Added image insert via image link paste Specifically added to align with existing TinyMCE behaviour which was used by some users based upon new editor feedback. --- .../wysiwyg/services/drop-paste-handling.ts | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/resources/js/wysiwyg/services/drop-paste-handling.ts b/resources/js/wysiwyg/services/drop-paste-handling.ts index 2ee831d74..57f9a80ae 100644 --- a/resources/js/wysiwyg/services/drop-paste-handling.ts +++ b/resources/js/wysiwyg/services/drop-paste-handling.ts @@ -95,6 +95,21 @@ function handleMediaInsert(data: DataTransfer, context: EditorUiContext): boolea return handled; } +function handleImageLinkInsert(data: DataTransfer, context: EditorUiContext): boolean { + const regex = /https?:\/\/([^?#]*?)\.(png|jpeg|jpg|gif|webp|bmp|avif)/i + const text = data.getData('text/plain'); + if (text && regex.test(text)) { + context.editor.update(() => { + const image = $createImageNode(text); + $insertNodes([image]); + image.select(); + }); + return true; + } + + return false; +} + function createDropListener(context: EditorUiContext): (event: DragEvent) => boolean { const editor = context.editor; return (event: DragEvent): boolean => { @@ -138,7 +153,10 @@ function createPasteListener(context: EditorUiContext): (event: ClipboardEvent) return false; } - const handled = handleMediaInsert(event.clipboardData, context); + const handled = + handleImageLinkInsert(event.clipboardData, context) || + handleMediaInsert(event.clipboardData, context); + if (handled) { event.preventDefault(); } From 2e718c12e120f5d39efc23eef995275fc6fb80a4 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 26 May 2025 18:47:51 +0100 Subject: [PATCH 6/9] Lexical: Changed table esacpe handling Avoids misuse of selectPrevious/Next as per prior commit which was then causing problems elsewhere, and is probably best to avoid creation in those select methods anyway. --- .../js/wysiwyg/lexical/core/LexicalNode.ts | 5 ++--- .../table/LexicalTableSelectionHelpers.ts | 5 +++-- .../js/wysiwyg/services/keyboard-handling.ts | 9 ++------- resources/js/wysiwyg/utils/nodes.ts | 19 ++++++++++++------- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/resources/js/wysiwyg/lexical/core/LexicalNode.ts b/resources/js/wysiwyg/lexical/core/LexicalNode.ts index e54cd1066..7306e6bca 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalNode.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalNode.ts @@ -48,7 +48,6 @@ import { internalMarkNodeAsDirty, removeFromParent, } from './LexicalUtils'; -import {$insertAndSelectNewEmptyAdjacentNode} from "../../utils/nodes"; export type NodeMap = Map; @@ -1131,7 +1130,7 @@ export class LexicalNode { const prevSibling = this.getPreviousSibling(); const parent = this.getParentOrThrow(); if (prevSibling === null) { - return $insertAndSelectNewEmptyAdjacentNode(this, false); + return parent.select(0, 0); } if ($isElementNode(prevSibling)) { return prevSibling.select(); @@ -1153,7 +1152,7 @@ export class LexicalNode { const nextSibling = this.getNextSibling(); const parent = this.getParentOrThrow(); if (nextSibling === null) { - return $insertAndSelectNewEmptyAdjacentNode(this, true); + return parent.select(); } if ($isElementNode(nextSibling)) { return nextSibling.select(0, 0); diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts b/resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts index d9164a778..448019669 100644 --- a/resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts +++ b/resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts @@ -71,6 +71,7 @@ import {TableDOMTable, TableObserver} from './LexicalTableObserver'; import {$isTableRowNode} from './LexicalTableRowNode'; import {$isTableSelection} from './LexicalTableSelection'; import {$computeTableMap, $getNodeTriplet} from './LexicalTableUtils'; +import {$selectOrCreateAdjacent} from "../../utils/nodes"; const LEXICAL_ELEMENT_KEY = '__lexicalTableSelection'; @@ -1113,7 +1114,7 @@ const selectTableNodeInDirection = ( false, ); } else { - tableNode.selectPrevious(); + $selectOrCreateAdjacent(tableNode, false); } return true; @@ -1125,7 +1126,7 @@ const selectTableNodeInDirection = ( true, ); } else { - tableNode.selectNext(); + $selectOrCreateAdjacent(tableNode, true); } return true; diff --git a/resources/js/wysiwyg/services/keyboard-handling.ts b/resources/js/wysiwyg/services/keyboard-handling.ts index 39818acb0..b4f546117 100644 --- a/resources/js/wysiwyg/services/keyboard-handling.ts +++ b/resources/js/wysiwyg/services/keyboard-handling.ts @@ -13,7 +13,7 @@ import { import {$isImageNode} from "@lexical/rich-text/LexicalImageNode"; import {$isMediaNode} from "@lexical/rich-text/LexicalMediaNode"; import {getLastSelection} from "../utils/selection"; -import {$getNearestNodeBlockParent, $getParentOfType} from "../utils/nodes"; +import {$getNearestNodeBlockParent, $getParentOfType, $selectOrCreateAdjacent} from "../utils/nodes"; import {$setInsetForSelection} from "../utils/lists"; import {$isListItemNode} from "@lexical/list"; import {$isDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; @@ -81,13 +81,8 @@ function focusAdjacentOrInsertForSingleSelectNode(editor: LexicalEditor, event: event?.preventDefault(); const node = selectionNodes[0]; - editor.update(() => { - if (after) { - node.selectNext(); - } else { - node.selectPrevious(); - } + $selectOrCreateAdjacent(node, after); }); return true; diff --git a/resources/js/wysiwyg/utils/nodes.ts b/resources/js/wysiwyg/utils/nodes.ts index ebf01e39d..778be5ba6 100644 --- a/resources/js/wysiwyg/utils/nodes.ts +++ b/resources/js/wysiwyg/utils/nodes.ts @@ -118,15 +118,20 @@ export function $sortNodes(nodes: LexicalNode[]): LexicalNode[] { return sorted; } -export function $insertAndSelectNewEmptyAdjacentNode(node: LexicalNode, after: boolean): RangeSelection { - const target = $createParagraphNode(); - if (after) { - node.insertAfter(target) - } else { - node.insertBefore(target); +export function $selectOrCreateAdjacent(node: LexicalNode, after: boolean): RangeSelection { + const nearestBlock = $getNearestNodeBlockParent(node) || node; + let target = after ? nearestBlock.getNextSibling() : nearestBlock.getPreviousSibling() + + if (!target) { + target = $createParagraphNode(); + if (after) { + node.insertAfter(target) + } else { + node.insertBefore(target); + } } - return target.select(); + return after ? target.selectStart() : target.selectEnd(); } export function nodeHasAlignment(node: object): node is NodeHasAlignment { From d9ea52522ea577e960ff978e186fd7c5226535f5 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 26 May 2025 19:06:36 +0100 Subject: [PATCH 7/9] Lexical: Fixed issues with recent changes --- resources/js/wysiwyg/ui/framework/buttons.ts | 10 ++++++++-- resources/js/wysiwyg/utils/nodes.ts | 4 ++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/resources/js/wysiwyg/ui/framework/buttons.ts b/resources/js/wysiwyg/ui/framework/buttons.ts index 0e1cab0f5..e12348e81 100644 --- a/resources/js/wysiwyg/ui/framework/buttons.ts +++ b/resources/js/wysiwyg/ui/framework/buttons.ts @@ -15,7 +15,7 @@ export interface EditorButtonDefinition extends EditorBasicButtonDefinition { * This can return false to indicate that the completion of the action should * NOT be communicated to parent UI elements, which is what occurs by default. */ - action: (context: EditorUiContext, button: EditorButton) => void|false; + action: (context: EditorUiContext, button: EditorButton) => void|false|Promise; isActive: (selection: BaseSelection|null, context: EditorUiContext) => boolean; isDisabled?: (selection: BaseSelection|null, context: EditorUiContext) => boolean; setup?: (context: EditorUiContext, button: EditorButton) => void; @@ -84,7 +84,13 @@ export class EditorButton extends EditorUiElement { protected onClick() { const result = this.definition.action(this.getContext(), this); - if (result !== false) { + if (result instanceof Promise) { + result.then(result => { + if (result === false) { + this.emitEvent('button-action'); + } + }); + } else if (result !== false) { this.emitEvent('button-action'); } } diff --git a/resources/js/wysiwyg/utils/nodes.ts b/resources/js/wysiwyg/utils/nodes.ts index 778be5ba6..116a3f4e5 100644 --- a/resources/js/wysiwyg/utils/nodes.ts +++ b/resources/js/wysiwyg/utils/nodes.ts @@ -125,9 +125,9 @@ export function $selectOrCreateAdjacent(node: LexicalNode, after: boolean): Rang if (!target) { target = $createParagraphNode(); if (after) { - node.insertAfter(target) + nearestBlock.insertAfter(target) } else { - node.insertBefore(target); + nearestBlock.insertBefore(target); } } From b862f12a5072ee1429f3eab6ae3be454f4fab5c4 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 28 May 2025 22:47:39 +0100 Subject: [PATCH 8/9] Lexical: Further improvements to table selection and captions - Fixed errors with selection and range handling due to captions existing. - Updated TableNode change handling to update existing DOM instead of re-creating, which avoids breaking an attached selection helper. - To support, Added function to handle node change detection and apply relevant dom updates for common properties. --- .../js/wysiwyg/lexical/core/nodes/common.ts | 33 +++++++++++++++ .../wysiwyg/lexical/table/LexicalTableNode.ts | 42 ++++++++++++------- .../table/LexicalTableSelectionHelpers.ts | 6 +-- .../lexical/table/LexicalTableUtils.ts | 3 +- .../helpers/table-selection-handler.ts | 2 +- resources/js/wysiwyg/utils/tables.ts | 19 ++++++++- 6 files changed, 84 insertions(+), 21 deletions(-) diff --git a/resources/js/wysiwyg/lexical/core/nodes/common.ts b/resources/js/wysiwyg/lexical/core/nodes/common.ts index eac9c8295..50d884344 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/common.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/common.ts @@ -1,5 +1,6 @@ import {sizeToPixels} from "../../../utils/dom"; import {SerializedCommonBlockNode} from "lexical/nodes/CommonBlockNode"; +import {elem} from "../../../../services/dom"; export type CommonBlockAlignment = 'left' | 'right' | 'center' | 'justify' | ''; const validAlignments: CommonBlockAlignment[] = ['left', 'right', 'center', 'justify']; @@ -82,6 +83,38 @@ export function commonPropertiesDifferent(nodeA: CommonBlockInterface, nodeB: Co nodeA.__dir !== nodeB.__dir; } +export function applyCommonPropertyChanges(prevNode: CommonBlockInterface, currentNode: CommonBlockInterface, element: HTMLElement): void { + if (prevNode.__id !== currentNode.__id) { + element.setAttribute('id', currentNode.__id); + } + + if (prevNode.__alignment !== currentNode.__alignment) { + for (const alignment of validAlignments) { + element.classList.remove('align-' + alignment); + } + + if (currentNode.__alignment) { + element.classList.add('align-' + currentNode.__alignment); + } + } + + if (prevNode.__inset !== currentNode.__inset) { + if (currentNode.__inset) { + element.style.paddingLeft = `${currentNode.__inset}px`; + } else { + element.style.removeProperty('paddingLeft'); + } + } + + if (prevNode.__dir !== currentNode.__dir) { + if (currentNode.__dir) { + element.dir = currentNode.__dir; + } else { + element.removeAttribute('dir'); + } + } +} + export function updateElementWithCommonBlockProps(element: HTMLElement, node: CommonBlockInterface): void { if (node.__id) { element.setAttribute('id', node.__id); diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts b/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts index 105764be2..460223bc9 100644 --- a/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts +++ b/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts @@ -30,12 +30,13 @@ import {TableDOMCell, TableDOMTable} from './LexicalTableObserver'; import {getTable} from './LexicalTableSelectionHelpers'; import {CommonBlockNode, copyCommonBlockProperties, SerializedCommonBlockNode} from "lexical/nodes/CommonBlockNode"; import { + applyCommonPropertyChanges, commonPropertiesDifferent, deserializeCommonBlockNode, setCommonBlockPropsFromElement, updateElementWithCommonBlockProps } from "lexical/nodes/common"; import {el, extractStyleMapFromElement, StyleMap} from "../../utils/dom"; -import {getTableColumnWidths} from "../../utils/tables"; +import {buildColgroupFromTableWidths, getTableColumnWidths} from "../../utils/tables"; export type SerializedTableNode = Spread<{ colWidths: string[]; @@ -98,15 +99,8 @@ export class TableNode extends CommonBlockNode { updateElementWithCommonBlockProps(tableElement, this); const colWidths = this.getColWidths(); - if (colWidths.length > 0) { - const colgroup = el('colgroup'); - for (const width of colWidths) { - const col = el('col'); - if (width) { - col.style.width = width; - } - colgroup.append(col); - } + const colgroup = buildColgroupFromTableWidths(colWidths); + if (colgroup) { tableElement.append(colgroup); } @@ -117,11 +111,29 @@ export class TableNode extends CommonBlockNode { return tableElement; } - updateDOM(_prevNode: TableNode): boolean { - return commonPropertiesDifferent(_prevNode, this) - || this.__colWidths.join(':') !== _prevNode.__colWidths.join(':') - || this.__styles.size !== _prevNode.__styles.size - || (Array.from(this.__styles.values()).join(':') !== (Array.from(_prevNode.__styles.values()).join(':'))); + updateDOM(_prevNode: TableNode, dom: HTMLElement): boolean { + applyCommonPropertyChanges(_prevNode, this, dom); + + if (this.__colWidths.join(':') !== _prevNode.__colWidths.join(':')) { + const existingColGroup = Array.from(dom.children).find(child => child.nodeName === 'COLGROUP'); + const newColGroup = buildColgroupFromTableWidths(this.__colWidths); + if (existingColGroup) { + existingColGroup.remove(); + } + + if (newColGroup) { + dom.prepend(newColGroup); + } + } + + if (Array.from(this.__styles.values()).join(':') !== Array.from(_prevNode.__styles.values()).join(':')) { + dom.style.cssText = ''; + for (const [name, value] of this.__styles.entries()) { + dom.style.setProperty(name, value); + } + } + + return false; } exportDOM(editor: LexicalEditor): DOMExportOutput { diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts b/resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts index 448019669..6e5e5416f 100644 --- a/resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts +++ b/resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts @@ -916,14 +916,14 @@ export function getTable(tableElement: HTMLElement): TableDOMTable { domRows.length = 0; while (currentNode != null) { - const nodeMame = currentNode.nodeName; + const nodeName = currentNode.nodeName; - if (nodeMame === 'COLGROUP') { + if (nodeName === 'COLGROUP' || nodeName === 'CAPTION') { currentNode = currentNode.nextSibling; continue; } - if (nodeMame === 'TD' || nodeMame === 'TH') { + if (nodeName === 'TD' || nodeName === 'TH') { const elem = currentNode as HTMLElement; const cell = { elem, diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableUtils.ts b/resources/js/wysiwyg/lexical/table/LexicalTableUtils.ts index cdbc84658..bd807d7f9 100644 --- a/resources/js/wysiwyg/lexical/table/LexicalTableUtils.ts +++ b/resources/js/wysiwyg/lexical/table/LexicalTableUtils.ts @@ -35,6 +35,7 @@ import { TableRowNode, } from './LexicalTableRowNode'; import {$isTableSelection} from './LexicalTableSelection'; +import {$isCaptionNode} from "@lexical/table/LexicalCaptionNode"; export function $createTableNodeWithDimensions( rowCount: number, @@ -779,7 +780,7 @@ export function $computeTableMapSkipCellCheck( return tableMap[row] === undefined || tableMap[row][column] === undefined; } - const gridChildren = grid.getChildren(); + const gridChildren = grid.getChildren().filter(node => !$isCaptionNode(node)); for (let i = 0; i < gridChildren.length; i++) { const row = gridChildren[i]; invariant( diff --git a/resources/js/wysiwyg/ui/framework/helpers/table-selection-handler.ts b/resources/js/wysiwyg/ui/framework/helpers/table-selection-handler.ts index d3d892550..c05e448f5 100644 --- a/resources/js/wysiwyg/ui/framework/helpers/table-selection-handler.ts +++ b/resources/js/wysiwyg/ui/framework/helpers/table-selection-handler.ts @@ -56,7 +56,7 @@ class TableSelectionHandler { tableNode, tableElement, this.editor, - false, + true, ); this.tableSelections.set(nodeKey, tableSelection); } diff --git a/resources/js/wysiwyg/utils/tables.ts b/resources/js/wysiwyg/utils/tables.ts index ed947ddcd..8f4a6599f 100644 --- a/resources/js/wysiwyg/utils/tables.ts +++ b/resources/js/wysiwyg/utils/tables.ts @@ -9,7 +9,7 @@ import { } from "@lexical/table"; import {$getParentOfType} from "./nodes"; import {$getNodeFromSelection} from "./selection"; -import {formatSizeValue} from "./dom"; +import {el, formatSizeValue} from "./dom"; import {TableMap} from "./table-map"; function $getTableFromCell(cell: TableCellNode): TableNode|null { @@ -140,6 +140,23 @@ export function $getTableCellColumnWidth(editor: LexicalEditor, cell: TableCellN return (widths.length > index) ? widths[index] : ''; } +export function buildColgroupFromTableWidths(colWidths: string[]): HTMLElement|null { + if (colWidths.length === 0) { + return null + } + + const colgroup = el('colgroup'); + for (const width of colWidths) { + const col = el('col'); + if (width) { + col.style.width = width; + } + colgroup.append(col); + } + + return colgroup; +} + export function $getTableCellsFromSelection(selection: BaseSelection|null): TableCellNode[] { if ($isTableSelection(selection)) { const nodes = selection.getNodes(); From c5ca8657234ad1f8866f3a868d4f5230c6b0d1dd Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 28 May 2025 22:52:09 +0100 Subject: [PATCH 9/9] Lexical: Updated WYSIWYG editor status from alpha to beta --- lang/en/entities.php | 2 +- resources/views/settings/categories/customization.blade.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lang/en/entities.php b/lang/en/entities.php index 6e616ded4..561022ad6 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -248,7 +248,7 @@ return [ 'pages_edit_switch_to_markdown_stable' => '(Stable Content)', 'pages_edit_switch_to_wysiwyg' => 'Switch to WYSIWYG Editor', 'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG', - 'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)', + 'pages_edit_switch_to_new_wysiwyg_desc' => '(In Beta Testing)', 'pages_edit_set_changelog' => 'Set Changelog', 'pages_edit_enter_changelog_desc' => 'Enter a brief description of the changes you\'ve made', 'pages_edit_enter_changelog' => 'Enter Changelog', diff --git a/resources/views/settings/categories/customization.blade.php b/resources/views/settings/categories/customization.blade.php index 70a490298..732cb0198 100644 --- a/resources/views/settings/categories/customization.blade.php +++ b/resources/views/settings/categories/customization.blade.php @@ -32,7 +32,7 @@