]> BookStack Code Mirror - bookstack/commitdiff
Merge pull request #5627 from BookStackApp/lexical_20250525
authorDan Brown <redacted>
Wed, 28 May 2025 21:53:03 +0000 (22:53 +0100)
committerGitHub <redacted>
Wed, 28 May 2025 21:53:03 +0000 (22:53 +0100)
Lexical Editor: Further fixes

21 files changed:
lang/en/entities.php
resources/js/wysiwyg/lexical/core/LexicalSelection.ts
resources/js/wysiwyg/lexical/core/nodes/common.ts
resources/js/wysiwyg/lexical/table/LexicalTableNode.ts
resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts
resources/js/wysiwyg/lexical/table/LexicalTableUtils.ts
resources/js/wysiwyg/services/drop-paste-handling.ts
resources/js/wysiwyg/services/keyboard-handling.ts
resources/js/wysiwyg/ui/decorators/diagram.ts
resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts
resources/js/wysiwyg/ui/framework/blocks/format-menu.ts
resources/js/wysiwyg/ui/framework/blocks/overflow-container.ts
resources/js/wysiwyg/ui/framework/buttons.ts
resources/js/wysiwyg/ui/framework/core.ts
resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts
resources/js/wysiwyg/ui/framework/helpers/table-selection-handler.ts
resources/js/wysiwyg/ui/framework/manager.ts
resources/js/wysiwyg/utils/nodes.ts
resources/js/wysiwyg/utils/tables.ts
resources/sass/_editor.scss
resources/views/settings/categories/customization.blade.php

index 6e616ded452c53b1e168da3561ff2bac1259c7c7..561022ad6b6b9bd34c0579ce9379142c7f5aacab 100644 (file)
@@ -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',
index db18cfc4a779fe65dfbd7d7d679d03df05285172..297286a4b8ca6069a07b3a42a90c24fa1b9b25de 100644 (file)
@@ -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
index eac9c82959573ee8d74b5f7d1ee17e18cae05492..50d8843440c87f2b0a66eed69a4dfb66d3ff455c 100644 (file)
@@ -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);
index a103614753ef88b2eaaeb8d3efe1316ee4144637..460223bc9ce9dc54d4b56d7c64b8e916ad94e24a 100644 (file)
@@ -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[];
@@ -54,7 +55,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;
   }
@@ -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 {
@@ -169,7 +181,7 @@ export class TableNode extends CommonBlockNode {
 
   getColWidths(): string[] {
     const self = this.getLatest();
-    return self.__colWidths;
+    return [...self.__colWidths];
   }
 
   getStyles(): StyleMap {
index e098a21e498a64988efaa5960c1ab8842e4662a3..6e5e5416fa03f7fe68b19c5930f4d5bd00aaa7b8 100644 (file)
@@ -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';
 
@@ -915,9 +916,14 @@ export function getTable(tableElement: HTMLElement): TableDOMTable {
   domRows.length = 0;
 
   while (currentNode != null) {
-    const nodeMame = currentNode.nodeName;
+    const nodeName = currentNode.nodeName;
 
-    if (nodeMame === 'TD' || nodeMame === 'TH') {
+    if (nodeName === 'COLGROUP' || nodeName === 'CAPTION') {
+      currentNode = currentNode.nextSibling;
+      continue;
+    }
+
+    if (nodeName === 'TD' || nodeName === 'TH') {
       const elem = currentNode as HTMLElement;
       const cell = {
         elem,
@@ -1108,7 +1114,7 @@ const selectTableNodeInDirection = (
           false,
         );
       } else {
-        tableNode.selectPrevious();
+        $selectOrCreateAdjacent(tableNode, false);
       }
 
       return true;
@@ -1120,7 +1126,7 @@ const selectTableNodeInDirection = (
           true,
         );
       } else {
-        tableNode.selectNext();
+        $selectOrCreateAdjacent(tableNode, true);
       }
 
       return true;
index cdbc846584d2a0d20bc5edcae4d773c9214c77e5..bd807d7f993d9cb44f7d25697bc717fff3621eb9 100644 (file)
@@ -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(
index 2ee831d74fc0e541a7f592dcbcfbc5bfd6a94914..57f9a80ae18fa8076cc1288c31b61eebb9f6c7fa 100644 (file)
@@ -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();
         }
index a7f1ec7f0b08342aaa277321b3afa0f4fbdce12a..b4f546117bb9951959f7ea21e2f08b5ef5fd5bcf 100644 (file)
@@ -13,15 +13,16 @@ 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";
+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;
         }
     }
@@ -46,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();
                 });
             });
@@ -74,22 +80,9 @@ 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);
-            }
-        }
-
-        target.selectStart();
+        $selectOrCreateAdjacent(node, after);
     });
 
     return true;
@@ -219,7 +212,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);
 
index d53bcb482ec78b5850eb202d8fe4b623a16c51b0..52a73ad7223332d9abe1b971bae69bdae8f6bd9f 100644 (file)
@@ -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(() => {
index d7f02d5732b96e7d167f40249dd2eb8314d5fb9d..45cb74dd4ae57af234e070b138cf7c5a591a9148 100644 (file)
@@ -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
index d666954bf057aab72192f87d0e0b427bc7cbf1b3..5d629493503b99406d6186bdc3fcdf209387853e 100644 (file)
@@ -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;
     }
index cd07805341554419e7f983224281d98e5e869449..1c96645058e96d7a3c701419622c53bc1700fe7d 100644 (file)
@@ -19,6 +19,7 @@ export class EditorOverflowContainer extends EditorContainerUiElement {
                 label: 'More',
                 icon: moreHorizontal,
             },
+            hideOnAction: false,
         }, []);
         this.addChildren(this.overflowButton);
     }
index cf114aa021256c9e075591a06331500c88162580..e12348e814be1cd6df53118663f324ce736849d6 100644 (file)
@@ -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|Promise<void|boolean>;
     isActive: (selection: BaseSelection|null, context: EditorUiContext) => boolean;
     isDisabled?: (selection: BaseSelection|null, context: EditorUiContext) => boolean;
     setup?: (context: EditorUiContext, button: EditorButton) => void;
@@ -78,7 +83,16 @@ export class EditorButton extends EditorUiElement {
     }
 
     protected onClick() {
-        this.definition.action(this.getContext(), this);
+        const result = this.definition.action(this.getContext(), this);
+        if (result instanceof Promise) {
+            result.then(result => {
+                if (result === false) {
+                    this.emitEvent('button-action');
+                }
+            });
+        } else if (result !== false) {
+            this.emitEvent('button-action');
+        }
     }
 
     protected updateActiveState(selection: BaseSelection|null) {
index 90ce4ebf93cb4fc8a1ad44497206370c279b8b6f..ca2ba40c6fc369b5a39e4e6e9c7f1061611b96f9 100644 (file)
@@ -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 {
index ccced68586748a1dfdc755e72e2783c190b30c95..751c1b3f207233134bcb8c0a3a70009d054fa8a4 100644 (file)
@@ -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<HTMLElement, HandleDropdownParams> = new WeakMap();
+    protected openDropdowns: Set<HTMLElement> = 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) => {
+    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;
 
-        // 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;
+        // Register dropdown
+        this.dropdownOptions.set(menu, options);
 
-        if (!withinToggle) {
-            hide();
+        // 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
index d3d8925505f8a71074b4138c35caec38c51b611e..c05e448f5a08f8ba890cce8344d17ead23be7e87 100644 (file)
@@ -56,7 +56,7 @@ class TableSelectionHandler {
                 tableNode,
                 tableElement,
                 this.editor,
-                false,
+                true,
             );
             this.tableSelections.set(nodeKey, tableSelection);
         }
index 0f501d9faae713063f4dcbbaa23d40e468c53225..2d15b341bdba6ae9a39e9236c77e5a6742854c4f 100644 (file)
@@ -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<SelectionChangeHandler> = new Set();
 
+    public dropdowns: DropDownManager = new DropDownManager();
+
     setContext(context: EditorUiContext) {
         this.context = context;
         this.setupEventListeners(context);
@@ -241,6 +244,7 @@ export class EditorUIManager {
             if (selectionChange) {
                 editor.update(() => {
                     const selection = $getSelection();
+                    // console.log('manager::selection', selection);
                     this.triggerStateUpdate({
                         editor, selection,
                     });
index 591232ea385b994a9e4c9901ae2027a1a44afedf..116a3f4e5c01e0a39ee0ed0b564dd325a226b99d 100644 (file)
@@ -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,22 @@ export function $sortNodes(nodes: LexicalNode[]): LexicalNode[] {
     return sorted;
 }
 
+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) {
+            nearestBlock.insertAfter(target)
+        } else {
+            nearestBlock.insertBefore(target);
+        }
+    }
+
+    return after ? target.selectStart() : target.selectEnd();
+}
+
 export function nodeHasAlignment(node: object): node is NodeHasAlignment {
     return '__alignment' in node;
 }
index ed947ddcdcbce26fa1e4454a805e1a721f1d7816..8f4a6599f9a17d760fceb36d624b5a97d95603c6 100644 (file)
@@ -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();
index 35f11c5a2790d7b131c18c24466de1c76b01064e..4112f62888c5f7e4135faa23b23e006578285d20 100644 (file)
@@ -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 {
index 70a490298c779022f1769387e996be370025985d..732cb01988a140f32b684f1aee91f6297941cdd7 100644 (file)
@@ -32,7 +32,7 @@
                     <select name="setting-app-editor" id="setting-app-editor">
                         <option @if(setting('app-editor') === 'wysiwyg') selected @endif value="wysiwyg">WYSIWYG</option>
                         <option @if(setting('app-editor') === 'markdown') selected @endif value="markdown">Markdown</option>
-                        <option @if(setting('app-editor') === 'wysiwyg2024') selected @endif value="wysiwyg2024">New WYSIWYG (alpha testing)</option>
+                        <option @if(setting('app-editor') === 'wysiwyg2024') selected @endif value="wysiwyg2024">New WYSIWYG (beta testing)</option>
                     </select>
                 </div>
             </div>