import * as plugins from './00plugins.js'; import { demoFunc } from './dees-contextmenu.demo.js'; import { customElement, html, DeesElement, property, type TemplateResult, cssManager, css, type CSSResult, unsafeCSS, } from '@design.estate/dees-element'; import * as domtools from '@design.estate/dees-domtools'; import { DeesWindowLayer } from './dees-windowlayer.js'; import { zIndexLayers } from './00zindex.js'; import './dees-icon.js'; declare global { interface HTMLElementTagNameMap { 'dees-contextmenu': DeesContextmenu; } } @customElement('dees-contextmenu') export class DeesContextmenu extends DeesElement { // DEMO public static demo = demoFunc // STATIC // This will store all the accumulated menu items public static contextMenuDeactivated = false; public static accumulatedMenuItems: (plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean; submenu?: (plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean } | { divider: true })[] } | { divider: true })[] = []; // Add a global event listener for the right-click context menu public static initializeGlobalListener() { document.addEventListener('contextmenu', (event: MouseEvent) => { if (this.contextMenuDeactivated) { return; } event.preventDefault(); // Clear previously accumulated items DeesContextmenu.accumulatedMenuItems = []; // Use composedPath to properly traverse shadow DOM boundaries const path = event.composedPath(); // Traverse the composed path to accumulate menu items for (const element of path) { if ((element as any).getContextMenuItems) { const items = (element as any).getContextMenuItems(); if (items && items.length > 0) { if (DeesContextmenu.accumulatedMenuItems.length > 0) { DeesContextmenu.accumulatedMenuItems.push({ divider: true }); } DeesContextmenu.accumulatedMenuItems.push(...items); } } } // Open the context menu with the accumulated items DeesContextmenu.openContextMenuWithOptions(event, DeesContextmenu.accumulatedMenuItems); }); } // allows opening of a contextmenu with options public static async openContextMenuWithOptions(eventArg: MouseEvent, menuItemsArg: (plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean; submenu?: (plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean } | { divider: true })[] } | { divider: true })[]) { if (this.contextMenuDeactivated) { return; } eventArg.preventDefault(); eventArg.stopPropagation(); const contextMenu = new DeesContextmenu(); contextMenu.style.position = 'fixed'; contextMenu.style.zIndex = String(zIndexLayers.overlay.contextMenu); contextMenu.style.opacity = '0'; contextMenu.style.transform = 'scale(0.95) translateY(-10px)'; contextMenu.menuItems = menuItemsArg; contextMenu.windowLayer = await DeesWindowLayer.createAndShow(); contextMenu.windowLayer.addEventListener('click', async (event) => { // Check if click is on the context menu or its submenus const clickedElement = event.target as HTMLElement; const isContextMenu = clickedElement.closest('dees-contextmenu'); if (!isContextMenu) { await contextMenu.destroy(); } }) document.body.append(contextMenu); // Get dimensions after adding to DOM await domtools.plugins.smartdelay.delayFor(0); const rect = contextMenu.getBoundingClientRect(); const windowWidth = window.innerWidth; const windowHeight = window.innerHeight; // Calculate position let top = eventArg.clientY; let left = eventArg.clientX; // Adjust if menu would go off right edge if (left + rect.width > windowWidth) { left = windowWidth - rect.width - 10; } // Adjust if menu would go off bottom edge if (top + rect.height > windowHeight) { top = windowHeight - rect.height - 10; } // Ensure menu doesn't go off left or top edge if (left < 10) left = 10; if (top < 10) top = 10; contextMenu.style.top = `${top}px`; contextMenu.style.left = `${left}px`; contextMenu.style.transformOrigin = 'top left'; // Animate in await domtools.plugins.smartdelay.delayFor(0); contextMenu.style.opacity = '1'; contextMenu.style.transform = 'scale(1) translateY(0)'; } // INSTANCE @property({ type: Array, }) public menuItems: (plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean; submenu?: (plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean } | { divider: true })[]; divider?: never } | { divider: true })[] = []; windowLayer: DeesWindowLayer; private submenu: DeesContextmenu | null = null; private submenuTimeout: any = null; private parentMenu: DeesContextmenu | null = null; constructor() { super(); this.tabIndex = 0; } /** * STATIC STYLES */ public static styles = [ cssManager.defaultStyles, css` :host { display: block; transition: opacity 0.2s, transform 0.2s; outline: none; } .mainbox { min-width: 200px; max-width: 280px; background: ${cssManager.bdTheme('#ffffff', '#000000')}; border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')}; border-radius: 4px; box-shadow: ${cssManager.bdTheme( '0 4px 12px rgba(0, 0, 0, 0.15)', '0 4px 12px rgba(0, 0, 0, 0.3)' )}; user-select: none; padding: 4px 0; font-size: 12px; color: ${cssManager.bdTheme('#333', '#ccc')}; } .menuitem { display: flex; align-items: center; gap: 8px; padding: 8px 12px; cursor: default; transition: background 0.1s; line-height: 1; position: relative; } .menuitem:hover { background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.04)', 'rgba(255, 255, 255, 0.08)')}; } .menuitem.has-submenu::after { content: '›'; position: absolute; right: 8px; font-size: 16px; opacity: 0.5; } .menuitem:active:not(.has-submenu) { background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.08)', 'rgba(255, 255, 255, 0.12)')}; } .menuitem.disabled { opacity: 0.5; cursor: not-allowed; pointer-events: none; } .menuitem dees-icon { font-size: 14px; opacity: 0.7; } .menuitem-text { flex: 1; } .menuitem-shortcut { font-size: 11px; color: ${cssManager.bdTheme('#999', '#666')}; margin-left: auto; opacity: 0.7; } .menu-divider { height: 1px; background: ${cssManager.bdTheme('#e0e0e0', '#202020')}; margin: 4px 0; } `, ]; public render(): TemplateResult { return html`
${this.menuItems.map((menuItemArg) => { if ('divider' in menuItemArg && menuItemArg.divider) { return html``; } const menuItem = menuItemArg as plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean; submenu?: any }; const hasSubmenu = menuItem.submenu && menuItem.submenu.length > 0; return html` `; })} ${this.menuItems.length === 0 ? html` ` : html``}
`; } public async firstUpdated() { // Focus on the menu for keyboard navigation this.focus(); // Add keyboard event listeners this.addEventListener('keydown', this.handleKeydown); } private handleKeydown = (event: KeyboardEvent) => { const menuItems = Array.from(this.shadowRoot.querySelectorAll('.menuitem:not(.disabled)')); const currentIndex = menuItems.findIndex(item => item.matches(':hover')); switch (event.key) { case 'ArrowDown': event.preventDefault(); const nextIndex = currentIndex + 1 < menuItems.length ? currentIndex + 1 : 0; (menuItems[nextIndex] as HTMLElement).dispatchEvent(new MouseEvent('mouseenter')); break; case 'ArrowUp': event.preventDefault(); const prevIndex = currentIndex - 1 >= 0 ? currentIndex - 1 : menuItems.length - 1; (menuItems[prevIndex] as HTMLElement).dispatchEvent(new MouseEvent('mouseenter')); break; case 'Enter': event.preventDefault(); if (currentIndex >= 0) { (menuItems[currentIndex] as HTMLElement).click(); } break; case 'Escape': event.preventDefault(); this.destroy(); break; } } public async handleClick(menuItem: plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean }) { menuItem.action(); // Close all menus in the chain (this menu and all parent menus) await this.destroyAll(); } private async handleMenuItemHover(menuItem: plugins.tsclass.website.IMenuItem & { submenu?: any }, hasSubmenu: boolean) { // Clear any existing timeout if (this.submenuTimeout) { clearTimeout(this.submenuTimeout); this.submenuTimeout = null; } // Hide any existing submenu if hovering a different item if (this.submenu) { await this.hideSubmenu(); } // Show submenu if this item has one if (hasSubmenu && menuItem.submenu) { this.submenuTimeout = setTimeout(() => { this.showSubmenu(menuItem); }, 200); // Small delay to prevent accidental triggers } } private handleMenuItemLeave() { // Add a delay before hiding to allow moving to submenu if (this.submenuTimeout) { clearTimeout(this.submenuTimeout); } this.submenuTimeout = setTimeout(() => { if (this.submenu && !this.submenu.matches(':hover')) { this.hideSubmenu(); } }, 300); } private async showSubmenu(menuItem: plugins.tsclass.website.IMenuItem & { submenu?: any }) { if (!menuItem.submenu || menuItem.submenu.length === 0) return; // Find the menu item element const menuItems = Array.from(this.shadowRoot.querySelectorAll('.menuitem')); const menuItemElement = menuItems.find(el => el.querySelector('.menuitem-text')?.textContent === menuItem.name) as HTMLElement; if (!menuItemElement) return; // Create submenu this.submenu = new DeesContextmenu(); this.submenu.menuItems = menuItem.submenu; this.submenu.parentMenu = this; this.submenu.style.position = 'fixed'; this.submenu.style.zIndex = String(parseInt(this.style.zIndex) + 1); this.submenu.style.opacity = '0'; this.submenu.style.transform = 'scale(0.95)'; // Don't create a window layer for submenus document.body.append(this.submenu); // Position submenu await domtools.plugins.smartdelay.delayFor(0); const itemRect = menuItemElement.getBoundingClientRect(); const menuRect = this.getBoundingClientRect(); const submenuRect = this.submenu.getBoundingClientRect(); const windowWidth = window.innerWidth; let left = menuRect.right - 4; // Slight overlap let top = itemRect.top; // Check if submenu would go off right edge if (left + submenuRect.width > windowWidth - 10) { // Show on left side instead left = menuRect.left - submenuRect.width + 4; } // Adjust vertical position if needed if (top + submenuRect.height > window.innerHeight - 10) { top = window.innerHeight - submenuRect.height - 10; } this.submenu.style.left = `${left}px`; this.submenu.style.top = `${top}px`; // Animate in await domtools.plugins.smartdelay.delayFor(0); this.submenu.style.opacity = '1'; this.submenu.style.transform = 'scale(1)'; // Handle submenu hover this.submenu.addEventListener('mouseenter', () => { if (this.submenuTimeout) { clearTimeout(this.submenuTimeout); this.submenuTimeout = null; } }); this.submenu.addEventListener('mouseleave', () => { this.handleMenuItemLeave(); }); } private async hideSubmenu() { if (!this.submenu) return; await this.submenu.destroy(); this.submenu = null; } public async destroy() { // Clear timeout if (this.submenuTimeout) { clearTimeout(this.submenuTimeout); this.submenuTimeout = null; } // Destroy submenu first if (this.submenu) { await this.submenu.destroy(); this.submenu = null; } // Only destroy window layer if this is not a submenu if (this.windowLayer && !this.parentMenu) { this.windowLayer.destroy(); } this.style.opacity = '0'; this.style.transform = 'scale(0.95) translateY(-10px)'; await domtools.plugins.smartdelay.delayFor(100); if (this.parentElement) { this.parentElement.removeChild(this); } } /** * Destroys this menu and all parent menus in the chain */ public async destroyAll() { // First destroy parent menus if they exist if (this.parentMenu) { await this.parentMenu.destroyAll(); } else { // If we're at the top level, just destroy this menu await this.destroy(); } } } DeesContextmenu.initializeGlobalListener();