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 './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 } | { 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(); // Get the target element of the right-click let target: EventTarget | null = event.target; // Clear previously accumulated items DeesContextmenu.accumulatedMenuItems = []; // Traverse up the DOM tree to accumulate menu items while (target) { if ((target as any).getContextMenuItems) { const items = (target as any).getContextMenuItems(); if (items && items.length > 0) { if (DeesContextmenu.accumulatedMenuItems.length > 0) { DeesContextmenu.accumulatedMenuItems.push({ divider: true }); } DeesContextmenu.accumulatedMenuItems.push(...items); } } target = (target as Node).parentNode; } // 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 } | { divider: true })[]) { if (this.contextMenuDeactivated) { return; } eventArg.preventDefault(); eventArg.stopPropagation(); const contextMenu = new DeesContextmenu(); contextMenu.style.position = 'fixed'; contextMenu.style.zIndex = '10000'; 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 () => { 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; divider?: never } | { divider: true })[] = []; windowLayer: DeesWindowLayer; 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; } .menuitem:hover { background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.04)', 'rgba(255, 255, 255, 0.08)')}; } .menuitem:active { 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 }; 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(); await this.destroy(); } public async destroy() { if (this.windowLayer) { this.windowLayer.destroy(); } this.style.opacity = '0'; this.style.transform = 'scale(0.95) translateY(-10px)'; await domtools.plugins.smartdelay.delayFor(100); this.parentElement.removeChild(this); } } DeesContextmenu.initializeGlobalListener();