shell: the app menu hangs from each pane's wB button; the titlebar logo is plain again
ShellMenu.trigger chooses the trigger: 'pane' (default; each pane's wB button opens the menu under
itself, lined up with its end edge and kept inside the window), 'button' (titlebar) or 'logo'.
Alt+F, F10, the palette and the account menu's last row open it under the focused pane's wB (the
first one on screen otherwise). The menu's Settings row and the other panel rows still open the
panel, and Ctrl+Shift+W still does. The logo is part of the drag region again, as before 715f6f2.
This commit is contained in:
parent
f7203b9725
commit
c694ed307b
8 changed files with 176 additions and 54 deletions
|
|
@ -1,9 +1,11 @@
|
||||||
// The app menu: the titlebar's "Settings and more" button, at the far end of the titlebar cluster
|
// The app menu ("Settings and more"). Its trigger is the app's choice (`ShellMenu.trigger`): by
|
||||||
// (before the caption buttons on Windows and Linux; macOS has none there). It opens on click, on
|
// default each pane's wayBetter button at the end of its toolbar (`PaneMenuButton`; the menu hangs
|
||||||
// Alt+F and F10 (Shell.tsx; a focused tab forwards the keys), from the command palette, from the
|
// under the button that was clicked, so in split view each pane opens it under itself), else a
|
||||||
// last row of the account menu, and from the app's logo at the start of the titlebar (the same
|
// button at the end of the titlebar or the titlebar's logo. It also opens on Alt+F and F10
|
||||||
// menu, hung under the logo on its side of the window). Its rows are the app's (`ShellSite.menu`, menuModel.ts): built-in
|
// (Shell.tsx; a focused tab forwards the keys), from the command palette and from the last row of
|
||||||
// ones by name, each shown only when the app serves what it needs, and the app's own.
|
// the account menu: under the focused pane's button (the first one on screen when no pane is
|
||||||
|
// focused). Its rows are the app's (`ShellSite.menu`, menuModel.ts): built-in ones by name, each
|
||||||
|
// shown only when the app serves what it needs, and the app's own.
|
||||||
//
|
//
|
||||||
// Like every shell menu it is an overlay: while open the shell is raised above the tab webviews
|
// Like every shell menu it is an overlay: while open the shell is raised above the tab webviews
|
||||||
// (`setOverlay`), panes stay visible and nothing is dimmed. Keyboard: arrows, Home/End, type-ahead,
|
// (`setOverlay`), panes stay visible and nothing is dimmed. Keyboard: arrows, Home/End, type-ahead,
|
||||||
|
|
@ -11,7 +13,7 @@
|
||||||
// Enter or Space chooses, Esc closes one level. Submenus fly out beside the menu and stay inside
|
// Enter or Space chooses, Esc closes one level. Submenus fly out beside the menu and stay inside
|
||||||
// the window (in a narrow one they lie over it). Words: menuMessages.en.json (`menuText`).
|
// the window (in a narrow one they lie over it). Words: menuMessages.en.json (`menuText`).
|
||||||
|
|
||||||
import { useEffect, useLayoutEffect, useReducer, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
|
import { useEffect, useLayoutEffect, useReducer, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { parseUpdateStatus, type UpdateStatus } from '../updateModel.ts';
|
import { parseUpdateStatus, type UpdateStatus } from '../updateModel.ts';
|
||||||
import { invoke, listen } from './bridge.ts';
|
import { invoke, listen } from './bridge.ts';
|
||||||
|
|
@ -22,8 +24,8 @@ import { useDev, usePaneTick, useT } from './hooks.ts';
|
||||||
import { formatNumber } from './i18n.ts';
|
import { formatNumber } from './i18n.ts';
|
||||||
import { Icon, type IconName } from './icons.tsx';
|
import { Icon, type IconName } from './icons.tsx';
|
||||||
import {
|
import {
|
||||||
DEFAULT_MENU, ZOOM_MAX, ZOOM_MIN, findSpec, joinGroups, languageChoice, languageRows, menuText, moveStop, planMenu, shortcutText, specRows, submenuKeys, submenuLeft, typeahead, updateRow,
|
DEFAULT_MENU, ZOOM_MAX, ZOOM_MIN, findSpec, joinGroups, languageChoice, languageRows, menuText, moveStop, planMenu, resolveTrigger, shortcutText, specRows, submenuKeys, submenuLeft, typeahead, updateRow,
|
||||||
type BuiltinMenuItem, type MenuEnv, type MenuItemRow, type MenuModeStatus, type MenuRow,
|
type AppMenuTrigger, type BuiltinMenuItem, type MenuEnv, type MenuItemRow, type MenuModeStatus, type MenuRow,
|
||||||
} from './menuModel.ts';
|
} from './menuModel.ts';
|
||||||
import { ReportDialog } from './ReportDialog.tsx';
|
import { ReportDialog } from './ReportDialog.tsx';
|
||||||
import { safe, setSafeMode } from './safeStore.ts';
|
import { safe, setSafeMode } from './safeStore.ts';
|
||||||
|
|
@ -35,8 +37,12 @@ import { loadWorkspaces, restoreWorkspace, workspaceList, workspaces } from './w
|
||||||
import { readZoom, stepZoom, zoomOf, zoomTarget } from './zoomStore.ts';
|
import { readZoom, stepZoom, zoomOf, zoomTarget } from './zoomStore.ts';
|
||||||
import './appMenu.css';
|
import './appMenu.css';
|
||||||
|
|
||||||
/** How the menu is asked to open: under the logo instead of its button, at a submenu, with the first row focused (keyboard), or closing if it is open. */
|
/**
|
||||||
export type AppMenuRequest = { anchor?: 'button' | 'logo'; submenu?: string; keyboard?: boolean; toggle?: boolean };
|
* How the menu is asked to open: at a trigger other than the app's own (`anchor`), under one pane's
|
||||||
|
* button (`pane`, a dockview panel id), at a submenu, with the first row focused (keyboard), or
|
||||||
|
* closing if it is open at that place.
|
||||||
|
*/
|
||||||
|
export type AppMenuRequest = { anchor?: AppMenuTrigger; pane?: string; submenu?: string; keyboard?: boolean; toggle?: boolean };
|
||||||
|
|
||||||
/** Opens the app menu (Alt+F / F10, the palette, the account menu's last row). */
|
/** Opens the app menu (Alt+F / F10, the palette, the account menu's last row). */
|
||||||
export const openAppMenu = (request: AppMenuRequest = {}) => window.dispatchEvent(new CustomEvent('bt-app-menu', { detail: request }));
|
export const openAppMenu = (request: AppMenuRequest = {}) => window.dispatchEvent(new CustomEvent('bt-app-menu', { detail: request }));
|
||||||
|
|
@ -49,17 +55,42 @@ const getError = (error: unknown) => (error instanceof Error ? error.message : S
|
||||||
const percent = (factor: number) => formatNumber(factor, { style: 'percent', maximumFractionDigits: 0 });
|
const percent = (factor: number) => formatNumber(factor, { style: 'percent', maximumFractionDigits: 0 });
|
||||||
|
|
||||||
type Focus = { level: number; id?: string; edge?: 'first' | 'last' | 'menu' };
|
type Focus = { level: number; id?: string; edge?: 'first' | 'last' | 'menu' };
|
||||||
/** Where the menu hangs: under its button (from the window's end edge) or under the logo (from its start edge). */
|
/**
|
||||||
type Anchor = { at: 'button' | 'logo'; top: number; left?: number; right?: number };
|
* Where the menu hangs: under a button (its end edge lined up with the button's: from the window's
|
||||||
|
* right edge, or from the left in a right-to-left pane) or under the logo (from its start edge).
|
||||||
|
*/
|
||||||
|
type Anchor = { at: AppMenuTrigger; pane?: string; top: number; left?: number; right?: number };
|
||||||
|
|
||||||
|
/** One trigger, as a key: `pane:<id>` for a pane's button. */
|
||||||
|
const triggerKey = (at: AppMenuTrigger, pane?: string) => (at === 'pane' ? `pane:${pane ?? ''}` : at);
|
||||||
|
|
||||||
/** Which trigger the open menu hangs from (null: closed), for the triggers' `aria-expanded`. */
|
/** Which trigger the open menu hangs from (null: closed), for the triggers' `aria-expanded`. */
|
||||||
let openAt: Anchor['at'] | null = null;
|
let openAt: string | null = null;
|
||||||
function noteOpen(at: Anchor['at'] | null) {
|
function noteOpen(key: string | null) {
|
||||||
if (openAt === at) return;
|
if (openAt === key) return;
|
||||||
openAt = at;
|
openAt = key;
|
||||||
window.dispatchEvent(new CustomEvent('bt-app-menu-state'));
|
window.dispatchEvent(new CustomEvent('bt-app-menu-state'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `aria-expanded` of one trigger. */
|
||||||
|
function useExpanded(key: string) {
|
||||||
|
const [expanded, setExpanded] = useState(openAt === key);
|
||||||
|
useEffect(() => {
|
||||||
|
const update = () => setExpanded(openAt === key);
|
||||||
|
update();
|
||||||
|
window.addEventListener('bt-app-menu-state', update);
|
||||||
|
return () => window.removeEventListener('bt-app-menu-state', update);
|
||||||
|
}, [key]);
|
||||||
|
return expanded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pane `id`'s menu button on screen; else the focused pane's; else the first one on screen. */
|
||||||
|
function paneButton(id?: string): HTMLElement | null {
|
||||||
|
const shown = Array.from(document.querySelectorAll<HTMLElement>('.bt-pane-menu[data-pane]')).filter(el => el.getClientRects().length > 0);
|
||||||
|
const find = (want: string | undefined) => (want ? shown.find(el => el.dataset.pane === want) : undefined);
|
||||||
|
return find(id) ?? find(shell.api?.activePanel?.id) ?? shown[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/** The rows of one level a key can stop on: enabled items; the zoom row counts once. */
|
/** The rows of one level a key can stop on: enabled items; the zoom row counts once. */
|
||||||
function stopsOf(menu: HTMLElement): HTMLElement[] {
|
function stopsOf(menu: HTMLElement): HTMLElement[] {
|
||||||
const all = Array.from(menu.querySelectorAll<HTMLElement>('[data-item]:not(:disabled)'));
|
const all = Array.from(menu.querySelectorAll<HTMLElement>('[data-item]:not(:disabled)'));
|
||||||
|
|
@ -69,7 +100,11 @@ function stopsOf(menu: HTMLElement): HTMLElement[] {
|
||||||
return all.filter(el => !('zoom' in el.dataset) || el === keep);
|
return all.filter(el => !('zoom' in el.dataset) || el === keep);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppMenu({ trigger = true }: { trigger?: boolean } = {}) {
|
/**
|
||||||
|
* The menu (mounted once, in the titlebar). `trigger`: where clicks and keys open it; default the
|
||||||
|
* app's `ShellMenu.trigger`, else `pane`. `true` / `false` are the older spellings of `button` / `logo`.
|
||||||
|
*/
|
||||||
|
export function AppMenu({ trigger }: { trigger?: AppMenuTrigger | boolean } = {}) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
usePaneTick();
|
usePaneTick();
|
||||||
const dev = useDev();
|
const dev = useDev();
|
||||||
|
|
@ -107,17 +142,32 @@ export function AppMenu({ trigger = true }: { trigger?: boolean } = {}) {
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/** Where a request without an anchor opens: the button when this app shows one, else the logo. */
|
/** Where a request without an anchor opens: the app's trigger. */
|
||||||
const home = (): Anchor['at'] => (trigger ? 'button' : 'logo');
|
const home = (): AppMenuTrigger => menuTrigger(trigger);
|
||||||
|
|
||||||
|
/** The element a request hangs the menu under (null: none on screen). */
|
||||||
|
function triggerOf(at: AppMenuTrigger, pane?: string): HTMLElement | null {
|
||||||
|
if (at === 'pane') return paneButton(pane);
|
||||||
|
if (at === 'logo') return document.querySelector<HTMLElement>('button.bt-titlebar-logo');
|
||||||
|
return button.current;
|
||||||
|
}
|
||||||
|
|
||||||
function show(request: AppMenuRequest = {}) {
|
function show(request: AppMenuRequest = {}) {
|
||||||
const at = request.anchor ?? home();
|
const at = request.anchor ?? home();
|
||||||
|
const el = triggerOf(at, request.pane);
|
||||||
|
const pane = at === 'pane' ? el?.dataset.pane : undefined;
|
||||||
diag(`menu open: anchor=${at} keyboard=${!!request.keyboard} hasFocus=${document.hasFocus()} active=${describe(document.activeElement)}`);
|
diag(`menu open: anchor=${at} keyboard=${!!request.keyboard} hasFocus=${document.hasFocus()} active=${describe(document.activeElement)}`);
|
||||||
const logo = at === 'logo' ? document.querySelector('.bt-titlebar-logo') : null;
|
const rect = el?.getBoundingClientRect();
|
||||||
const rect = (logo ?? button.current)?.getBoundingClientRect();
|
|
||||||
if (rect) {
|
if (rect) {
|
||||||
const top = Math.round(rect.bottom + 6);
|
const top = Math.round(rect.bottom + 6);
|
||||||
setAnchor(logo ? { at: 'logo', top, left: Math.max(8, Math.round(rect.left - 3)) } : { at: 'button', top, right: Math.max(8, Math.round(window.innerWidth - rect.right)) });
|
const rtl = getComputedStyle(el!).direction === 'rtl';
|
||||||
|
if (at === 'logo') setAnchor({ at, top, left: Math.max(8, Math.round(rect.left - 3)) });
|
||||||
|
// The menu's end edge lines up with its button's, so it opens over the pane the button is in.
|
||||||
|
else if (rtl) setAnchor({ at, pane, top, left: Math.max(8, Math.round(rect.left)) });
|
||||||
|
else setAnchor({ at, pane, top, right: Math.max(8, Math.round(window.innerWidth - rect.right)) });
|
||||||
|
} else {
|
||||||
|
// No trigger on screen (only the app's own tab, a deck): under the titlebar, at its end.
|
||||||
|
setAnchor({ at, pane, top: 50, right: 8 });
|
||||||
}
|
}
|
||||||
const active = document.activeElement as HTMLElement | null;
|
const active = document.activeElement as HTMLElement | null;
|
||||||
before.current = active && active !== document.body && document.hasFocus() ? active : null;
|
before.current = active && active !== document.body && document.hasFocus() ? active : null;
|
||||||
|
|
@ -165,8 +215,10 @@ export function AppMenu({ trigger = true }: { trigger?: boolean } = {}) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onRequest = (event: Event) => {
|
const onRequest = (event: Event) => {
|
||||||
const request = (event as CustomEvent<AppMenuRequest | undefined>).detail ?? {};
|
const request = (event as CustomEvent<AppMenuRequest | undefined>).detail ?? {};
|
||||||
// Asked again from the other trigger, the open menu moves there instead of closing.
|
// Asked again from another trigger, the open menu moves there instead of closing.
|
||||||
if (live.current.open && request.toggle && (request.anchor ?? home()) === (openAt ?? home())) close('escape'); else show(request);
|
const at = request.anchor ?? home();
|
||||||
|
const key = triggerKey(at, at === 'pane' ? request.pane ?? paneButton()?.dataset.pane : undefined);
|
||||||
|
if (live.current.open && request.toggle && key === openAt) close('escape'); else show(request);
|
||||||
};
|
};
|
||||||
window.addEventListener('bt-app-menu', onRequest);
|
window.addEventListener('bt-app-menu', onRequest);
|
||||||
return () => window.removeEventListener('bt-app-menu', onRequest);
|
return () => window.removeEventListener('bt-app-menu', onRequest);
|
||||||
|
|
@ -176,7 +228,6 @@ export function AppMenu({ trigger = true }: { trigger?: boolean } = {}) {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
// Native tab webviews would cover the menu.
|
// Native tab webviews would cover the menu.
|
||||||
setOverlay(1);
|
setOverlay(1);
|
||||||
noteOpen(anchor.at);
|
|
||||||
// The window lost the keyboard: the menu closes, unless the focus is back a moment later (taking the
|
// The window lost the keyboard: the menu closes, unless the focus is back a moment later (taking the
|
||||||
// keyboard from a tab for Alt+F / F10 can pass through a blur on its way to the shell).
|
// keyboard from a tab for Alt+F / F10 can pass through a blur on its way to the shell).
|
||||||
let leaving: ReturnType<typeof setTimeout> | undefined;
|
let leaving: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
@ -199,14 +250,33 @@ export function AppMenu({ trigger = true }: { trigger?: boolean } = {}) {
|
||||||
window.addEventListener('blur', away);
|
window.addEventListener('blur', away);
|
||||||
window.addEventListener('resize', resized);
|
window.addEventListener('resize', resized);
|
||||||
window.addEventListener('keydown', onKey);
|
window.addEventListener('keydown', onKey);
|
||||||
return () => { clearTimeout(leaving); setOverlay(-1); noteOpen(null); window.removeEventListener('blur', away); window.removeEventListener('resize', resized); window.removeEventListener('keydown', onKey); };
|
return () => { clearTimeout(leaving); setOverlay(-1); window.removeEventListener('blur', away); window.removeEventListener('resize', resized); window.removeEventListener('keydown', onKey); };
|
||||||
}, [open, anchor.at]);
|
}, [open, anchor.at]);
|
||||||
|
|
||||||
const layer = (level: number) => layers.current?.querySelector<HTMLElement>(`[data-level="${level}"]`) ?? null;
|
// The trigger the menu hangs from shows it is open.
|
||||||
|
useEffect(() => {
|
||||||
|
noteOpen(open ? triggerKey(anchor.at, anchor.pane) : null);
|
||||||
|
return () => noteOpen(null);
|
||||||
|
}, [open, anchor.at, anchor.pane]);
|
||||||
|
|
||||||
|
const layer =(level: number) => layers.current?.querySelector<HTMLElement>(`[data-level="${level}"]`) ?? null;
|
||||||
|
|
||||||
// After every render: submenus sit beside their row, inside the window; then the focus that was asked for.
|
// After every render: submenus sit beside their row, inside the window; then the focus that was asked for.
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
// The menu stays inside the window: a button near the start edge (a narrow pane in split view)
|
||||||
|
// would push it out, so it slides back in.
|
||||||
|
const root = layer(0);
|
||||||
|
if (root) {
|
||||||
|
const edge = 8;
|
||||||
|
root.style.left = anchor.left === undefined ? '' : `${anchor.left}px`;
|
||||||
|
root.style.right = anchor.right === undefined ? '' : `${anchor.right}px`;
|
||||||
|
const box = root.getBoundingClientRect();
|
||||||
|
if (box.left < edge || box.right > window.innerWidth - edge) {
|
||||||
|
root.style.left = `${Math.round(Math.max(edge, Math.min(box.left, window.innerWidth - edge - box.width)))}px`;
|
||||||
|
root.style.right = 'auto';
|
||||||
|
}
|
||||||
|
}
|
||||||
for (let level = 1; level <= path.length; level += 1) {
|
for (let level = 1; level <= path.length; level += 1) {
|
||||||
const el = layer(level);
|
const el = layer(level);
|
||||||
const parent = layer(level - 1);
|
const parent = layer(level - 1);
|
||||||
|
|
@ -564,7 +634,7 @@ export function AppMenu({ trigger = true }: { trigger?: boolean } = {}) {
|
||||||
|
|
||||||
const label = menuText('label');
|
const label = menuText('label');
|
||||||
return <>
|
return <>
|
||||||
{trigger && <button ref={button} type="button" className="bt-app-button" aria-haspopup="menu" aria-expanded={open && anchor.at === 'button'} aria-label={label} title={`${label} (${APP_MENU_KEYS})`}
|
{home() === 'button' && <button ref={button} type="button" className="bt-app-button" aria-haspopup="menu" aria-expanded={open && anchor.at === 'button'} aria-label={label} title={`${label} (${APP_MENU_KEYS})`}
|
||||||
onClick={event => (open && anchor.at === 'button' ? close('escape') : show({ keyboard: event.detail === 0 }))}>
|
onClick={event => (open && anchor.at === 'button' ? close('escape') : show({ keyboard: event.detail === 0 }))}>
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="5.75" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="12" cy="18.25" r="1.6"/></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="5.75" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="12" cy="18.25" r="1.6"/></svg>
|
||||||
</button>}
|
</button>}
|
||||||
|
|
@ -584,13 +654,8 @@ export function AppMenu({ trigger = true }: { trigger?: boolean } = {}) {
|
||||||
*/
|
*/
|
||||||
export function AppMenuLogo() {
|
export function AppMenuLogo() {
|
||||||
useT();
|
useT();
|
||||||
const [expanded, setExpanded] = useState(openAt === 'logo');
|
const expanded = useExpanded('logo');
|
||||||
const press = useRef<{ x: number; y: number; dragged: boolean } | null>(null);
|
const press = useRef<{ x: number; y: number; dragged: boolean } | null>(null);
|
||||||
useEffect(() => {
|
|
||||||
const update = () => setExpanded(openAt === 'logo');
|
|
||||||
window.addEventListener('bt-app-menu-state', update);
|
|
||||||
return () => window.removeEventListener('bt-app-menu-state', update);
|
|
||||||
}, []);
|
|
||||||
const { product, logo } = site();
|
const { product, logo } = site();
|
||||||
const label = menuText('label');
|
const label = menuText('label');
|
||||||
return <button type="button" className="bt-titlebar-logo" aria-haspopup="menu" aria-expanded={expanded} aria-label={`${product}: ${label}`} title={`${product} · ${label}`}
|
return <button type="button" className="bt-titlebar-logo" aria-haspopup="menu" aria-expanded={expanded} aria-label={`${product}: ${label}`} title={`${product} · ${label}`}
|
||||||
|
|
@ -614,3 +679,22 @@ export function AppMenuLogo() {
|
||||||
<img src={logo} alt="" draggable={false}/>
|
<img src={logo} alt="" draggable={false}/>
|
||||||
</button>;
|
</button>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The app's menu trigger: `trigger` as `AppMenu` takes it, else `ShellMenu.trigger`, else `pane`. */
|
||||||
|
export const menuTrigger = (trigger?: AppMenuTrigger | boolean): AppMenuTrigger => resolveTrigger(trigger, site().menu?.trigger);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One pane's menu button, at the end of its toolbar (the app's mark inside): opens the app menu
|
||||||
|
* under itself, or closes it when it is open there. Shown when the app's trigger is `pane`.
|
||||||
|
*/
|
||||||
|
export function PaneMenuButton({ pane, children }: { pane: string; children: ReactNode }) {
|
||||||
|
useT();
|
||||||
|
const expanded = useExpanded(triggerKey('pane', pane));
|
||||||
|
const label = menuText('label');
|
||||||
|
const { product } = site();
|
||||||
|
return <button type="button" className="bt-brand-button bt-pane-menu" data-pane={pane} aria-haspopup="menu" aria-expanded={expanded}
|
||||||
|
aria-label={`${product}: ${label}`} title={`${product} · ${label} (${APP_MENU_KEYS})`}
|
||||||
|
onClick={event => openAppMenu({ anchor: 'pane', pane, toggle: true, keyboard: event.detail === 0 })}>
|
||||||
|
{children}
|
||||||
|
</button>;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useEffect, type CSSProperties, type MouseEvent as ReactMouseEvent } fro
|
||||||
import { RESIZE_BORDER } from '../chrome.ts';
|
import { RESIZE_BORDER } from '../chrome.ts';
|
||||||
import { chipFor } from '../updateModel.ts';
|
import { chipFor } from '../updateModel.ts';
|
||||||
import { AccountSwitcher } from './AccountSwitcher.tsx';
|
import { AccountSwitcher } from './AccountSwitcher.tsx';
|
||||||
import { AppMenu, AppMenuLogo } from './AppMenu.tsx';
|
import { AppMenu, AppMenuLogo, menuTrigger } from './AppMenu.tsx';
|
||||||
import { invoke } from './bridge.ts';
|
import { invoke } from './bridge.ts';
|
||||||
import { useT, useUpdateStatus } from './hooks.ts';
|
import { useT, useUpdateStatus } from './hooks.ts';
|
||||||
import { tx } from './i18n.ts';
|
import { tx } from './i18n.ts';
|
||||||
|
|
@ -24,18 +24,20 @@ export function titlebarContextMenu(event: ReactMouseEvent | MouseEvent) {
|
||||||
/**
|
/**
|
||||||
* The 44px titlebar row. It sits under the dockview root: the top groups' tab headers are
|
* The 44px titlebar row. It sits under the dockview root: the top groups' tab headers are
|
||||||
* transparent and let clicks through to the drag layer, the logo and the caption buttons. The logo
|
* transparent and let clicks through to the drag layer, the logo and the caption buttons. The logo
|
||||||
* is the app menu's second trigger (AppMenu.tsx); everything else that is empty drags the window.
|
* is part of the drag region, unless the app chose it as the menu's trigger (`ShellMenu.trigger`);
|
||||||
|
* everything else that is empty drags the window.
|
||||||
*/
|
*/
|
||||||
export function Titlebar({ maximized }: { maximized: boolean }) {
|
export function Titlebar({ maximized }: { maximized: boolean }) {
|
||||||
|
const trigger = menuTrigger();
|
||||||
return <header className="bt-titlebar" onContextMenu={titlebarContextMenu}>
|
return <header className="bt-titlebar" onContextMenu={titlebarContextMenu}>
|
||||||
<div className="bt-titlebar-drag" data-tauri-drag-region/>
|
<div className="bt-titlebar-drag" data-tauri-drag-region/>
|
||||||
<AppMenuLogo/>
|
{trigger === 'logo' ? <AppMenuLogo/> : <img className="bt-titlebar-logo" src={site().logo} alt="" draggable={false} data-tauri-drag-region/>}
|
||||||
<div className="bt-titlebar-spacer" data-tauri-drag-region/>
|
<div className="bt-titlebar-spacer" data-tauri-drag-region/>
|
||||||
<TitlebarExtras/>
|
<TitlebarExtras/>
|
||||||
<AccountSwitcher/>
|
<AccountSwitcher/>
|
||||||
<UpdateChip/>
|
<UpdateChip/>
|
||||||
{/* One home for the app menu: the logo. Alt+F, F10, the palette and the account menu's last row open it there. */}
|
{/* The app menu, mounted once; it hangs under its trigger (by default each pane's wB button). */}
|
||||||
<AppMenu trigger={false}/>
|
<AppMenu trigger={trigger}/>
|
||||||
{os !== 'macos' && <WindowControls maximized={maximized}/>}
|
{os !== 'macos' && <WindowControls maximized={maximized}/>}
|
||||||
</header>;
|
</header>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import type { IDockviewPanelProps } from 'dockview';
|
||||||
import { Fragment, useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
|
import { Fragment, useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||||
import { tabProfile } from '../profileModel.ts';
|
import { tabProfile } from '../profileModel.ts';
|
||||||
import { AccountAvatar, accountName } from './AccountSwitcher.tsx';
|
import { AccountAvatar, accountName } from './AccountSwitcher.tsx';
|
||||||
|
import { menuTrigger, PaneMenuButton } from './AppMenu.tsx';
|
||||||
import { WB_MARK } from './brand.ts';
|
import { WB_MARK } from './brand.ts';
|
||||||
import { invoke } from './bridge.ts';
|
import { invoke } from './bridge.ts';
|
||||||
import { FindBar } from './FindBar.tsx';
|
import { FindBar } from './FindBar.tsx';
|
||||||
|
|
@ -195,9 +196,12 @@ export function WebPanel(props: IDockviewPanelProps<WebParams>) {
|
||||||
</>}
|
</>}
|
||||||
</div>}
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
<button className="bt-brand-button" title={`${product} (Ctrl+Shift+W)`} aria-label={product} onClick={() => shell.openPanel(api.id)}>
|
{/* The app menu's trigger (its Settings row and the other panel rows open the panel); an app whose menu hangs elsewhere keeps it as the panel's button. */}
|
||||||
<img className="bt-wb" src={WB_MARK} alt="" width={30} height={17} draggable={false}/>
|
{menuTrigger() === 'pane'
|
||||||
</button>
|
? <PaneMenuButton pane={api.id}><img className="bt-wb" src={WB_MARK} alt="" width={30} height={17} draggable={false}/></PaneMenuButton>
|
||||||
|
: <button className="bt-brand-button" title={`${product} (Ctrl+Shift+W)`} aria-label={product} onClick={() => shell.openPanel(api.id)}>
|
||||||
|
<img className="bt-wb" src={WB_MARK} alt="" width={30} height={17} draggable={false}/>
|
||||||
|
</button>}
|
||||||
</div>
|
</div>
|
||||||
<div className="bt-viewport" ref={viewport}>
|
<div className="bt-viewport" ref={viewport}>
|
||||||
{/* Until the page's own webview is on screen: its last frame, else the site's shape (rail, timeline), not a blank pane. */}
|
{/* Until the page's own webview is on screen: its last frame, else the site's shape (rail, timeline), not a blank pane. */}
|
||||||
|
|
|
||||||
|
|
@ -16,17 +16,23 @@
|
||||||
/* An app that shows no menu button (`<AppMenu trigger={false}/>`, the logo is the menu's home) reserves no room for it. */
|
/* An app that shows no menu button (`<AppMenu trigger={false}/>`, the logo is the menu's home) reserves no room for it. */
|
||||||
html:not(:has(.bt-app-button)) { --bt-tb-menu: 0px; }
|
html:not(:has(.bt-app-button)) { --bt-tb-menu: 0px; }
|
||||||
|
|
||||||
/* The logo at the start of the titlebar: the menu's second trigger (a 30px target around the 20px
|
/* The logo at the start of the titlebar. Plain, it is part of the drag region (clicks fall through
|
||||||
mark; with its margins it takes the 50px the tab strip leaves in --bt-tb-left). */
|
to it); as the app's chosen trigger it is a 30px target around the 20px mark. Either way it takes
|
||||||
.bt-titlebar-logo {
|
the 50px the tab strip leaves in --bt-tb-left. */
|
||||||
|
img.bt-titlebar-logo { width: 20px; height: 20px; margin: 12px 14px 12px 16px; flex: 0 0 20px; -webkit-user-drag: none; } /* rtl-ok: titlebar */
|
||||||
|
button.bt-titlebar-logo {
|
||||||
align-self: center; display: inline-grid; place-items: center; width: 30px; height: 30px; flex: 0 0 30px; margin: 0 9px 0 11px; padding: 0; box-sizing: border-box; /* rtl-ok: titlebar */
|
align-self: center; display: inline-grid; place-items: center; width: 30px; height: 30px; flex: 0 0 30px; margin: 0 9px 0 11px; padding: 0; box-sizing: border-box; /* rtl-ok: titlebar */
|
||||||
border: 1px solid transparent; border-radius: 9px; background: transparent; cursor: pointer; -webkit-user-drag: none;
|
border: 1px solid transparent; border-radius: 9px; background: transparent; cursor: pointer; -webkit-user-drag: none;
|
||||||
transition: background-color var(--bt-t1) var(--bt-ease), border-color var(--bt-t1) var(--bt-ease);
|
transition: background-color var(--bt-t1) var(--bt-ease), border-color var(--bt-t1) var(--bt-ease);
|
||||||
}
|
}
|
||||||
.bt-titlebar-logo img { display: block; width: 20px; height: 20px; pointer-events: none; -webkit-user-drag: none; }
|
button.bt-titlebar-logo img { display: block; width: 20px; height: 20px; pointer-events: none; -webkit-user-drag: none; }
|
||||||
.bt-titlebar-logo:hover { background: var(--bt-hover); border-color: var(--bt-line-soft); }
|
button.bt-titlebar-logo:hover { background: var(--bt-hover); border-color: var(--bt-line-soft); }
|
||||||
.bt-titlebar-logo[aria-expanded=true] { background: var(--bt-hover); border-color: rgb(var(--bt-accent-rgb) / .5); }
|
button.bt-titlebar-logo[aria-expanded=true] { background: var(--bt-hover); border-color: rgb(var(--bt-accent-rgb) / .5); }
|
||||||
.bt-titlebar-logo:focus-visible { outline: 0; box-shadow: var(--bt-ring); }
|
button.bt-titlebar-logo:focus-visible { outline: 0; box-shadow: var(--bt-ring); }
|
||||||
|
|
||||||
|
/* Each pane's wB button is the menu's trigger: it stays lit while the menu hangs from it. */
|
||||||
|
.bt-pane-menu[aria-expanded=true] { background: var(--bt-hover); border-color: rgb(var(--bt-accent-rgb) / .55); }
|
||||||
|
.bt-pane-menu:focus-visible { outline: 0; box-shadow: var(--bt-ring); }
|
||||||
|
|
||||||
/* The menu and its submenus. `display: contents`: the layers are positioned on their own. */
|
/* The menu and its submenus. `display: contents`: the layers are positioned on their own. */
|
||||||
.bt-app-layers { display: contents; }
|
.bt-app-layers { display: contents; }
|
||||||
|
|
|
||||||
|
|
@ -55,8 +55,8 @@ export type { PaletteItem, PaletteAction, PaletteContext } from './paletteModel.
|
||||||
export { Icon, Avatar, ICONS, type IconName } from './icons.tsx';
|
export { Icon, Avatar, ICONS, type IconName } from './icons.tsx';
|
||||||
export {
|
export {
|
||||||
DEFAULT_MENU, MENU_BUILTINS, MENU_MESSAGES, menuWith, menuWithout,
|
DEFAULT_MENU, MENU_BUILTINS, MENU_MESSAGES, menuWith, menuWithout,
|
||||||
type BuiltinMenuItem, type MenuEntry, type MenuGroups, type MenuItemSpec, type MenuLanguages, type MenuModes, type ReportDetails, type ShellMenu,
|
type AppMenuTrigger, type BuiltinMenuItem, type MenuEntry, type MenuGroups, type MenuItemSpec, type MenuLanguages, type MenuModes, type ReportDetails, type ShellMenu,
|
||||||
} from './menuModel.ts';
|
} from './menuModel.ts';
|
||||||
export { openAppMenu, type AppMenuRequest } from './AppMenu.tsx';
|
export { menuTrigger, openAppMenu, PaneMenuButton, type AppMenuRequest } from './AppMenu.tsx';
|
||||||
export { createLanguage, resolveLocale, type Language, type LanguageOptions, type LanguagePreference } from './language.ts';
|
export { createLanguage, resolveLocale, type Language, type LanguageOptions, type LanguagePreference } from './language.ts';
|
||||||
export { createAppI18n, type AppI18n, type AppI18nOptions, type AppLanguage } from './appI18n.ts';
|
export { createAppI18n, type AppI18n, type AppI18nOptions, type AppLanguage } from './appI18n.ts';
|
||||||
|
|
|
||||||
|
|
@ -105,8 +105,24 @@ export type MenuModeStatus = { custom: boolean; on: string[]; off: string[]; add
|
||||||
/** Lines an app adds to the "Report a problem" summary (reportModel.ts): ids only, never anything about the person. */
|
/** Lines an app adds to the "Report a problem" summary (reportModel.ts): ids only, never anything about the person. */
|
||||||
export type ReportDetails = ReadonlyArray<{ label: string; values: readonly string[] }>;
|
export type ReportDetails = ReadonlyArray<{ label: string; values: readonly string[] }>;
|
||||||
|
|
||||||
|
/** Where the app menu's trigger is (`ShellMenu.trigger`). */
|
||||||
|
export type AppMenuTrigger = 'pane' | 'button' | 'logo';
|
||||||
|
|
||||||
|
/** The trigger in use: `AppMenu`'s own `trigger` (`true` / `false` are the older `button` / `logo`), else the app's, else `pane`. */
|
||||||
|
export function resolveTrigger(trigger: AppMenuTrigger | boolean | undefined, configured: AppMenuTrigger | undefined): AppMenuTrigger {
|
||||||
|
if (trigger === true) return 'button';
|
||||||
|
if (trigger === false) return 'logo';
|
||||||
|
return trigger ?? configured ?? 'pane';
|
||||||
|
}
|
||||||
|
|
||||||
/** `ShellSite.menu`: the app menu's rows and what the built-in ones need from the app. */
|
/** `ShellSite.menu`: the app menu's rows and what the built-in ones need from the app. */
|
||||||
export type ShellMenu = {
|
export type ShellMenu = {
|
||||||
|
/**
|
||||||
|
* What opens the menu with a click: `pane` (default), each pane's wayBetter button at the end of
|
||||||
|
* its toolbar (the menu hangs under the button that was clicked); `button`, a "⋮" button at the
|
||||||
|
* end of the titlebar; `logo`, the titlebar's logo. Keys and other requests open it at the same place.
|
||||||
|
*/
|
||||||
|
trigger?: AppMenuTrigger;
|
||||||
/** The rows, in order. A string is a built-in row, an object one of the app's. Default: `DEFAULT_MENU`. */
|
/** The rows, in order. A string is a built-in row, an object one of the app's. Default: `DEFAULT_MENU`. */
|
||||||
groups?: MenuGroups;
|
groups?: MenuGroups;
|
||||||
/** The Language submenu; omit for an app in one language. */
|
/** The Language submenu; omit for an app in one language. */
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,7 @@ html, body, #root { height: 100%; margin: 0; overflow: hidden; background: var(-
|
||||||
pointer events only on its own controls; everything else falls through to the tabs and their
|
pointer events only on its own controls; everything else falls through to the tabs and their
|
||||||
drag region. With no tabs the titlebar itself is the drag region again. */
|
drag region. With no tabs the titlebar itself is the drag region again. */
|
||||||
.bt-titlebar { z-index: 2; pointer-events: none; }
|
.bt-titlebar { z-index: 2; pointer-events: none; }
|
||||||
.bt-titlebar > :not(.bt-titlebar-drag):not(.bt-titlebar-spacer) { pointer-events: auto; }
|
.bt-titlebar > :not(.bt-titlebar-drag):not(.bt-titlebar-spacer):not(img.bt-titlebar-logo) { pointer-events: auto; }
|
||||||
.bt-shell--empty .bt-titlebar > * { pointer-events: auto; }
|
.bt-shell--empty .bt-titlebar > * { pointer-events: auto; }
|
||||||
/* Above the dock, so it must not paint: the tab strips hosted in the titlebar row show through it. */
|
/* Above the dock, so it must not paint: the tab strips hosted in the titlebar row show through it. */
|
||||||
.bt-shell:not(.bt-shell--empty) .bt-titlebar { background: transparent; }
|
.bt-shell:not(.bt-shell--empty) .bt-titlebar { background: transparent; }
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { fileURLToPath } from 'node:url';
|
||||||
import { setShellI18n, type ShellI18n } from '../src/frame/i18n.ts';
|
import { setShellI18n, type ShellI18n } from '../src/frame/i18n.ts';
|
||||||
import {
|
import {
|
||||||
DEFAULT_MENU, MENU_BUILTINS, MENU_MESSAGES, ZOOM_MAX, ZOOM_MIN, ZOOM_STEPS, builtinAvailable, findLabel, findSpec, joinGroups, languageChoice, languageRows, menuText, menuWith,
|
DEFAULT_MENU, MENU_BUILTINS, MENU_MESSAGES, ZOOM_MAX, ZOOM_MIN, ZOOM_STEPS, builtinAvailable, findLabel, findSpec, joinGroups, languageChoice, languageRows, menuText, menuWith,
|
||||||
menuWithout, moveStop, planMenu, shortcutText, specRows, submenuKeys, submenuLeft, typeahead, updateRow, zoomPercent, zoomStep,
|
menuWithout, moveStop, planMenu, resolveTrigger, shortcutText, specRows, submenuKeys, submenuLeft, typeahead, updateRow, zoomPercent, zoomStep,
|
||||||
type MenuEnv, type MenuGroups, type MenuItemRow, type MenuItemSpec, type MenuLanguage, type MenuRow,
|
type MenuEnv, type MenuGroups, type MenuItemRow, type MenuItemSpec, type MenuLanguage, type MenuRow,
|
||||||
} from '../src/frame/menuModel.ts';
|
} from '../src/frame/menuModel.ts';
|
||||||
import { buildItems } from '../src/frame/paletteModel.ts';
|
import { buildItems } from '../src/frame/paletteModel.ts';
|
||||||
|
|
@ -271,3 +271,13 @@ test('the palette opens the menu', () => {
|
||||||
const without = buildItems({ tabs: [], profiles: [], activeProfile: 'default' }, '', { site: { placeholder: () => '', sections: [] }, product: 'App', decks: false, media: false, find: false });
|
const without = buildItems({ tabs: [], profiles: [], activeProfile: 'default' }, '', { site: { placeholder: () => '', sections: [] }, product: 'App', decks: false, media: false, find: false });
|
||||||
assert.equal(without.some(item => item.id === 'find'), false);
|
assert.equal(without.some(item => item.id === 'find'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("the menu hangs from each pane's button unless the app chose the titlebar", () => {
|
||||||
|
assert.equal(resolveTrigger(undefined, undefined), 'pane');
|
||||||
|
assert.equal(resolveTrigger(undefined, 'logo'), 'logo');
|
||||||
|
assert.equal(resolveTrigger(undefined, 'button'), 'button');
|
||||||
|
// A trigger given to the component wins over the app's; `true` / `false` are the older spellings.
|
||||||
|
assert.equal(resolveTrigger('pane', 'logo'), 'pane');
|
||||||
|
assert.equal(resolveTrigger(true, 'pane'), 'button');
|
||||||
|
assert.equal(resolveTrigger(false, 'pane'), 'logo');
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue