/** Main menu for the frameless title bar, with hamburger and persistent title modes. */ import { useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent } from "react"; import clsx from "clsx"; import { useProject } from "@/platform/project"; import { lookup, t, useI18n } from "@/platform/i18n"; import { commands, type MenuNode } from "@/platform/ipc"; import { usePlatform } from "@/platform/platformInfo"; import { Icon, MenuList, Popup, type MenuItem } from "@/ui"; import menuStyles from "@/ui/Menu.module.css"; import { isDistractionFree } from "./distractionFree"; import styles from "./TitleBar.module.css"; function label(id: string, fallback: string): string { const key = `menu.${id}`; if (lookup(key) === undefined) return fallback; return t(key, { os: usePlatform.getState().os, distractionFree: isDistractionFree() ? "on" : "off" }); } type Submenu = Extract; const isSubmenu = (n: MenuNode): n is Submenu => n.kind === "submenu"; export function menuItems(nodes: MenuNode[]): MenuItem[] { return nodes.map((n): MenuItem => { switch (n.kind) { case "separator": return { kind: "separator" }; case "placeholder": return { id: n.id, label: label(n.id, n.label), disabled: true }; case "submenu": return { id: n.id, label: label(n.id, n.label), submenu: menuItems(n.children) }; case "action": return { kind: "action", action: n.id, arg: n.arg ?? undefined, label: n.arg ? n.label : label(n.id, n.label) }; } }); } /** * Calls `onAlt` when Alt is pressed and released with nothing else in between * (no other key, no click): Alt+1, Alt+Enter or Alt+click never open the menu. */ export function onLoneAlt(target: Window, onAlt: () => void): () => void { let armed = false; const down = (e: globalThis.KeyboardEvent) => { armed = e.key === "Alt" && !e.repeat && !e.ctrlKey && !e.shiftKey && !e.metaKey; }; const up = (e: globalThis.KeyboardEvent) => { const fire = armed && e.key === "Alt"; armed = false; if (!fire) return; e.preventDefault(); onAlt(); }; const cancel = () => { armed = false; }; target.addEventListener("keydown", down, true); target.addEventListener("keyup", up, true); target.addEventListener("pointerdown", cancel, true); target.addEventListener("blur", cancel); return () => { target.removeEventListener("keydown", down, true); target.removeEventListener("keyup", up, true); target.removeEventListener("pointerdown", cancel, true); target.removeEventListener("blur", cancel); }; } async function loadMenus(): Promise { const model: MenuNode[] = await commands.menuModel().catch(() => []); return model.filter(isSubmenu); } type MainMenuProps = { onExpandedChange?: (expanded: boolean) => void }; export function MainMenu(props: MainMenuProps) { const merged = useProject((s) => s.settings?.appearance?.mainMenuStyle === "merged"); return ; } function MainMenuBar({ merged, onExpandedChange }: MainMenuProps & { merged: boolean }) { useI18n((s) => s.locale); const [menus, setMenus] = useState([]); const [expanded, setExpanded] = useState(false); const [open, setOpen] = useState(null); const [focused, setFocused] = useState(0); const [bar, setBar] = useState(null); const slot = useRef(null); const measure = useRef(null); const hamburger = useRef(null); const previousFocus = useRef(null); const [fits, setFits] = useState(false); const pressedTitle = useRef(false); const persistent = merged && fits; const showTitles = persistent || (!merged && expanded); useEffect(() => { let disposed = false; if (merged) void loadMenus().then((top) => { if (!disposed) setMenus(top); }); return () => { disposed = true; }; }, [merged]); useEffect(() => { if (!merged || open === null) return; let disposed = false; void loadMenus().then((top) => { if (!disposed && top.length) setMenus(top); }); return () => { disposed = true; }; }, [merged, open]); useEffect(() => { onExpandedChange?.(!merged && expanded); return () => onExpandedChange?.(false); }, [merged, expanded, onExpandedChange]); // The invisible copy keeps the natural flex basis even while the slot shrinks. // Measuring the allocated slot includes all neighbouring toolbar/caption controls. useLayoutEffect(() => { if (!merged || !slot.current || !measure.current) return; const update = () => { const next = menus.length > 0 && slot.current!.clientWidth >= measure.current!.scrollWidth; setFits(next); // Each presentation's menu closes with it, and must not reopen when it returns. if (next) setExpanded(false); else setOpen(null); }; const observer = new ResizeObserver(update); observer.observe(slot.current); observer.observe(measure.current); update(); return () => observer.disconnect(); }, [merged, menus]); const close = () => { setOpen(null); setExpanded(false); }; const expand = async () => { const top = await loadMenus(); if (!top.length) return; setMenus(top); setExpanded(true); setOpen(0); }; const focusTitle = (i: number) => { setFocused(i); (bar?.children[i] as HTMLElement | undefined)?.focus(); }; const onMenuClose = (reason?: string) => { if (reason !== "outside") { if (reason === "escape" && persistent) focusTitle(open ?? focused); return close(); } setTimeout(() => { if (pressedTitle.current) pressedTitle.current = false; else close(); }); }; useEffect(() => onLoneAlt(window, () => { if (open !== null || expanded) { close(); if (persistent) focusTitle(focused); } else if (persistent) { if (bar?.contains(document.activeElement)) previousFocus.current?.focus(); else { previousFocus.current = document.activeElement as HTMLElement; focusTitle(0); } } else void expand(); }), ); const onKeyDown = (e: KeyboardEvent) => { if (e.key === "ArrowLeft" || e.key === "ArrowRight") { if (!menus.length) return; e.preventDefault(); const next = ((open ?? focused) + (e.key === "ArrowRight" ? 1 : -1) + menus.length) % menus.length; if (open !== null) setOpen(next); else focusTitle(next); } else if (e.key === "ArrowDown" && open === null) { e.preventDefault(); setOpen(focused); } else if (e.key === "Escape" && open === null) { close(); previousFocus.current?.focus(); } }; const button = ( ); const titles = (
{menus.map((m, i) => ( ))} {open !== null && menus[open] && bar?.children[open] && ( )}
); if (!merged) return showTitles ? titles : button; return (
{showTitles ? titles : button}
{!showTitles && expanded && ( )}
); }