feat: custom title bar that is more compact
This commit is contained in:
@@ -12,6 +12,7 @@ import { getGitEngine } from '../engine/GitEngine';
|
|||||||
import { taskManager, TaskProgress } from '../engine/TaskManager';
|
import { taskManager, TaskProgress } from '../engine/TaskManager';
|
||||||
import { getDatabase } from '../database';
|
import { getDatabase } from '../database';
|
||||||
import { media } from '../database/schema';
|
import { media } from '../database/schema';
|
||||||
|
import { APP_MENU_ACTION_EVENT_MAP, APP_MENU_WEB_CONTENTS_ACTIONS, type AppMenuAction } from '../shared/menuCommands';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrap an IPC handler so that "Database is closing" errors during shutdown
|
* Wrap an IPC handler so that "Database is closing" errors during shutdown
|
||||||
@@ -46,6 +47,45 @@ function resolvePostCreatedAt(post: { createdAt: Date | string }): Date {
|
|||||||
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
|
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function runWebContentsMenuAction(sender: any, action: AppMenuAction): boolean {
|
||||||
|
if (!sender) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!APP_MENU_WEB_CONTENTS_ACTIONS.has(action)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'undo':
|
||||||
|
sender.undo?.();
|
||||||
|
return true;
|
||||||
|
case 'redo':
|
||||||
|
sender.redo?.();
|
||||||
|
return true;
|
||||||
|
case 'cut':
|
||||||
|
sender.cut?.();
|
||||||
|
return true;
|
||||||
|
case 'copy':
|
||||||
|
sender.copy?.();
|
||||||
|
return true;
|
||||||
|
case 'paste':
|
||||||
|
sender.paste?.();
|
||||||
|
return true;
|
||||||
|
case 'delete':
|
||||||
|
sender.delete?.();
|
||||||
|
return true;
|
||||||
|
case 'selectAll':
|
||||||
|
sender.selectAll?.();
|
||||||
|
return true;
|
||||||
|
case 'toggleDevTools':
|
||||||
|
sender.toggleDevTools?.();
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function registerIpcHandlers(): void {
|
export function registerIpcHandlers(): void {
|
||||||
// ============ Git Handlers ============
|
// ============ Git Handlers ============
|
||||||
|
|
||||||
@@ -673,6 +713,20 @@ export function registerIpcHandlers(): void {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
safeHandle('app:triggerMenuAction', async (event, action: string) => {
|
||||||
|
const typedAction = action as AppMenuAction;
|
||||||
|
const handledByWebContents = runWebContentsMenuAction((event as any)?.sender, typedAction);
|
||||||
|
if (handledByWebContents) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = APP_MENU_ACTION_EVENT_MAP[typedAction];
|
||||||
|
if (!channel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.sender.send(channel);
|
||||||
|
});
|
||||||
|
|
||||||
// ============ Meta Handlers ============
|
// ============ Meta Handlers ============
|
||||||
|
|
||||||
safeHandle('meta:getTags', async () => {
|
safeHandle('meta:getTags', async () => {
|
||||||
|
|||||||
173
src/main/main.ts
173
src/main/main.ts
@@ -8,6 +8,7 @@ import { eq } from 'drizzle-orm';
|
|||||||
import { getMediaEngine } from './engine/MediaEngine';
|
import { getMediaEngine } from './engine/MediaEngine';
|
||||||
import { getPostEngine } from './engine/PostEngine';
|
import { getPostEngine } from './engine/PostEngine';
|
||||||
import { PreviewServer } from './engine/PreviewServer';
|
import { PreviewServer } from './engine/PreviewServer';
|
||||||
|
import { APP_MENU_ACTION_EVENT_MAP, APP_MENU_GROUPS, type AppMenuAction, type AppMenuItemDefinition } from './shared/menuCommands';
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
let previewServer: PreviewServer | null = null;
|
let previewServer: PreviewServer | null = null;
|
||||||
@@ -58,7 +59,7 @@ function createWindow(): void {
|
|||||||
symbolColor: '#cccccc',
|
symbolColor: '#cccccc',
|
||||||
height: 34,
|
height: 34,
|
||||||
},
|
},
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: false,
|
||||||
}),
|
}),
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: path.join(__dirname, 'preload.js'),
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
@@ -169,32 +170,67 @@ async function startPreviewServerOnAppStart(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createApplicationMenu(): Menu {
|
function createApplicationMenu(): Menu {
|
||||||
|
const commandDefinitions = APP_MENU_GROUPS
|
||||||
|
.flatMap(group => group.items)
|
||||||
|
.filter(item => !item.separator)
|
||||||
|
.reduce<Record<string, AppMenuItemDefinition>>((acc, item) => {
|
||||||
|
acc[item.action] = item;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const triggerMenuAction = (action: AppMenuAction): void => {
|
||||||
|
const channel = APP_MENU_ACTION_EVENT_MAP[action];
|
||||||
|
if (channel) {
|
||||||
|
mainWindow?.webContents.send(channel);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildSharedMenuItem = (action: AppMenuAction): MenuItemConstructorOptions => {
|
||||||
|
const definition = commandDefinitions[action];
|
||||||
|
if (!definition) {
|
||||||
|
throw new Error(`Unknown shared menu action: ${action}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (definition.role) {
|
||||||
|
return {
|
||||||
|
label: definition.label,
|
||||||
|
role: definition.role,
|
||||||
|
accelerator: definition.accelerator,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: definition.label,
|
||||||
|
accelerator: definition.accelerator,
|
||||||
|
click: () => {
|
||||||
|
triggerMenuAction(action);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildSharedGroupMenuItems = (groupLabel: string): MenuItemConstructorOptions[] => {
|
||||||
|
const group = APP_MENU_GROUPS.find(item => item.label === groupLabel);
|
||||||
|
if (!group) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return group.items.map((item) => {
|
||||||
|
if (item.separator) {
|
||||||
|
return { type: 'separator' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildSharedMenuItem(item.action as AppMenuAction);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const template: MenuItemConstructorOptions[] = [
|
const template: MenuItemConstructorOptions[] = [
|
||||||
{
|
{
|
||||||
label: 'File',
|
label: 'File',
|
||||||
submenu: [
|
submenu: [
|
||||||
{
|
buildSharedMenuItem('newPost'),
|
||||||
label: 'New Post',
|
buildSharedMenuItem('importMedia'),
|
||||||
accelerator: 'CmdOrCtrl+N',
|
|
||||||
click: () => {
|
|
||||||
mainWindow?.webContents.send('menu:newPost');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Import Media...',
|
|
||||||
accelerator: 'CmdOrCtrl+I',
|
|
||||||
click: () => {
|
|
||||||
mainWindow?.webContents.send('menu:importMedia');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{
|
buildSharedMenuItem('save'),
|
||||||
label: 'Save',
|
|
||||||
accelerator: 'CmdOrCtrl+S',
|
|
||||||
click: () => {
|
|
||||||
mainWindow?.webContents.send('menu:save');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{
|
{
|
||||||
label: 'Open in Browser',
|
label: 'Open in Browser',
|
||||||
@@ -226,65 +262,12 @@ function createApplicationMenu(): Menu {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Edit',
|
label: 'Edit',
|
||||||
submenu: [
|
submenu: buildSharedGroupMenuItems('Edit'),
|
||||||
{ role: 'undo' },
|
|
||||||
{ role: 'redo' },
|
|
||||||
{ type: 'separator' },
|
|
||||||
{ role: 'cut' },
|
|
||||||
{ role: 'copy' },
|
|
||||||
{ role: 'paste' },
|
|
||||||
{ role: 'delete' },
|
|
||||||
{ type: 'separator' },
|
|
||||||
{ role: 'selectAll' },
|
|
||||||
{ type: 'separator' },
|
|
||||||
{
|
|
||||||
label: 'Find',
|
|
||||||
accelerator: 'CmdOrCtrl+F',
|
|
||||||
click: () => {
|
|
||||||
mainWindow?.webContents.send('menu:find');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Replace',
|
|
||||||
accelerator: 'CmdOrCtrl+H',
|
|
||||||
click: () => {
|
|
||||||
mainWindow?.webContents.send('menu:replace');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'View',
|
label: 'View',
|
||||||
submenu: [
|
submenu: [
|
||||||
{
|
...buildSharedGroupMenuItems('View'),
|
||||||
label: 'Posts',
|
|
||||||
accelerator: 'CmdOrCtrl+1',
|
|
||||||
click: () => {
|
|
||||||
mainWindow?.webContents.send('menu:viewPosts');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Media',
|
|
||||||
accelerator: 'CmdOrCtrl+2',
|
|
||||||
click: () => {
|
|
||||||
mainWindow?.webContents.send('menu:viewMedia');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ type: 'separator' },
|
|
||||||
{
|
|
||||||
label: 'Toggle Sidebar',
|
|
||||||
accelerator: 'CmdOrCtrl+B',
|
|
||||||
click: () => {
|
|
||||||
mainWindow?.webContents.send('menu:toggleSidebar');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Toggle Panel',
|
|
||||||
accelerator: 'CmdOrCtrl+J',
|
|
||||||
click: () => {
|
|
||||||
mainWindow?.webContents.send('menu:togglePanel');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ role: 'reload' },
|
{ role: 'reload' },
|
||||||
{ role: 'forceReload' },
|
{ role: 'forceReload' },
|
||||||
@@ -306,13 +289,7 @@ function createApplicationMenu(): Menu {
|
|||||||
{
|
{
|
||||||
label: 'Blog',
|
label: 'Blog',
|
||||||
submenu: [
|
submenu: [
|
||||||
{
|
buildSharedMenuItem('publishSelected'),
|
||||||
label: 'Publish Selected',
|
|
||||||
accelerator: 'CmdOrCtrl+Shift+P',
|
|
||||||
click: () => {
|
|
||||||
mainWindow?.webContents.send('menu:publishSelected');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{
|
{
|
||||||
label: 'Preview Post',
|
label: 'Preview Post',
|
||||||
@@ -328,36 +305,16 @@ function createApplicationMenu(): Menu {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{
|
buildSharedMenuItem('rebuildDatabase'),
|
||||||
label: 'Rebuild Database from Files',
|
buildSharedMenuItem('reindexText'),
|
||||||
click: () => {
|
|
||||||
mainWindow?.webContents.send('menu:rebuildDatabase');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Reindex Search Text',
|
|
||||||
click: () => {
|
|
||||||
mainWindow?.webContents.send('menu:reindexText');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{
|
buildSharedMenuItem('metadataDiff'),
|
||||||
label: 'Metadata Diff Tool',
|
|
||||||
click: () => {
|
|
||||||
mainWindow?.webContents.send('menu:metadataDiff');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Help',
|
label: 'Help',
|
||||||
submenu: [
|
submenu: [
|
||||||
{
|
buildSharedMenuItem('about'),
|
||||||
label: 'About Blogging Desktop Server',
|
|
||||||
click: () => {
|
|
||||||
mainWindow?.webContents.send('menu:about');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{
|
{
|
||||||
label: 'View on GitHub',
|
label: 'View on GitHub',
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ export const electronAPI: ElectronAPI = {
|
|||||||
getDefaultProjectPath: (projectId: string) => ipcRenderer.invoke('app:getDefaultProjectPath', projectId),
|
getDefaultProjectPath: (projectId: string) => ipcRenderer.invoke('app:getDefaultProjectPath', projectId),
|
||||||
readProjectMetadata: (folderPath: string) => ipcRenderer.invoke('app:readProjectMetadata', folderPath),
|
readProjectMetadata: (folderPath: string) => ipcRenderer.invoke('app:readProjectMetadata', folderPath),
|
||||||
setPreviewPostTarget: (postId: string | null) => ipcRenderer.invoke('app:setPreviewPostTarget', postId),
|
setPreviewPostTarget: (postId: string | null) => ipcRenderer.invoke('app:setPreviewPostTarget', postId),
|
||||||
|
triggerMenuAction: (action: string) => ipcRenderer.invoke('app:triggerMenuAction', action),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Meta (tags, categories, and project metadata)
|
// Meta (tags, categories, and project metadata)
|
||||||
|
|||||||
@@ -510,6 +510,7 @@ export interface ElectronAPI {
|
|||||||
getDefaultProjectPath: (projectId: string) => Promise<string>;
|
getDefaultProjectPath: (projectId: string) => Promise<string>;
|
||||||
readProjectMetadata: (folderPath: string) => Promise<{ name?: string; description?: string; mainLanguage?: string } | null>;
|
readProjectMetadata: (folderPath: string) => Promise<{ name?: string; description?: string; mainLanguage?: string } | null>;
|
||||||
setPreviewPostTarget: (postId: string | null) => Promise<void>;
|
setPreviewPostTarget: (postId: string | null) => Promise<void>;
|
||||||
|
triggerMenuAction: (action: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
meta: {
|
meta: {
|
||||||
getTags: () => Promise<string[]>;
|
getTags: () => Promise<string[]>;
|
||||||
|
|||||||
120
src/main/shared/menuCommands.ts
Normal file
120
src/main/shared/menuCommands.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
export type AppMenuAction =
|
||||||
|
| 'newPost'
|
||||||
|
| 'importMedia'
|
||||||
|
| 'save'
|
||||||
|
| 'undo'
|
||||||
|
| 'redo'
|
||||||
|
| 'cut'
|
||||||
|
| 'copy'
|
||||||
|
| 'paste'
|
||||||
|
| 'delete'
|
||||||
|
| 'selectAll'
|
||||||
|
| 'find'
|
||||||
|
| 'replace'
|
||||||
|
| 'viewPosts'
|
||||||
|
| 'viewMedia'
|
||||||
|
| 'toggleSidebar'
|
||||||
|
| 'togglePanel'
|
||||||
|
| 'toggleDevTools'
|
||||||
|
| 'publishSelected'
|
||||||
|
| 'rebuildDatabase'
|
||||||
|
| 'reindexText'
|
||||||
|
| 'metadataDiff'
|
||||||
|
| 'about';
|
||||||
|
|
||||||
|
export type AppMenuRole = 'undo' | 'redo' | 'cut' | 'copy' | 'paste' | 'delete' | 'selectAll';
|
||||||
|
|
||||||
|
export interface AppMenuItemDefinition {
|
||||||
|
label: string;
|
||||||
|
action: AppMenuAction | `${string}-separator-${number}`;
|
||||||
|
accelerator?: string;
|
||||||
|
separator?: boolean;
|
||||||
|
role?: AppMenuRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppMenuGroupDefinition {
|
||||||
|
label: 'File' | 'Edit' | 'View' | 'Blog' | 'Help';
|
||||||
|
items: AppMenuItemDefinition[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const APP_MENU_GROUPS: AppMenuGroupDefinition[] = [
|
||||||
|
{
|
||||||
|
label: 'File',
|
||||||
|
items: [
|
||||||
|
{ label: 'New Post', action: 'newPost', accelerator: 'CmdOrCtrl+N' },
|
||||||
|
{ label: 'Import Media...', action: 'importMedia', accelerator: 'CmdOrCtrl+I' },
|
||||||
|
{ label: 'Save', action: 'save', accelerator: 'CmdOrCtrl+S' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Edit',
|
||||||
|
items: [
|
||||||
|
{ label: 'Undo', action: 'undo', accelerator: 'CmdOrCtrl+Z', role: 'undo' },
|
||||||
|
{ label: 'Redo', action: 'redo', accelerator: 'CmdOrCtrl+Y', role: 'redo' },
|
||||||
|
{ label: '', action: 'edit-separator-1', separator: true },
|
||||||
|
{ label: 'Cut', action: 'cut', accelerator: 'CmdOrCtrl+X', role: 'cut' },
|
||||||
|
{ label: 'Copy', action: 'copy', accelerator: 'CmdOrCtrl+C', role: 'copy' },
|
||||||
|
{ label: 'Paste', action: 'paste', accelerator: 'CmdOrCtrl+V', role: 'paste' },
|
||||||
|
{ label: 'Delete', action: 'delete', role: 'delete' },
|
||||||
|
{ label: '', action: 'edit-separator-2', separator: true },
|
||||||
|
{ label: 'Select All', action: 'selectAll', accelerator: 'CmdOrCtrl+A', role: 'selectAll' },
|
||||||
|
{ label: '', action: 'edit-separator-3', separator: true },
|
||||||
|
{ label: 'Find', action: 'find', accelerator: 'CmdOrCtrl+F' },
|
||||||
|
{ label: 'Replace', action: 'replace', accelerator: 'CmdOrCtrl+H' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'View',
|
||||||
|
items: [
|
||||||
|
{ label: 'Posts', action: 'viewPosts', accelerator: 'CmdOrCtrl+1' },
|
||||||
|
{ label: 'Media', action: 'viewMedia', accelerator: 'CmdOrCtrl+2' },
|
||||||
|
{ label: 'Toggle Sidebar', action: 'toggleSidebar', accelerator: 'CmdOrCtrl+B' },
|
||||||
|
{ label: 'Toggle Panel', action: 'togglePanel', accelerator: 'CmdOrCtrl+J' },
|
||||||
|
{ label: 'Toggle Developer Tools', action: 'toggleDevTools', accelerator: 'CmdOrCtrl+Shift+I' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Blog',
|
||||||
|
items: [
|
||||||
|
{ label: 'Publish Selected', action: 'publishSelected', accelerator: 'CmdOrCtrl+Shift+P' },
|
||||||
|
{ label: 'Rebuild Database from Files', action: 'rebuildDatabase' },
|
||||||
|
{ label: 'Reindex Search Text', action: 'reindexText' },
|
||||||
|
{ label: 'Metadata Diff Tool', action: 'metadataDiff' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Help',
|
||||||
|
items: [
|
||||||
|
{ label: 'About Blogging Desktop Server', action: 'about' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const APP_MENU_ACTION_EVENT_MAP: Partial<Record<AppMenuAction, string>> = {
|
||||||
|
newPost: 'menu:newPost',
|
||||||
|
importMedia: 'menu:importMedia',
|
||||||
|
save: 'menu:save',
|
||||||
|
find: 'menu:find',
|
||||||
|
replace: 'menu:replace',
|
||||||
|
viewPosts: 'menu:viewPosts',
|
||||||
|
viewMedia: 'menu:viewMedia',
|
||||||
|
toggleSidebar: 'menu:toggleSidebar',
|
||||||
|
togglePanel: 'menu:togglePanel',
|
||||||
|
toggleDevTools: 'menu:toggleDevTools',
|
||||||
|
publishSelected: 'menu:publishSelected',
|
||||||
|
rebuildDatabase: 'menu:rebuildDatabase',
|
||||||
|
reindexText: 'menu:reindexText',
|
||||||
|
metadataDiff: 'menu:metadataDiff',
|
||||||
|
about: 'menu:about',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const APP_MENU_WEB_CONTENTS_ACTIONS: ReadonlySet<AppMenuAction> = new Set([
|
||||||
|
'undo',
|
||||||
|
'redo',
|
||||||
|
'cut',
|
||||||
|
'copy',
|
||||||
|
'paste',
|
||||||
|
'delete',
|
||||||
|
'selectAll',
|
||||||
|
'toggleDevTools',
|
||||||
|
]);
|
||||||
@@ -472,6 +472,10 @@ export const TabBar: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (tabs.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tab-bar">
|
<div className="tab-bar">
|
||||||
{showLeftArrow && (
|
{showLeftArrow && (
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
.window-titlebar {
|
.window-titlebar {
|
||||||
|
position: relative;
|
||||||
height: 34px;
|
height: 34px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -6,8 +7,96 @@
|
|||||||
background-color: var(--vscode-editorGroupHeader-tabsBackground, #252526);
|
background-color: var(--vscode-editorGroupHeader-tabsBackground, #252526);
|
||||||
border-bottom: 1px solid var(--vscode-editorGroupHeader-tabsBorder, #1e1e1e);
|
border-bottom: 1px solid var(--vscode-editorGroupHeader-tabsBorder, #1e1e1e);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
app-region: drag;
|
||||||
-webkit-app-region: drag;
|
-webkit-app-region: drag;
|
||||||
padding-right: 10px;
|
padding-right: calc(10px + var(--bds-titlebar-overlay-right, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-titlebar-menu-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
margin-left: 6px;
|
||||||
|
gap: 2px;
|
||||||
|
app-region: no-drag;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-titlebar-menu-button {
|
||||||
|
height: 24px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--vscode-titleBar-activeForeground, var(--vscode-foreground, #cccccc));
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-titlebar-menu-button:focus,
|
||||||
|
.window-titlebar-menu-button:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-titlebar-menu-button:hover,
|
||||||
|
.window-titlebar-menu-button.is-active {
|
||||||
|
background-color: var(--vscode-toolbar-hoverBackground, rgba(90, 93, 94, 0.31));
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-titlebar-menu-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 30px;
|
||||||
|
min-width: 210px;
|
||||||
|
padding: 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
background-color: var(--vscode-menu-background, #252526);
|
||||||
|
border: 1px solid var(--vscode-menu-border, #454545);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: var(--vscode-widget-shadow, 0 8px 24px rgba(0, 0, 0, 0.4));
|
||||||
|
app-region: no-drag;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-titlebar-menu-item {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--vscode-menu-foreground, var(--vscode-foreground, #cccccc));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
text-align: left;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-titlebar-menu-item:focus,
|
||||||
|
.window-titlebar-menu-item:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: none;
|
||||||
|
background-color: var(--vscode-toolbar-hoverBackground, rgba(90, 93, 94, 0.31));
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-titlebar-menu-item:hover {
|
||||||
|
background-color: var(--vscode-menu-selectionBackground, rgba(9, 71, 113, 0.45));
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-titlebar-menu-item-accelerator {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-titlebar-menu-separator {
|
||||||
|
height: 1px;
|
||||||
|
margin: 4px 2px;
|
||||||
|
background-color: var(--vscode-menu-separatorBackground, rgba(255, 255, 255, 0.08));
|
||||||
}
|
}
|
||||||
|
|
||||||
.window-titlebar-drag-region {
|
.window-titlebar-drag-region {
|
||||||
@@ -15,6 +104,25 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.window-titlebar-title {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
max-width: 45%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--vscode-titleBar-activeForeground, var(--vscode-foreground, #cccccc));
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.window-titlebar-actions {
|
.window-titlebar-actions {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -35,6 +143,7 @@
|
|||||||
color: var(--vscode-foreground, #cccccc);
|
color: var(--vscode-foreground, #cccccc);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
app-region: no-drag;
|
||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,180 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { useAppStore } from '../../store';
|
import { useAppStore } from '../../store';
|
||||||
|
import { APP_MENU_GROUPS } from '../../../main/shared/menuCommands';
|
||||||
import './WindowTitleBar.css';
|
import './WindowTitleBar.css';
|
||||||
|
|
||||||
|
type WindowControlsOverlayLike = {
|
||||||
|
visible: boolean;
|
||||||
|
getTitlebarAreaRect: () => DOMRect;
|
||||||
|
addEventListener: (type: 'geometrychange', listener: EventListener) => void;
|
||||||
|
removeEventListener: (type: 'geometrychange', listener: EventListener) => void;
|
||||||
|
};
|
||||||
|
|
||||||
export const WindowTitleBar: React.FC = () => {
|
export const WindowTitleBar: React.FC = () => {
|
||||||
const { sidebarVisible, toggleSidebar } = useAppStore();
|
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 menuRootRef = useRef<HTMLDivElement | null>(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));
|
||||||
|
|
||||||
|
const visibleMenuGroups = APP_MENU_GROUPS.map((group) => {
|
||||||
|
if (group.label !== 'View') {
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...group,
|
||||||
|
items: group.items.filter(item => isDevMode || item.action !== 'toggleDevTools'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const rootStyle = document.documentElement.style;
|
||||||
|
const setInsets = (left: number, right: number) => {
|
||||||
|
rootStyle.setProperty('--bds-titlebar-overlay-left', `${left}px`);
|
||||||
|
rootStyle.setProperty('--bds-titlebar-overlay-right', `${right}px`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const overlay = (navigator as Navigator & { windowControlsOverlay?: WindowControlsOverlayLike }).windowControlsOverlay;
|
||||||
|
if (!overlay) {
|
||||||
|
setInsets(0, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncOverlayInsets = () => {
|
||||||
|
if (!overlay.visible) {
|
||||||
|
setInsets(0, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const titlebarRect = overlay.getTitlebarAreaRect();
|
||||||
|
const viewportWidth = window.innerWidth || document.documentElement.clientWidth || titlebarRect.right;
|
||||||
|
|
||||||
|
const leftInset = Math.max(0, Math.round(titlebarRect.left));
|
||||||
|
const rightInset = Math.max(0, Math.round(viewportWidth - titlebarRect.right));
|
||||||
|
setInsets(leftInset, rightInset);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onGeometryChange: EventListener = () => {
|
||||||
|
syncOverlayInsets();
|
||||||
|
};
|
||||||
|
const onResize = () => {
|
||||||
|
syncOverlayInsets();
|
||||||
|
};
|
||||||
|
|
||||||
|
syncOverlayInsets();
|
||||||
|
overlay.addEventListener('geometrychange', onGeometryChange);
|
||||||
|
const canListenToResize = typeof window.addEventListener === 'function';
|
||||||
|
if (canListenToResize) {
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
overlay.removeEventListener('geometrychange', onGeometryChange);
|
||||||
|
if (canListenToResize && typeof window.removeEventListener === 'function') {
|
||||||
|
window.removeEventListener('resize', onResize);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateTitle = () => {
|
||||||
|
setWindowTitle(document.title || 'Blogging Desktop Server');
|
||||||
|
};
|
||||||
|
|
||||||
|
updateTitle();
|
||||||
|
|
||||||
|
const titleElement = document.querySelector('title');
|
||||||
|
if (!titleElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
updateTitle();
|
||||||
|
});
|
||||||
|
observer.observe(titleElement, { childList: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!openMenu) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDocumentMouseDown = (event: MouseEvent) => {
|
||||||
|
const target = event.target as Node;
|
||||||
|
if (menuRootRef.current && !menuRootRef.current.contains(target)) {
|
||||||
|
setOpenMenu(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
setOpenMenu(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', onDocumentMouseDown);
|
||||||
|
document.addEventListener('keydown', onEscape);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onDocumentMouseDown);
|
||||||
|
document.removeEventListener('keydown', onEscape);
|
||||||
|
};
|
||||||
|
}, [openMenu]);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (openMenu?.label === label) {
|
||||||
|
setOpenMenu(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpenMenu({ label, left });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMenuItemClick = (action: string) => {
|
||||||
|
setOpenMenu(null);
|
||||||
|
void window.electronAPI?.app?.triggerMenuAction?.(action);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatAccelerator = (accelerator: string): string => {
|
||||||
|
const firstPass = accelerator
|
||||||
|
.replace(/CmdOrCtrl/g, isMac ? '⌘' : 'Ctrl')
|
||||||
|
.replace(/Alt/g, isMac ? '⌥' : 'Alt')
|
||||||
|
.replace(/Shift/g, isMac ? '⇧' : 'Shift');
|
||||||
|
return firstPass;
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeMenu = openMenu ? visibleMenuGroups.find(group => group.label === openMenu.label) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="window-titlebar" data-testid="window-titlebar">
|
<div className="window-titlebar" data-testid="window-titlebar" ref={menuRootRef}>
|
||||||
|
<div className="window-titlebar-menu-bar" data-testid="window-titlebar-menu-bar">
|
||||||
|
{visibleMenuGroups.map(group => (
|
||||||
|
<button
|
||||||
|
key={group.label}
|
||||||
|
className={`window-titlebar-menu-button${openMenu?.label === group.label ? ' is-active' : ''}`}
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => handleMenuButtonClick(event, group.label)}
|
||||||
|
aria-label={group.label}
|
||||||
|
>
|
||||||
|
{group.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<div className="window-titlebar-drag-region" />
|
<div className="window-titlebar-drag-region" />
|
||||||
|
<div className="window-titlebar-title" data-testid="window-titlebar-title" title={windowTitle}>
|
||||||
|
{windowTitle}
|
||||||
|
</div>
|
||||||
<div className="window-titlebar-actions">
|
<div className="window-titlebar-actions">
|
||||||
<button
|
<button
|
||||||
className="window-titlebar-action-button"
|
className="window-titlebar-action-button"
|
||||||
@@ -20,6 +187,34 @@ export const WindowTitleBar: React.FC = () => {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{openMenu && activeMenu && (
|
||||||
|
<div
|
||||||
|
className="window-titlebar-menu-dropdown"
|
||||||
|
data-testid="window-titlebar-menu-dropdown"
|
||||||
|
style={{ left: `${openMenu.left}px` }}
|
||||||
|
>
|
||||||
|
{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;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.action}
|
||||||
|
type="button"
|
||||||
|
className="window-titlebar-menu-item"
|
||||||
|
onClick={() => handleMenuItemClick(item.action)}
|
||||||
|
aria-label={acceleratorText ? `${item.label} ${acceleratorText}` : item.label}
|
||||||
|
>
|
||||||
|
<span className="window-titlebar-menu-item-label">{item.label}</span>
|
||||||
|
{acceleratorText && <span className="window-titlebar-menu-item-accelerator">{acceleratorText}</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
symbolColor: '#cccccc',
|
symbolColor: '#cccccc',
|
||||||
height: 34,
|
height: 34,
|
||||||
},
|
},
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
||||||
|
|||||||
@@ -1339,6 +1339,39 @@ describe('IPC Handlers', () => {
|
|||||||
expect(result).toBe('/Users/test/bds/project-1');
|
expect(result).toBe('/Users/test/bds/project-1');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('app:triggerMenuAction', () => {
|
||||||
|
it('should forward custom titlebar action to renderer menu channel', async () => {
|
||||||
|
const send = vi.fn();
|
||||||
|
const event = { sender: { send } };
|
||||||
|
|
||||||
|
await invokeHandlerWithEvent(event, 'app:triggerMenuAction', 'newPost');
|
||||||
|
|
||||||
|
expect(send).toHaveBeenCalledWith('menu:newPost');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should execute default edit actions on webContents sender', async () => {
|
||||||
|
const undo = vi.fn();
|
||||||
|
const send = vi.fn();
|
||||||
|
const event = { sender: { undo, send } };
|
||||||
|
|
||||||
|
await invokeHandlerWithEvent(event, 'app:triggerMenuAction', 'undo');
|
||||||
|
|
||||||
|
expect(undo).toHaveBeenCalled();
|
||||||
|
expect(send).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should execute toggleDevTools on sender when action is toggleDevTools', async () => {
|
||||||
|
const toggleDevTools = vi.fn();
|
||||||
|
const send = vi.fn();
|
||||||
|
const event = { sender: { toggleDevTools, send } };
|
||||||
|
|
||||||
|
await invokeHandlerWithEvent(event, 'app:triggerMenuAction', 'toggleDevTools');
|
||||||
|
|
||||||
|
expect(toggleDevTools).toHaveBeenCalled();
|
||||||
|
expect(send).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============ Error Handling ============
|
// ============ Error Handling ============
|
||||||
|
|||||||
@@ -78,4 +78,12 @@ describe('TabBar', () => {
|
|||||||
expect(await screen.findByText('abc123d feat: improve commit diff tabs')).toBeInTheDocument();
|
expect(await screen.findByText('abc123d feat: improve commit diff tabs')).toBeInTheDocument();
|
||||||
expect((window as any).electronAPI.git.getHistory).toHaveBeenCalledWith('/repo/path', 200);
|
expect((window as any).electronAPI.git.getHistory).toHaveBeenCalledWith('/repo/path', 200);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not render the tab bar when there are no open tabs', () => {
|
||||||
|
useAppStore.setState({ tabs: [], activeTabId: null });
|
||||||
|
|
||||||
|
const { container } = render(<TabBar />);
|
||||||
|
|
||||||
|
expect(container.querySelector('.tab-bar')).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
33
tests/renderer/components/WindowTitleBar.styles.test.ts
Normal file
33
tests/renderer/components/WindowTitleBar.styles.test.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
|
||||||
|
describe('WindowTitleBar styles', () => {
|
||||||
|
const cssPath = path.resolve(
|
||||||
|
__dirname,
|
||||||
|
'../../../src/renderer/components/WindowTitleBar/WindowTitleBar.css'
|
||||||
|
);
|
||||||
|
|
||||||
|
it('defines both standard and webkit drag regions for cross-platform support', () => {
|
||||||
|
const css = fs.readFileSync(cssPath, 'utf8');
|
||||||
|
|
||||||
|
expect(css).toMatch(/app-region:\s*drag;/);
|
||||||
|
expect(css).toMatch(/-webkit-app-region:\s*drag;/);
|
||||||
|
expect(css).toMatch(/app-region:\s*no-drag;/);
|
||||||
|
expect(css).toMatch(/-webkit-app-region:\s*no-drag;/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reserves overlay control width to keep custom actions clickable on Windows/Linux', () => {
|
||||||
|
const css = fs.readFileSync(cssPath, 'utf8');
|
||||||
|
|
||||||
|
expect(css).toMatch(/padding-right:\s*calc\(10px\s*\+\s*var\(--bds-titlebar-overlay-right,\s*0px\)\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses neutral focus styling for menu buttons without blue accent outlines', () => {
|
||||||
|
const css = fs.readFileSync(cssPath, 'utf8');
|
||||||
|
|
||||||
|
expect(css).toMatch(/\.window-titlebar-menu-button:focus/);
|
||||||
|
expect(css).toMatch(/outline:\s*none;/);
|
||||||
|
expect(css).toMatch(/box-shadow:\s*none;/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
import { fireEvent, render, screen } from '@testing-library/react';
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
import { WindowTitleBar } from '../../../src/renderer/components/WindowTitleBar/WindowTitleBar';
|
import { WindowTitleBar } from '../../../src/renderer/components/WindowTitleBar/WindowTitleBar';
|
||||||
import { useAppStore } from '../../../src/renderer/store';
|
import { useAppStore } from '../../../src/renderer/store';
|
||||||
@@ -36,4 +36,129 @@ describe('WindowTitleBar', () => {
|
|||||||
expect(iconFrame).toHaveAttribute('data-shape', 'frame-square');
|
expect(iconFrame).toHaveAttribute('data-shape', 'frame-square');
|
||||||
expect(iconPane).toHaveAttribute('data-shape', 'left-half');
|
expect(iconPane).toHaveAttribute('data-shape', 'left-half');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('updates overlay inset CSS variables when window controls geometry changes', () => {
|
||||||
|
const geometryListeners = new Set<EventListener>();
|
||||||
|
let rect = {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 924,
|
||||||
|
height: 34,
|
||||||
|
top: 0,
|
||||||
|
right: 924,
|
||||||
|
bottom: 34,
|
||||||
|
left: 0,
|
||||||
|
toJSON: () => '',
|
||||||
|
} as DOMRect;
|
||||||
|
|
||||||
|
const mockOverlay = {
|
||||||
|
visible: true,
|
||||||
|
getTitlebarAreaRect: () => rect,
|
||||||
|
addEventListener: (type: string, listener: EventListenerOrEventListenerObject) => {
|
||||||
|
if (type === 'geometrychange' && typeof listener === 'function') {
|
||||||
|
geometryListeners.add(listener);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
removeEventListener: (type: string, listener: EventListenerOrEventListenerObject) => {
|
||||||
|
if (type === 'geometrychange' && typeof listener === 'function') {
|
||||||
|
geometryListeners.delete(listener);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'innerWidth', { value: 1024, configurable: true });
|
||||||
|
(navigator as Navigator & { windowControlsOverlay?: typeof mockOverlay }).windowControlsOverlay = mockOverlay;
|
||||||
|
|
||||||
|
render(<WindowTitleBar />);
|
||||||
|
|
||||||
|
expect(document.documentElement.style.getPropertyValue('--bds-titlebar-overlay-right')).toBe('100px');
|
||||||
|
expect(document.documentElement.style.getPropertyValue('--bds-titlebar-overlay-left')).toBe('0px');
|
||||||
|
|
||||||
|
rect = {
|
||||||
|
...rect,
|
||||||
|
width: 824,
|
||||||
|
right: 824,
|
||||||
|
} as DOMRect;
|
||||||
|
|
||||||
|
geometryListeners.forEach(listener => listener(new Event('geometrychange')));
|
||||||
|
|
||||||
|
expect(document.documentElement.style.getPropertyValue('--bds-titlebar-overlay-right')).toBe('200px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the window title centered in the custom title bar', () => {
|
||||||
|
document.title = 'Blogging Desktop Server';
|
||||||
|
|
||||||
|
render(<WindowTitleBar />);
|
||||||
|
|
||||||
|
const title = screen.getByTestId('window-titlebar-title');
|
||||||
|
expect(title).toBeInTheDocument();
|
||||||
|
expect(title).toHaveTextContent('Blogging Desktop Server');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders VS Code-style top menu labels in the title bar', () => {
|
||||||
|
render(<WindowTitleBar />);
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: 'File' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'View' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Blog' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Help' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches menu action through electron API when menu item is clicked', () => {
|
||||||
|
const triggerMenuAction = vi.fn().mockResolvedValue(undefined);
|
||||||
|
window.electronAPI.app = {
|
||||||
|
...(window.electronAPI.app || {}),
|
||||||
|
triggerMenuAction,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<WindowTitleBar />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'File' }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'New Post Ctrl+N' }));
|
||||||
|
|
||||||
|
expect(triggerMenuAction).toHaveBeenCalledWith('newPost');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows default edit actions with accelerators in Edit menu', () => {
|
||||||
|
render(<WindowTitleBar />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: 'Undo Ctrl+Z' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Redo Ctrl+Y' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Cut Ctrl+X' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Copy Ctrl+C' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Paste Ctrl+V' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Select All Ctrl+A' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows assigned accelerators in View and Blog menus', () => {
|
||||||
|
render(<WindowTitleBar />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'View' }));
|
||||||
|
expect(screen.getByRole('button', { name: 'Posts Ctrl+1' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Media Ctrl+2' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Toggle Sidebar Ctrl+B' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Toggle Panel Ctrl+J' })).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Blog' }));
|
||||||
|
expect(screen.getByRole('button', { name: 'Publish Selected Ctrl+Shift+P' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows Toggle Developer Tools in View menu in development mode', () => {
|
||||||
|
(window as Window & { __BDS_IS_DEV__?: boolean }).__BDS_IS_DEV__ = true;
|
||||||
|
const triggerMenuAction = vi.fn().mockResolvedValue(undefined);
|
||||||
|
window.electronAPI.app = {
|
||||||
|
...(window.electronAPI.app || {}),
|
||||||
|
triggerMenuAction,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<WindowTitleBar />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'View' }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Toggle Developer Tools Ctrl+Shift+I' }));
|
||||||
|
|
||||||
|
expect(triggerMenuAction).toHaveBeenCalledWith('toggleDevTools');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -124,6 +124,9 @@ Object.defineProperty(globalThis, 'window', {
|
|||||||
cancel: vi.fn(),
|
cancel: vi.fn(),
|
||||||
clearCompleted: vi.fn(),
|
clearCompleted: vi.fn(),
|
||||||
},
|
},
|
||||||
|
app: {
|
||||||
|
triggerMenuAction: vi.fn(),
|
||||||
|
},
|
||||||
import: {
|
import: {
|
||||||
selectAndAnalyze: vi.fn(),
|
selectAndAnalyze: vi.fn(),
|
||||||
analyzeFile: vi.fn(),
|
analyzeFile: vi.fn(),
|
||||||
|
|||||||
Reference in New Issue
Block a user