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).
This commit is contained in:
Dan Brown 2025-05-25 16:28:42 +01:00
parent 3280919370
commit 1243108e0f
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
7 changed files with 120 additions and 43 deletions

View 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;
}
}

View 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;
}

View File

@ -19,6 +19,7 @@ export class EditorOverflowContainer extends EditorContainerUiElement {
label: 'More',
icon: moreHorizontal,
},
hideOnAction: false,
}, []);
this.addChildren(this.overflowButton);
}

View 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) {

View 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 {

View 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) => {
// 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);
});
}
}
}

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