]> BookStack Code Mirror - bookstack/commitdiff
Lexical: Updated dropdown handling to match tinymce behaviour
authorDan Brown <redacted>
Sun, 25 May 2025 15:28:42 +0000 (16:28 +0100)
committerDan Brown <redacted>
Sun, 25 May 2025 15:28:42 +0000 (16:28 +0100)
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).

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/manager.ts

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..0e1cab0f569a7d64a96c7b18669dd6985ae8c514 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;
     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) {
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 0f501d9faae713063f4dcbbaa23d40e468c53225..c80291fb707528583508bfe7fe2528ce7d391b49 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);