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);