fix: more work on menus

This commit is contained in:
2026-02-17 11:25:50 +01:00
parent 7b5829e965
commit 70bc0b1b09
7 changed files with 442 additions and 33 deletions

View File

@@ -1,4 +1,4 @@
import { ipcMain, dialog, shell } from 'electron';
import { app, ipcMain, dialog, shell } from 'electron';
import * as path from 'path';
import * as fsPromises from 'fs/promises';
import { eq } from 'drizzle-orm';
@@ -715,6 +715,22 @@ export function registerIpcHandlers(): void {
safeHandle('app:triggerMenuAction', async (event, action: string) => {
const typedAction = action as AppMenuAction;
if (typedAction === 'quit') {
app.quit();
return;
}
if (typedAction === 'viewOnGitHub') {
await shell.openExternal('https://github.com/rfc1437/bDS');
return;
}
if (typedAction === 'reportIssue') {
await shell.openExternal('https://github.com/rfc1437/bDS/issues');
return;
}
const handledByWebContents = runWebContentsMenuAction((event as any)?.sender, typedAction);
if (handledByWebContents) {
return;

View File

@@ -179,6 +179,21 @@ function createApplicationMenu(): Menu {
}, {});
const triggerMenuAction = (action: AppMenuAction): void => {
if (action === 'quit') {
app.quit();
return;
}
if (action === 'viewOnGitHub') {
void shell.openExternal('https://github.com/rfc1437/bDS');
return;
}
if (action === 'reportIssue') {
void shell.openExternal('https://github.com/rfc1437/bDS/issues');
return;
}
const channel = APP_MENU_ACTION_EVENT_MAP[action];
if (channel) {
mainWindow?.webContents.send(channel);
@@ -251,13 +266,7 @@ function createApplicationMenu(): Menu {
},
},
{ type: 'separator' },
{
label: 'Exit',
accelerator: process.platform === 'darwin' ? 'Cmd+Q' : 'Alt+F4',
click: () => {
app.quit();
},
},
buildSharedMenuItem('quit'),
],
},
{
@@ -313,22 +322,7 @@ function createApplicationMenu(): Menu {
},
{
label: 'Help',
submenu: [
buildSharedMenuItem('about'),
{ type: 'separator' },
{
label: 'View on GitHub',
click: async () => {
await shell.openExternal('https://github.com/rfc1437/bDS');
},
},
{
label: 'Report Issue',
click: async () => {
await shell.openExternal('https://github.com/rfc1437/bDS/issues');
},
},
],
submenu: buildSharedGroupMenuItems('Help'),
},
];

View File

@@ -2,6 +2,7 @@ export type AppMenuAction =
| 'newPost'
| 'importMedia'
| 'save'
| 'quit'
| 'undo'
| 'redo'
| 'cut'
@@ -20,7 +21,9 @@ export type AppMenuAction =
| 'rebuildDatabase'
| 'reindexText'
| 'metadataDiff'
| 'about';
| 'about'
| 'viewOnGitHub'
| 'reportIssue';
export type AppMenuRole = 'undo' | 'redo' | 'cut' | 'copy' | 'paste' | 'delete' | 'selectAll';
@@ -44,6 +47,8 @@ export const APP_MENU_GROUPS: AppMenuGroupDefinition[] = [
{ label: 'New Post', action: 'newPost', accelerator: 'CmdOrCtrl+N' },
{ label: 'Import Media...', action: 'importMedia', accelerator: 'CmdOrCtrl+I' },
{ label: 'Save', action: 'save', accelerator: 'CmdOrCtrl+S' },
{ label: '', action: 'file-separator-1', separator: true },
{ label: 'Quit', action: 'quit', accelerator: 'CmdOrCtrl+Q' },
],
},
{
@@ -86,6 +91,9 @@ export const APP_MENU_GROUPS: AppMenuGroupDefinition[] = [
label: 'Help',
items: [
{ label: 'About Blogging Desktop Server', action: 'about' },
{ label: '', action: 'help-separator-1', separator: true },
{ label: 'View on GitHub', action: 'viewOnGitHub' },
{ label: 'Report Issue', action: 'reportIssue' },
],
},
];

View File

@@ -46,6 +46,11 @@
background-color: var(--vscode-toolbar-hoverBackground, rgba(90, 93, 94, 0.31));
}
.window-titlebar-menu-mnemonic {
text-decoration: underline;
text-underline-offset: 2px;
}
.window-titlebar-menu-dropdown {
position: absolute;
top: 30px;
@@ -89,6 +94,10 @@
background-color: var(--vscode-menu-selectionBackground, rgba(9, 71, 113, 0.45));
}
.window-titlebar-menu-item.is-keyboard-active {
background-color: var(--vscode-menu-selectionBackground, rgba(9, 71, 113, 0.45));
}
.window-titlebar-menu-item-accelerator {
opacity: 0.8;
}

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useAppStore } from '../../store';
import { APP_MENU_GROUPS } from '../../../main/shared/menuCommands';
import './WindowTitleBar.css';
@@ -14,7 +14,10 @@ export const WindowTitleBar: React.FC = () => {
const { sidebarVisible, toggleSidebar } = useAppStore();
const [windowTitle, setWindowTitle] = useState<string>(document.title || 'Blogging Desktop Server');
const [openMenu, setOpenMenu] = useState<{ label: string; left: number } | null>(null);
const [showMnemonics, setShowMnemonics] = useState<boolean>(false);
const [keyboardMenuItemIndex, setKeyboardMenuItemIndex] = useState<number | null>(null);
const menuRootRef = useRef<HTMLDivElement | null>(null);
const menuButtonRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const isMac = navigator.platform.toLowerCase().includes('mac');
const isDevMode = (window as Window & { __BDS_IS_DEV__?: boolean }).__BDS_IS_DEV__
?? (typeof import.meta !== 'undefined' && Boolean(import.meta.env?.DEV));
@@ -30,6 +33,57 @@ export const WindowTitleBar: React.FC = () => {
};
});
const mnemonicByKey = useMemo(() => {
return visibleMenuGroups.reduce<Record<string, string>>((acc, group) => {
const mnemonicKey = group.label.charAt(0).toLowerCase();
acc[mnemonicKey] = group.label;
return acc;
}, {});
}, [visibleMenuGroups]);
const getMenuLeft = (label: string): number | null => {
const button = menuButtonRefs.current[label];
if (!button) {
return null;
}
const buttonRect = button.getBoundingClientRect();
const rootRect = menuRootRef.current?.getBoundingClientRect();
return rootRect ? buttonRect.left - rootRect.left : buttonRect.left;
};
const openMenuByLabel = (label: string) => {
const left = getMenuLeft(label);
if (left === null) {
return;
}
setKeyboardMenuItemIndex(null);
setOpenMenu({ label, left });
};
const getMenuActionableItems = (label: string) => {
const group = visibleMenuGroups.find(item => item.label === label);
if (!group) {
return [];
}
return group.items.filter(item => !item.separator);
};
const switchOpenMenuByOffset = (offset: number) => {
if (!openMenu) {
return;
}
const currentIndex = visibleMenuGroups.findIndex(group => group.label === openMenu.label);
if (currentIndex < 0) {
return;
}
const nextIndex = (currentIndex + offset + visibleMenuGroups.length) % visibleMenuGroups.length;
openMenuByLabel(visibleMenuGroups[nextIndex].label);
};
useEffect(() => {
const rootStyle = document.documentElement.style;
const setInsets = (left: number, right: number) => {
@@ -110,12 +164,111 @@ export const WindowTitleBar: React.FC = () => {
const target = event.target as Node;
if (menuRootRef.current && !menuRootRef.current.contains(target)) {
setOpenMenu(null);
setShowMnemonics(false);
}
};
const onEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setOpenMenu(null);
setKeyboardMenuItemIndex(null);
setShowMnemonics(false);
return;
}
if (event.key === 'ArrowRight') {
event.preventDefault();
switchOpenMenuByOffset(1);
return;
}
if (event.key === 'ArrowLeft') {
event.preventDefault();
switchOpenMenuByOffset(-1);
return;
}
if (!openMenu) {
return;
}
const actionableItems = getMenuActionableItems(openMenu.label);
if (actionableItems.length === 0) {
return;
}
if (event.key === 'ArrowDown') {
event.preventDefault();
setKeyboardMenuItemIndex((previous) => {
if (previous === null) {
return 0;
}
return (previous + 1) % actionableItems.length;
});
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
setKeyboardMenuItemIndex((previous) => {
if (previous === null) {
return actionableItems.length - 1;
}
return (previous - 1 + actionableItems.length) % actionableItems.length;
});
return;
}
if ((event.key === 'Enter' || event.key === ' ') && keyboardMenuItemIndex !== null) {
event.preventDefault();
const targetItem = actionableItems[keyboardMenuItemIndex];
if (targetItem) {
void window.electronAPI?.app?.triggerMenuAction?.(targetItem.action);
setOpenMenu(null);
setKeyboardMenuItemIndex(null);
setShowMnemonics(false);
}
return;
}
if (event.key === 'Home') {
event.preventDefault();
setKeyboardMenuItemIndex(0);
return;
}
if (event.key === 'End') {
event.preventDefault();
setKeyboardMenuItemIndex(actionableItems.length - 1);
return;
}
if (event.key.length === 1 && !event.altKey && !event.shiftKey) {
const typed = event.key.toLowerCase();
const matchingIndices = actionableItems
.map((item, index) => ({ item, index }))
.filter(entry => entry.item.label.toLowerCase().startsWith(typed))
.map(entry => entry.index);
if (matchingIndices.length === 0) {
return;
}
event.preventDefault();
if (keyboardMenuItemIndex === null) {
setKeyboardMenuItemIndex(matchingIndices[0]);
return;
}
const currentMatchPosition = matchingIndices.findIndex(index => index === keyboardMenuItemIndex);
if (currentMatchPosition >= 0) {
const nextPosition = (currentMatchPosition + 1) % matchingIndices.length;
setKeyboardMenuItemIndex(matchingIndices[nextPosition]);
return;
}
const firstAfterCurrent = matchingIndices.find(index => index > keyboardMenuItemIndex);
setKeyboardMenuItemIndex(firstAfterCurrent ?? matchingIndices[0]);
}
};
@@ -126,26 +279,99 @@ export const WindowTitleBar: React.FC = () => {
document.removeEventListener('mousedown', onDocumentMouseDown);
document.removeEventListener('keydown', onEscape);
};
}, [openMenu]);
}, [keyboardMenuItemIndex, openMenu, visibleMenuGroups]);
useEffect(() => {
setKeyboardMenuItemIndex(null);
}, [openMenu?.label]);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented || event.metaKey || event.ctrlKey) {
return;
}
if (event.key === 'Alt' && !event.shiftKey) {
setShowMnemonics(true);
return;
}
if (event.altKey && event.key.length === 1) {
const targetMenuLabel = mnemonicByKey[event.key.toLowerCase()];
if (targetMenuLabel) {
event.preventDefault();
setShowMnemonics(true);
openMenuByLabel(targetMenuLabel);
}
return;
}
if (showMnemonics && event.key !== 'Shift') {
setShowMnemonics(false);
}
};
const onDocumentMouseDown = () => {
if (showMnemonics) {
setShowMnemonics(false);
}
};
document.addEventListener('keydown', onKeyDown);
document.addEventListener('mousedown', onDocumentMouseDown);
return () => {
document.removeEventListener('keydown', onKeyDown);
document.removeEventListener('mousedown', onDocumentMouseDown);
};
}, [mnemonicByKey, showMnemonics]);
const handleMenuButtonClick = (event: React.MouseEvent<HTMLButtonElement>, label: string) => {
const buttonRect = event.currentTarget.getBoundingClientRect();
const rootRect = menuRootRef.current?.getBoundingClientRect();
const left = rootRect ? buttonRect.left - rootRect.left : buttonRect.left;
const left = getMenuLeft(label);
if (left === null) {
return;
}
if (openMenu?.label === label) {
setOpenMenu(null);
setKeyboardMenuItemIndex(null);
return;
}
setOpenMenu({ label, left });
};
const handleMenuButtonMouseEnter = (event: React.MouseEvent<HTMLButtonElement>, label: string) => {
if (!openMenu || openMenu.label === label) {
return;
}
const buttonRect = event.currentTarget.getBoundingClientRect();
const rootRect = menuRootRef.current?.getBoundingClientRect();
const left = rootRect ? buttonRect.left - rootRect.left : buttonRect.left;
setOpenMenu({ label, left });
};
const handleMenuItemClick = (action: string) => {
setOpenMenu(null);
setKeyboardMenuItemIndex(null);
setShowMnemonics(false);
void window.electronAPI?.app?.triggerMenuAction?.(action);
};
const renderMenuLabel = (label: string) => {
if (!showMnemonics || label.length === 0) {
return label;
}
return (
<>
<span className="window-titlebar-menu-mnemonic">{label.charAt(0)}</span>
{label.slice(1)}
</>
);
};
const formatAccelerator = (accelerator: string): string => {
const firstPass = accelerator
.replace(/CmdOrCtrl/g, isMac ? '⌘' : 'Ctrl')
@@ -162,12 +388,16 @@ export const WindowTitleBar: React.FC = () => {
{visibleMenuGroups.map(group => (
<button
key={group.label}
ref={(element) => {
menuButtonRefs.current[group.label] = element;
}}
className={`window-titlebar-menu-button${openMenu?.label === group.label ? ' is-active' : ''}`}
type="button"
onClick={(event) => handleMenuButtonClick(event, group.label)}
onMouseEnter={(event) => handleMenuButtonMouseEnter(event, group.label)}
aria-label={group.label}
>
{group.label}
{renderMenuLabel(group.label)}
</button>
))}
</div>
@@ -193,18 +423,21 @@ export const WindowTitleBar: React.FC = () => {
data-testid="window-titlebar-menu-dropdown"
style={{ left: `${openMenu.left}px` }}
>
{activeMenu.items.map(item => {
{activeMenu.items.map((item) => {
if (item.separator) {
return <div key={item.action} className="window-titlebar-menu-separator" />;
}
const acceleratorText = item.accelerator ? formatAccelerator(item.accelerator) : null;
const actionableItems = activeMenu.items.filter(menuItem => !menuItem.separator);
const currentActionableIndex = actionableItems.findIndex(menuItem => menuItem.action === item.action);
const isKeyboardActive = keyboardMenuItemIndex !== null && keyboardMenuItemIndex === currentActionableIndex;
return (
<button
key={item.action}
type="button"
className="window-titlebar-menu-item"
className={`window-titlebar-menu-item${isKeyboardActive ? ' is-keyboard-active' : ''}`}
onClick={() => handleMenuItemClick(item.action)}
aria-label={acceleratorText ? `${item.label} ${acceleratorText}` : item.label}
>