/** * Turns an HTML menu into a native one. The ContextMenu component renders as it does * in a browser, hidden, with its submenus open. This reads the result and shows that * natively. A pick clicks the row the HTML menu has for it, so a menu has one list of * entries with one set of handlers and the two can't drift. * * A menu with something a native one can't show (a colour picker, a row with no text) * stays HTML. So does anything marked data-native-menu="html". A plugin's frame in the * menu doesn't: the rest shows natively, with one line that opens the HTML menu. */ import { getDesktopBridge } from './bridge'; import type { DesktopNativeMenuItem } from './types'; export function nativeMenusAvailable(): boolean { return !!getDesktopBridge()?.menu?.popup; } export interface ReadMenu { items: DesktopNativeMenuItem[]; /** The HTML row behind each native item's id. */ elements: Map; /** There's something here a native menu can't show. */ unsupported: boolean; /** A plugin drew something of its own in the menu. */ hasFrames: boolean; } const ROW = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], button, a[href]'; /** The native line that opens the HTML menu instead. */ export const HTML_MENU_ITEM = "html-menu"; function isDisabled(el: HTMLElement): boolean { return (el as HTMLButtonElement).disabled === true || el.getAttribute('aria-disabled') === 'true'; } function labelOf(el: HTMLElement): string { const named = el.getAttribute('aria-label') ?? el.querySelector('[data-menu-label]')?.textContent ?? el.textContent ?? ''; return named.replace(/\s+/g, ' ').trim(); } function isHidden(el: Element): boolean { return typeof window.getComputedStyle === 'function' && window.getComputedStyle(el).display === 'none'; } export function readMenu(root: HTMLElement): ReadMenu { const elements = new Map(); let unsupported = root.querySelector('[data-native-menu="html"], select, textarea, input[type="color"]') !== null; let hasFrames = false; let next = 1; const walk = (container: Element, into: DesktopNativeMenuItem[]): void => { for (const child of Array.from(container.children)) { if (!(child instanceof HTMLElement) || isHidden(child)) continue; if (child.getAttribute('role') === 'separator') { into.push({ type: 'separator' }); continue; } if (child.tagName === 'IFRAME') { // Only a plugin's own slot has this coming. Anything else stays HTML whole. if (child.closest('[data-plugin-slot]')) hasFrames = true; else unsupported = true; continue; } if (child.dataset.submenuPanel !== undefined) continue; if (child.dataset.contextMenuHeader !== undefined) { const label = labelOf(child); if (label) into.push({ id: `h${next++}`, label, enabled: false }); continue; } if (child.dataset.submenuTrigger !== undefined) { const label = labelOf(child); if (isDisabled(child)) { into.push({ id: `s${next++}`, label, enabled: false }); continue; } const panel = child.parentElement?.querySelector(':scope > [data-submenu-panel]'); if (!panel) { unsupported = true; continue; } const submenu: DesktopNativeMenuItem[] = []; walk(panel, submenu); into.push({ label, submenu }); continue; } if (child.matches(ROW)) { // A row that opens its own popup: there's nothing to click through to. if (child.hasAttribute('aria-haspopup')) { unsupported = true; continue; } const label = labelOf(child); if (!label) { // A swatch or an icon says nothing in a native menu. unsupported = true; continue; } const id = `i${next++}`; elements.set(id, child); const role = child.getAttribute('role'); into.push({ id, label, enabled: !isDisabled(child), ...(role === 'menuitemcheckbox' || role === 'menuitemradio' ? { type: role === 'menuitemcheckbox' ? 'checkbox' as const : 'radio' as const, checked: child.getAttribute('aria-checked') === 'true' } : {}), }); continue; } // A search field over a list is no use where you can't type. if (child instanceof HTMLInputElement) continue; walk(child, into); } }; const items: DesktopNativeMenuItem[] = []; walk(root, items); return { items, elements, unsupported, hasFrames }; } const SETTLE_MS = 30; const SETTLE_LIMIT_MS = 400; /** * The menu once it stopped changing. Entries can render a moment after the menu * (the mail menu waits for its data), and a native menu can't grow once it's up. */ export async function readSettledMenu(root: HTMLElement, cancelled: () => boolean): Promise { const started = Date.now(); let previous = readMenu(root); while (!cancelled() && Date.now() - started < SETTLE_LIMIT_MS) { await new Promise((resolve) => setTimeout(resolve, SETTLE_MS)); const current = readMenu(root); if (current.items.length > 0 && JSON.stringify(current.items) === JSON.stringify(previous.items)) return current; previous = current; } return previous; } function labelOfId(items: DesktopNativeMenuItem[], id: string): string | undefined { for (const item of items) { if (item.id === id) return item.label; const below = item.submenu && labelOfId(item.submenu, id); if (below) return below; } return undefined; } export type NativeMenuOutcome = 'done' | 'html' | 'gone'; /** * Shows it and clicks the row picked. Says when the pick found nothing: the page can * re-render under a menu that stays up (the list refreshing), and a row that's gone * would otherwise do nothing with no word about it. If the same line is back at the * same place, that's the one. */ export async function showNativeMenu(read: ReadMenu, root: HTMLElement, at?: { x: number; y: number }, fromPlugins?: string): Promise { const popup = getDesktopBridge()?.menu?.popup; if (!popup) return 'done'; const items = read.hasFrames && fromPlugins ? [...read.items, { type: 'separator' as const }, { id: HTML_MENU_ITEM, label: fromPlugins }] : read.items; document.documentElement.dataset.nativeMenuOpen = ''; let picked: string | null; try { picked = await popup(items, at); } finally { delete document.documentElement.dataset.nativeMenuOpen; } if (!picked) return 'done'; if (picked === HTML_MENU_ITEM) return 'html'; let row = read.elements.get(picked); if (!row?.isConnected) { const again = root.isConnected ? readMenu(root) : null; row = again && labelOfId(again.items, picked) === labelOfId(read.items, picked) ? again.elements.get(picked) : undefined; } if (!row?.isConnected) return 'gone'; row.click(); return 'done'; } /** A native menu is up: the window's blur or a stray event says nothing about it. */ export function nativeMenuIsOpen(): boolean { return typeof document !== 'undefined' && document.documentElement.dataset.nativeMenuOpen !== undefined; }