From 70bc0b1b09bb6114d8d4b997cacc877adac29c6c Mon Sep 17 00:00:00 2001 From: hugo Date: Tue, 17 Feb 2026 11:25:50 +0100 Subject: [PATCH] fix: more work on menus --- src/main/ipc/handlers.ts | 18 +- src/main/main.ts | 40 ++- src/main/shared/menuCommands.ts | 10 +- .../WindowTitleBar/WindowTitleBar.css | 9 + .../WindowTitleBar/WindowTitleBar.tsx | 249 +++++++++++++++++- tests/ipc/handlers.test.ts | 26 ++ .../components/WindowTitleBar.test.tsx | 123 +++++++++ 7 files changed, 442 insertions(+), 33 deletions(-) diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index efd29e1..62886da 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -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; diff --git a/src/main/main.ts b/src/main/main.ts index 083e68a..f5dd29e 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -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'), }, ]; diff --git a/src/main/shared/menuCommands.ts b/src/main/shared/menuCommands.ts index e5a6bc7..ec05d22 100644 --- a/src/main/shared/menuCommands.ts +++ b/src/main/shared/menuCommands.ts @@ -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' }, ], }, ]; diff --git a/src/renderer/components/WindowTitleBar/WindowTitleBar.css b/src/renderer/components/WindowTitleBar/WindowTitleBar.css index b0f4d99..d40b4a3 100644 --- a/src/renderer/components/WindowTitleBar/WindowTitleBar.css +++ b/src/renderer/components/WindowTitleBar/WindowTitleBar.css @@ -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; } diff --git a/src/renderer/components/WindowTitleBar/WindowTitleBar.tsx b/src/renderer/components/WindowTitleBar/WindowTitleBar.tsx index e98ad42..28aa251 100644 --- a/src/renderer/components/WindowTitleBar/WindowTitleBar.tsx +++ b/src/renderer/components/WindowTitleBar/WindowTitleBar.tsx @@ -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(document.title || 'Blogging Desktop Server'); const [openMenu, setOpenMenu] = useState<{ label: string; left: number } | null>(null); + const [showMnemonics, setShowMnemonics] = useState(false); + const [keyboardMenuItemIndex, setKeyboardMenuItemIndex] = useState(null); const menuRootRef = useRef(null); + const menuButtonRefs = useRef>({}); 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>((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, 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, 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 ( + <> + {label.charAt(0)} + {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 => ( ))} @@ -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
; } 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 (