fix: more work on menus
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { ipcMain, dialog, shell } from 'electron';
|
import { app, ipcMain, dialog, shell } from 'electron';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as fsPromises from 'fs/promises';
|
import * as fsPromises from 'fs/promises';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
@@ -715,6 +715,22 @@ export function registerIpcHandlers(): void {
|
|||||||
|
|
||||||
safeHandle('app:triggerMenuAction', async (event, action: string) => {
|
safeHandle('app:triggerMenuAction', async (event, action: string) => {
|
||||||
const typedAction = action as AppMenuAction;
|
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);
|
const handledByWebContents = runWebContentsMenuAction((event as any)?.sender, typedAction);
|
||||||
if (handledByWebContents) {
|
if (handledByWebContents) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -179,6 +179,21 @@ function createApplicationMenu(): Menu {
|
|||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
const triggerMenuAction = (action: AppMenuAction): void => {
|
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];
|
const channel = APP_MENU_ACTION_EVENT_MAP[action];
|
||||||
if (channel) {
|
if (channel) {
|
||||||
mainWindow?.webContents.send(channel);
|
mainWindow?.webContents.send(channel);
|
||||||
@@ -251,13 +266,7 @@ function createApplicationMenu(): Menu {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{
|
buildSharedMenuItem('quit'),
|
||||||
label: 'Exit',
|
|
||||||
accelerator: process.platform === 'darwin' ? 'Cmd+Q' : 'Alt+F4',
|
|
||||||
click: () => {
|
|
||||||
app.quit();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -313,22 +322,7 @@ function createApplicationMenu(): Menu {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Help',
|
label: 'Help',
|
||||||
submenu: [
|
submenu: buildSharedGroupMenuItems('Help'),
|
||||||
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');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export type AppMenuAction =
|
|||||||
| 'newPost'
|
| 'newPost'
|
||||||
| 'importMedia'
|
| 'importMedia'
|
||||||
| 'save'
|
| 'save'
|
||||||
|
| 'quit'
|
||||||
| 'undo'
|
| 'undo'
|
||||||
| 'redo'
|
| 'redo'
|
||||||
| 'cut'
|
| 'cut'
|
||||||
@@ -20,7 +21,9 @@ export type AppMenuAction =
|
|||||||
| 'rebuildDatabase'
|
| 'rebuildDatabase'
|
||||||
| 'reindexText'
|
| 'reindexText'
|
||||||
| 'metadataDiff'
|
| 'metadataDiff'
|
||||||
| 'about';
|
| 'about'
|
||||||
|
| 'viewOnGitHub'
|
||||||
|
| 'reportIssue';
|
||||||
|
|
||||||
export type AppMenuRole = 'undo' | 'redo' | 'cut' | 'copy' | 'paste' | 'delete' | 'selectAll';
|
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: 'New Post', action: 'newPost', accelerator: 'CmdOrCtrl+N' },
|
||||||
{ label: 'Import Media...', action: 'importMedia', accelerator: 'CmdOrCtrl+I' },
|
{ label: 'Import Media...', action: 'importMedia', accelerator: 'CmdOrCtrl+I' },
|
||||||
{ label: 'Save', action: 'save', accelerator: 'CmdOrCtrl+S' },
|
{ 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',
|
label: 'Help',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'About Blogging Desktop Server', action: 'about' },
|
{ 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' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -46,6 +46,11 @@
|
|||||||
background-color: var(--vscode-toolbar-hoverBackground, rgba(90, 93, 94, 0.31));
|
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 {
|
.window-titlebar-menu-dropdown {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 30px;
|
top: 30px;
|
||||||
@@ -89,6 +94,10 @@
|
|||||||
background-color: var(--vscode-menu-selectionBackground, rgba(9, 71, 113, 0.45));
|
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 {
|
.window-titlebar-menu-item-accelerator {
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useAppStore } from '../../store';
|
import { useAppStore } from '../../store';
|
||||||
import { APP_MENU_GROUPS } from '../../../main/shared/menuCommands';
|
import { APP_MENU_GROUPS } from '../../../main/shared/menuCommands';
|
||||||
import './WindowTitleBar.css';
|
import './WindowTitleBar.css';
|
||||||
@@ -14,7 +14,10 @@ export const WindowTitleBar: React.FC = () => {
|
|||||||
const { sidebarVisible, toggleSidebar } = useAppStore();
|
const { sidebarVisible, toggleSidebar } = useAppStore();
|
||||||
const [windowTitle, setWindowTitle] = useState<string>(document.title || 'Blogging Desktop Server');
|
const [windowTitle, setWindowTitle] = useState<string>(document.title || 'Blogging Desktop Server');
|
||||||
const [openMenu, setOpenMenu] = useState<{ label: string; left: number } | null>(null);
|
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 menuRootRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const menuButtonRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||||
const isMac = navigator.platform.toLowerCase().includes('mac');
|
const isMac = navigator.platform.toLowerCase().includes('mac');
|
||||||
const isDevMode = (window as Window & { __BDS_IS_DEV__?: boolean }).__BDS_IS_DEV__
|
const isDevMode = (window as Window & { __BDS_IS_DEV__?: boolean }).__BDS_IS_DEV__
|
||||||
?? (typeof import.meta !== 'undefined' && Boolean(import.meta.env?.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(() => {
|
useEffect(() => {
|
||||||
const rootStyle = document.documentElement.style;
|
const rootStyle = document.documentElement.style;
|
||||||
const setInsets = (left: number, right: number) => {
|
const setInsets = (left: number, right: number) => {
|
||||||
@@ -110,12 +164,111 @@ export const WindowTitleBar: React.FC = () => {
|
|||||||
const target = event.target as Node;
|
const target = event.target as Node;
|
||||||
if (menuRootRef.current && !menuRootRef.current.contains(target)) {
|
if (menuRootRef.current && !menuRootRef.current.contains(target)) {
|
||||||
setOpenMenu(null);
|
setOpenMenu(null);
|
||||||
|
setShowMnemonics(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onEscape = (event: KeyboardEvent) => {
|
const onEscape = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
setOpenMenu(null);
|
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('mousedown', onDocumentMouseDown);
|
||||||
document.removeEventListener('keydown', onEscape);
|
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 handleMenuButtonClick = (event: React.MouseEvent<HTMLButtonElement>, label: string) => {
|
||||||
const buttonRect = event.currentTarget.getBoundingClientRect();
|
const left = getMenuLeft(label);
|
||||||
const rootRect = menuRootRef.current?.getBoundingClientRect();
|
if (left === null) {
|
||||||
const left = rootRect ? buttonRect.left - rootRect.left : buttonRect.left;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (openMenu?.label === label) {
|
if (openMenu?.label === label) {
|
||||||
setOpenMenu(null);
|
setOpenMenu(null);
|
||||||
|
setKeyboardMenuItemIndex(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setOpenMenu({ label, left });
|
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) => {
|
const handleMenuItemClick = (action: string) => {
|
||||||
setOpenMenu(null);
|
setOpenMenu(null);
|
||||||
|
setKeyboardMenuItemIndex(null);
|
||||||
|
setShowMnemonics(false);
|
||||||
void window.electronAPI?.app?.triggerMenuAction?.(action);
|
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 formatAccelerator = (accelerator: string): string => {
|
||||||
const firstPass = accelerator
|
const firstPass = accelerator
|
||||||
.replace(/CmdOrCtrl/g, isMac ? '⌘' : 'Ctrl')
|
.replace(/CmdOrCtrl/g, isMac ? '⌘' : 'Ctrl')
|
||||||
@@ -162,12 +388,16 @@ export const WindowTitleBar: React.FC = () => {
|
|||||||
{visibleMenuGroups.map(group => (
|
{visibleMenuGroups.map(group => (
|
||||||
<button
|
<button
|
||||||
key={group.label}
|
key={group.label}
|
||||||
|
ref={(element) => {
|
||||||
|
menuButtonRefs.current[group.label] = element;
|
||||||
|
}}
|
||||||
className={`window-titlebar-menu-button${openMenu?.label === group.label ? ' is-active' : ''}`}
|
className={`window-titlebar-menu-button${openMenu?.label === group.label ? ' is-active' : ''}`}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(event) => handleMenuButtonClick(event, group.label)}
|
onClick={(event) => handleMenuButtonClick(event, group.label)}
|
||||||
|
onMouseEnter={(event) => handleMenuButtonMouseEnter(event, group.label)}
|
||||||
aria-label={group.label}
|
aria-label={group.label}
|
||||||
>
|
>
|
||||||
{group.label}
|
{renderMenuLabel(group.label)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -193,18 +423,21 @@ export const WindowTitleBar: React.FC = () => {
|
|||||||
data-testid="window-titlebar-menu-dropdown"
|
data-testid="window-titlebar-menu-dropdown"
|
||||||
style={{ left: `${openMenu.left}px` }}
|
style={{ left: `${openMenu.left}px` }}
|
||||||
>
|
>
|
||||||
{activeMenu.items.map(item => {
|
{activeMenu.items.map((item) => {
|
||||||
if (item.separator) {
|
if (item.separator) {
|
||||||
return <div key={item.action} className="window-titlebar-menu-separator" />;
|
return <div key={item.action} className="window-titlebar-menu-separator" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const acceleratorText = item.accelerator ? formatAccelerator(item.accelerator) : null;
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
key={item.action}
|
key={item.action}
|
||||||
type="button"
|
type="button"
|
||||||
className="window-titlebar-menu-item"
|
className={`window-titlebar-menu-item${isKeyboardActive ? ' is-keyboard-active' : ''}`}
|
||||||
onClick={() => handleMenuItemClick(item.action)}
|
onClick={() => handleMenuItemClick(item.action)}
|
||||||
aria-label={acceleratorText ? `${item.label} ${acceleratorText}` : item.label}
|
aria-label={acceleratorText ? `${item.label} ${acceleratorText}` : item.label}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ const registeredHandlers = new Map<string, (...args: any[]) => Promise<any>>();
|
|||||||
|
|
||||||
// Mock ipcMain to capture handler registrations
|
// Mock ipcMain to capture handler registrations
|
||||||
vi.mock('electron', () => ({
|
vi.mock('electron', () => ({
|
||||||
|
app: {
|
||||||
|
quit: vi.fn(),
|
||||||
|
},
|
||||||
ipcMain: {
|
ipcMain: {
|
||||||
handle: vi.fn((channel: string, handler: (...args: any[]) => Promise<any>) => {
|
handle: vi.fn((channel: string, handler: (...args: any[]) => Promise<any>) => {
|
||||||
registeredHandlers.set(channel, handler);
|
registeredHandlers.set(channel, handler);
|
||||||
@@ -27,6 +30,7 @@ vi.mock('electron', () => ({
|
|||||||
},
|
},
|
||||||
shell: {
|
shell: {
|
||||||
openPath: vi.fn(),
|
openPath: vi.fn(),
|
||||||
|
openExternal: vi.fn(),
|
||||||
showItemInFolder: vi.fn(),
|
showItemInFolder: vi.fn(),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -1371,6 +1375,28 @@ describe('IPC Handlers', () => {
|
|||||||
expect(toggleDevTools).toHaveBeenCalled();
|
expect(toggleDevTools).toHaveBeenCalled();
|
||||||
expect(send).not.toHaveBeenCalled();
|
expect(send).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should quit the application when action is quit', async () => {
|
||||||
|
const { app } = await import('electron');
|
||||||
|
const send = vi.fn();
|
||||||
|
const event = { sender: { send } };
|
||||||
|
|
||||||
|
await invokeHandlerWithEvent(event, 'app:triggerMenuAction', 'quit');
|
||||||
|
|
||||||
|
expect(app.quit).toHaveBeenCalled();
|
||||||
|
expect(send).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should open repository URL when action is viewOnGitHub', async () => {
|
||||||
|
const { shell } = await import('electron');
|
||||||
|
const send = vi.fn();
|
||||||
|
const event = { sender: { send } };
|
||||||
|
|
||||||
|
await invokeHandlerWithEvent(event, 'app:triggerMenuAction', 'viewOnGitHub');
|
||||||
|
|
||||||
|
expect(shell.openExternal).toHaveBeenCalledWith('https://github.com/rfc1437/bDS');
|
||||||
|
expect(send).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -146,6 +146,129 @@ describe('WindowTitleBar', () => {
|
|||||||
expect(screen.getByRole('button', { name: 'Publish Selected Ctrl+Shift+P' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: 'Publish Selected Ctrl+Shift+P' })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows Quit in File menu and View on GitHub in Help menu', () => {
|
||||||
|
render(<WindowTitleBar />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'File' }));
|
||||||
|
expect(screen.getByRole('button', { name: 'Quit Ctrl+Q' })).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Help' }));
|
||||||
|
expect(screen.getByRole('button', { name: 'View on GitHub' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches to another menu on hover when a menu is already open', () => {
|
||||||
|
render(<WindowTitleBar />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'File' }));
|
||||||
|
expect(screen.getByRole('button', { name: 'New Post Ctrl+N' })).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.mouseEnter(screen.getByRole('button', { name: 'Edit' }));
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: 'Undo Ctrl+Z' })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: 'New Post Ctrl+N' })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows menu mnemonics when Alt is pressed', () => {
|
||||||
|
const { container } = render(<WindowTitleBar />);
|
||||||
|
|
||||||
|
expect(container.querySelector('.window-titlebar-menu-mnemonic')).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.keyDown(document, { key: 'Alt' });
|
||||||
|
|
||||||
|
expect(container.querySelector('.window-titlebar-menu-mnemonic')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens File menu when Alt+F is pressed', () => {
|
||||||
|
render(<WindowTitleBar />);
|
||||||
|
|
||||||
|
fireEvent.keyDown(document, { key: 'f', altKey: true });
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: 'New Post Ctrl+N' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigates menu items with arrow keys and activates selection with Enter', () => {
|
||||||
|
const triggerMenuAction = vi.fn().mockResolvedValue(undefined);
|
||||||
|
window.electronAPI.app = {
|
||||||
|
...(window.electronAPI.app || {}),
|
||||||
|
triggerMenuAction,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<WindowTitleBar />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'File' }));
|
||||||
|
fireEvent.keyDown(document, { key: 'ArrowDown' });
|
||||||
|
fireEvent.keyDown(document, { key: 'Enter' });
|
||||||
|
|
||||||
|
expect(triggerMenuAction).toHaveBeenCalledWith('newPost');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches open menu with ArrowRight and ArrowLeft', () => {
|
||||||
|
render(<WindowTitleBar />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'File' }));
|
||||||
|
expect(screen.getByRole('button', { name: 'New Post Ctrl+N' })).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.keyDown(document, { key: 'ArrowRight' });
|
||||||
|
expect(screen.getByRole('button', { name: 'Undo Ctrl+Z' })).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.keyDown(document, { key: 'ArrowLeft' });
|
||||||
|
expect(screen.getByRole('button', { name: 'New Post Ctrl+N' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('jumps to first and last menu item with Home and End', () => {
|
||||||
|
const triggerMenuAction = vi.fn().mockResolvedValue(undefined);
|
||||||
|
window.electronAPI.app = {
|
||||||
|
...(window.electronAPI.app || {}),
|
||||||
|
triggerMenuAction,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<WindowTitleBar />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'File' }));
|
||||||
|
|
||||||
|
fireEvent.keyDown(document, { key: 'End' });
|
||||||
|
fireEvent.keyDown(document, { key: 'Enter' });
|
||||||
|
expect(triggerMenuAction).toHaveBeenCalledWith('quit');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'File' }));
|
||||||
|
fireEvent.keyDown(document, { key: 'Home' });
|
||||||
|
fireEvent.keyDown(document, { key: 'Enter' });
|
||||||
|
expect(triggerMenuAction).toHaveBeenCalledWith('newPost');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('jumps to matching item by first letter key and activates with Enter', () => {
|
||||||
|
const triggerMenuAction = vi.fn().mockResolvedValue(undefined);
|
||||||
|
window.electronAPI.app = {
|
||||||
|
...(window.electronAPI.app || {}),
|
||||||
|
triggerMenuAction,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<WindowTitleBar />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'File' }));
|
||||||
|
fireEvent.keyDown(document, { key: 'i' });
|
||||||
|
fireEvent.keyDown(document, { key: 'Enter' });
|
||||||
|
|
||||||
|
expect(triggerMenuAction).toHaveBeenCalledWith('importMedia');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cycles through same-letter matches on repeated key presses', () => {
|
||||||
|
const triggerMenuAction = vi.fn().mockResolvedValue(undefined);
|
||||||
|
window.electronAPI.app = {
|
||||||
|
...(window.electronAPI.app || {}),
|
||||||
|
triggerMenuAction,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<WindowTitleBar />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
|
||||||
|
fireEvent.keyDown(document, { key: 'r' });
|
||||||
|
fireEvent.keyDown(document, { key: 'r' });
|
||||||
|
fireEvent.keyDown(document, { key: 'Enter' });
|
||||||
|
|
||||||
|
expect(triggerMenuAction).toHaveBeenCalledWith('replace');
|
||||||
|
});
|
||||||
|
|
||||||
it('shows Toggle Developer Tools in View menu in development mode', () => {
|
it('shows Toggle Developer Tools in View menu in development mode', () => {
|
||||||
(window as Window & { __BDS_IS_DEV__?: boolean }).__BDS_IS_DEV__ = true;
|
(window as Window & { __BDS_IS_DEV__?: boolean }).__BDS_IS_DEV__ = true;
|
||||||
const triggerMenuAction = vi.fn().mockResolvedValue(undefined);
|
const triggerMenuAction = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|||||||
Reference in New Issue
Block a user