| | import { generateId, wait } from "./shared_utils.js";
|
| | import { createElement as $el, getClosestOrSelf, setAttributes} from "./utils_dom.js";
|
| |
|
| | |
| | |
| |
|
| | class Menu {
|
| |
|
| | private element: HTMLMenuElement = $el('menu.rgthree-menu');
|
| | private callbacks: Map<string, (e: PointerEvent) => Promise<boolean|void>> = new Map();
|
| |
|
| | private handleWindowPointerDownBound = this.handleWindowPointerDown.bind(this);
|
| |
|
| | constructor(options: MenuOption[]) {
|
| | this.setOptions(options);
|
| | this.element.addEventListener('pointerup', async (e) => {
|
| | const target = getClosestOrSelf(e.target as HTMLElement, "[data-callback],menu");
|
| | if (e.which !== 1) {
|
| | return;
|
| | }
|
| | const callback = target?.dataset?.['callback'];
|
| | if (callback) {
|
| | const halt = await this.callbacks.get(callback)?.(e);
|
| | if (halt !== false) {
|
| | this.close();
|
| | }
|
| | }
|
| | e.preventDefault();
|
| | e.stopPropagation();
|
| | e.stopImmediatePropagation();
|
| | });
|
| | }
|
| |
|
| | setOptions(options: MenuOption[]) {
|
| | for (const option of options) {
|
| | if (option.type === 'title') {
|
| | this.element.appendChild($el(`li`, {
|
| | html: option.label
|
| | }));
|
| | } else {
|
| | const id = generateId(8);
|
| | this.callbacks.set(id, async (e: PointerEvent) => { return option?.callback?.(e); });
|
| | this.element.appendChild($el(`li[role="button"][data-callback="${id}"]`, {
|
| | html: option.label
|
| | }));
|
| | }
|
| | }
|
| | }
|
| |
|
| | toElement() {
|
| | return this.element;
|
| | }
|
| |
|
| | async open(e: PointerEvent) {
|
| | const parent = (e.target as HTMLElement).closest('div,dialog,body') as HTMLElement
|
| | parent.appendChild(this.element);
|
| | setAttributes(this.element, {
|
| | style: {
|
| | left: `${e.clientX + 16}px`,
|
| | top: `${e.clientY - 16}px`,
|
| | }
|
| | });
|
| | this.element.setAttribute('state', 'measuring-open');
|
| | await wait(16);
|
| | const rect = this.element.getBoundingClientRect();
|
| | if (rect.right > window.innerWidth) {
|
| | this.element.style.left = `${e.clientX - rect.width - 16}px`;
|
| | await wait(16);
|
| | }
|
| | this.element.setAttribute('state', 'open');
|
| | setTimeout(() => {
|
| | window.addEventListener('pointerdown', this.handleWindowPointerDownBound);
|
| | });
|
| | }
|
| |
|
| | handleWindowPointerDown(e:PointerEvent) {
|
| | if (!this.element.contains(e.target as HTMLElement)) {
|
| | this.close();
|
| | }
|
| | }
|
| |
|
| | async close() {
|
| | window.removeEventListener('pointerdown', this.handleWindowPointerDownBound);
|
| | this.element.setAttribute('state', 'measuring-closed');
|
| | await wait(16);
|
| | this.element.setAttribute('state', 'closed');
|
| | this.element.remove();
|
| | }
|
| |
|
| | isOpen() {
|
| | return (this.element.getAttribute('state') || '').includes('open');
|
| | }
|
| |
|
| | }
|
| |
|
| | type MenuOption = {
|
| | label: string;
|
| | type?: 'title'|'item'|'separator';
|
| | callback?: (e: PointerEvent) => void;
|
| | }
|
| |
|
| | type MenuButtonOptions = {
|
| | icon: string;
|
| | options: MenuOption[];
|
| | }
|
| |
|
| | export class MenuButton {
|
| |
|
| | private options: MenuButtonOptions;
|
| | private menu: Menu;
|
| |
|
| | private element: HTMLButtonElement = $el('button.rgthree-button[data-action="open-menu"]')
|
| |
|
| | constructor(options: MenuButtonOptions) {
|
| | this.options = options;
|
| | this.element.innerHTML = options.icon;
|
| | this.menu = new Menu(options.options);
|
| |
|
| | this.element.addEventListener('pointerdown', (e) => {
|
| | if (!this.menu.isOpen()) {
|
| | this.menu.open(e);
|
| | }
|
| | });
|
| | }
|
| |
|
| | toElement() {
|
| | return this.element;
|
| | }
|
| |
|
| | } |