Skip to content

Commit 1243108

Browse files
committed
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).
1 parent 3280919 commit 1243108

File tree

7 files changed

+118
-41
lines changed

7 files changed

+118
-41
lines changed

resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import {handleDropdown} from "../helpers/dropdowns";
21
import {EditorContainerUiElement, EditorUiElement} from "../core";
32
import {EditorBasicButtonDefinition, EditorButton} from "../buttons";
43
import {el} from "../../../utils/dom";
@@ -8,13 +7,15 @@ export type EditorDropdownButtonOptions = {
87
showOnHover?: boolean;
98
direction?: 'vertical'|'horizontal';
109
showAside?: boolean;
10+
hideOnAction?: boolean;
1111
button: EditorBasicButtonDefinition|EditorButton;
1212
};
1313

1414
const defaultOptions: EditorDropdownButtonOptions = {
1515
showOnHover: false,
1616
direction: 'horizontal',
1717
showAside: undefined,
18+
hideOnAction: true,
1819
button: {label: 'Menu'},
1920
}
2021

@@ -40,7 +41,7 @@ export class EditorDropdownButton extends EditorContainerUiElement {
4041
},
4142
isActive: () => {
4243
return this.open;
43-
}
44+
},
4445
});
4546
}
4647

@@ -65,7 +66,7 @@ export class EditorDropdownButton extends EditorContainerUiElement {
6566
class: 'editor-dropdown-menu-container',
6667
}, [button, menu]);
6768

68-
handleDropdown({toggle: button, menu : menu,
69+
this.getContext().manager.dropdowns.handle({toggle: button, menu : menu,
6970
showOnHover: this.options.showOnHover,
7071
showAside: typeof this.options.showAside === 'boolean' ? this.options.showAside : (this.options.direction === 'vertical'),
7172
onOpen : () => {
@@ -76,6 +77,12 @@ export class EditorDropdownButton extends EditorContainerUiElement {
7677
this.getContext().manager.triggerStateUpdateForElement(this.button);
7778
}});
7879

80+
if (this.options.hideOnAction) {
81+
this.onEvent('button-action', () => {
82+
this.getContext().manager.dropdowns.closeAll();
83+
}, wrapper);
84+
}
85+
7986
return wrapper;
8087
}
8188
}

resources/js/wysiwyg/ui/framework/blocks/format-menu.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import {EditorUiStateUpdate, EditorContainerUiElement} from "../core";
22
import {EditorButton} from "../buttons";
3-
import {handleDropdown} from "../helpers/dropdowns";
43
import {el} from "../../../utils/dom";
54

65
export class EditorFormatMenu extends EditorContainerUiElement {
@@ -20,7 +19,11 @@ export class EditorFormatMenu extends EditorContainerUiElement {
2019
class: 'editor-format-menu editor-dropdown-menu-container',
2120
}, [toggle, menu]);
2221

23-
handleDropdown({toggle : toggle, menu : menu});
22+
this.getContext().manager.dropdowns.handle({toggle : toggle, menu : menu});
23+
24+
this.onEvent('button-action', () => {
25+
this.getContext().manager.dropdowns.closeAll();
26+
}, wrapper);
2427

2528
return wrapper;
2629
}

resources/js/wysiwyg/ui/framework/blocks/overflow-container.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export class EditorOverflowContainer extends EditorContainerUiElement {
1919
label: 'More',
2020
icon: moreHorizontal,
2121
},
22+
hideOnAction: false,
2223
}, []);
2324
this.addChildren(this.overflowButton);
2425
}

resources/js/wysiwyg/ui/framework/buttons.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@ export interface EditorBasicButtonDefinition {
1010
}
1111

1212
export interface EditorButtonDefinition extends EditorBasicButtonDefinition {
13-
action: (context: EditorUiContext, button: EditorButton) => void;
13+
/**
14+
* The action to perform when the button is used.
15+
* This can return false to indicate that the completion of the action should
16+
* NOT be communicated to parent UI elements, which is what occurs by default.
17+
*/
18+
action: (context: EditorUiContext, button: EditorButton) => void|false;
1419
isActive: (selection: BaseSelection|null, context: EditorUiContext) => boolean;
1520
isDisabled?: (selection: BaseSelection|null, context: EditorUiContext) => boolean;
1621
setup?: (context: EditorUiContext, button: EditorButton) => void;
@@ -78,7 +83,10 @@ export class EditorButton extends EditorUiElement {
7883
}
7984

8085
protected onClick() {
81-
this.definition.action(this.getContext(), this);
86+
const result = this.definition.action(this.getContext(), this);
87+
if (result !== false) {
88+
this.emitEvent('button-action');
89+
}
8290
}
8391

8492
protected updateActiveState(selection: BaseSelection|null) {

resources/js/wysiwyg/ui/framework/core.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,21 @@ export abstract class EditorUiElement {
6767
updateState(state: EditorUiStateUpdate): void {
6868
return;
6969
}
70+
71+
emitEvent(name: string, data: object = {}): void {
72+
if (this.dom) {
73+
this.dom.dispatchEvent(new CustomEvent('editor::' + name, {detail: data, bubbles: true}));
74+
}
75+
}
76+
77+
onEvent(name: string, callback: (data: object) => any, listenTarget: HTMLElement|null = null): void {
78+
const target = listenTarget || this.dom;
79+
if (target) {
80+
target.addEventListener('editor::' + name, ((event: CustomEvent) => {
81+
callback(event.detail);
82+
}) as EventListener);
83+
}
84+
}
7085
}
7186

7287
export class EditorContainerUiElement extends EditorUiElement {

resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts

Lines changed: 74 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -34,57 +34,97 @@ function positionMenu(menu: HTMLElement, toggle: HTMLElement, showAside: boolean
3434
}
3535
}
3636

37-
export function handleDropdown(options: HandleDropdownParams) {
38-
const {menu, toggle, onClose, onOpen, showOnHover, showAside} = options;
39-
let clickListener: Function|null = null;
37+
export class DropDownManager {
4038

41-
const hide = () => {
39+
protected dropdownOptions: WeakMap<HTMLElement, HandleDropdownParams> = new WeakMap();
40+
protected openDropdowns: Set<HTMLElement> = new Set();
41+
42+
constructor() {
43+
this.onMenuMouseOver = this.onMenuMouseOver.bind(this);
44+
45+
window.addEventListener('click', (event: MouseEvent) => {
46+
const target = event.target as HTMLElement;
47+
this.closeAllNotContainingElement(target);
48+
});
49+
}
50+
51+
protected closeAllNotContainingElement(element: HTMLElement): void {
52+
for (const menu of this.openDropdowns) {
53+
if (!menu.parentElement?.contains(element)) {
54+
this.closeDropdown(menu);
55+
}
56+
}
57+
}
58+
59+
protected onMenuMouseOver(event: MouseEvent): void {
60+
const target = event.target as HTMLElement;
61+
this.closeAllNotContainingElement(target);
62+
}
63+
64+
/**
65+
* Close all open dropdowns.
66+
*/
67+
public closeAll(): void {
68+
for (const menu of this.openDropdowns) {
69+
this.closeDropdown(menu);
70+
}
71+
}
72+
73+
protected closeDropdown(menu: HTMLElement): void {
4274
menu.hidden = true;
4375
menu.style.removeProperty('position');
4476
menu.style.removeProperty('left');
4577
menu.style.removeProperty('top');
46-
if (clickListener) {
47-
window.removeEventListener('click', clickListener as EventListener);
48-
}
78+
79+
this.openDropdowns.delete(menu);
80+
menu.removeEventListener('mouseover', this.onMenuMouseOver);
81+
82+
const onClose = this.getOptions(menu).onClose;
4983
if (onClose) {
5084
onClose();
5185
}
52-
};
86+
}
5387

54-
const show = () => {
88+
protected openDropdown(menu: HTMLElement): void {
89+
const {toggle, showAside, onOpen} = this.getOptions(menu);
5590
menu.hidden = false
5691
positionMenu(menu, toggle, Boolean(showAside));
57-
clickListener = (event: MouseEvent) => {
58-
if (!toggle.contains(event.target as HTMLElement) && !menu.contains(event.target as HTMLElement)) {
59-
hide();
60-
}
61-
}
62-
window.addEventListener('click', clickListener as EventListener);
92+
93+
this.openDropdowns.add(menu);
94+
menu.addEventListener('mouseover', this.onMenuMouseOver);
95+
6396
if (onOpen) {
6497
onOpen();
6598
}
66-
};
67-
68-
const toggleShowing = (event: MouseEvent) => {
69-
menu.hasAttribute('hidden') ? show() : hide();
70-
};
71-
toggle.addEventListener('click', toggleShowing);
72-
if (showOnHover) {
73-
toggle.addEventListener('mouseenter', toggleShowing);
7499
}
75100

76-
menu.parentElement?.addEventListener('mouseleave', (event: MouseEvent) => {
101+
protected getOptions(menu: HTMLElement): HandleDropdownParams {
102+
const options = this.dropdownOptions.get(menu);
103+
if (!options) {
104+
throw new Error(`Can't find options for dropdown menu`);
105+
}
106+
107+
return options;
108+
}
109+
110+
/**
111+
* Add handling for a new dropdown.
112+
*/
113+
public handle(options: HandleDropdownParams) {
114+
const {menu, toggle, showOnHover} = options;
77115

78-
// Prevent mouseleave hiding if withing the same bounds of the toggle.
79-
// Avoids hiding in the event the mouse is interrupted by a high z-index
80-
// item like a browser scrollbar.
81-
const toggleBounds = toggle.getBoundingClientRect();
82-
const withinX = event.clientX <= toggleBounds.right && event.clientX >= toggleBounds.left;
83-
const withinY = event.clientY <= toggleBounds.bottom && event.clientY >= toggleBounds.top;
84-
const withinToggle = withinX && withinY;
116+
// Register dropdown
117+
this.dropdownOptions.set(menu, options);
85118

86-
if (!withinToggle) {
87-
hide();
119+
// Configure default events
120+
const toggleShowing = (event: MouseEvent) => {
121+
menu.hasAttribute('hidden') ? this.openDropdown(menu) : this.closeDropdown(menu);
122+
};
123+
toggle.addEventListener('click', toggleShowing);
124+
if (showOnHover) {
125+
toggle.addEventListener('mouseenter', () => {
126+
this.openDropdown(menu);
127+
});
88128
}
89-
});
129+
}
90130
}

resources/js/wysiwyg/ui/framework/manager.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {DecoratorListener} from "lexical/LexicalEditor";
66
import type {NodeKey} from "lexical/LexicalNode";
77
import {EditorContextToolbar, EditorContextToolbarDefinition} from "./toolbars";
88
import {getLastSelection, setLastSelection} from "../../utils/selection";
9+
import {DropDownManager} from "./helpers/dropdowns";
910

1011
export type SelectionChangeHandler = (selection: BaseSelection|null) => void;
1112

@@ -21,6 +22,8 @@ export class EditorUIManager {
2122
protected activeContextToolbars: EditorContextToolbar[] = [];
2223
protected selectionChangeHandlers: Set<SelectionChangeHandler> = new Set();
2324

25+
public dropdowns: DropDownManager = new DropDownManager();
26+
2427
setContext(context: EditorUiContext) {
2528
this.context = context;
2629
this.setupEventListeners(context);

0 commit comments

Comments
 (0)