fix: updated UI to macosx conventions
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { app, ipcMain, dialog, shell } from 'electron';
|
import { app, BrowserWindow, 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';
|
||||||
@@ -697,6 +697,20 @@ export function registerIpcHandlers(): void {
|
|||||||
return projectEngine.getDefaultProjectBaseDir(projectId);
|
return projectEngine.getDefaultProjectBaseDir(projectId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
safeHandle('app:getTitleBarMetrics', async (event) => {
|
||||||
|
const ownerWindow = BrowserWindow.fromWebContents(event.sender);
|
||||||
|
const buttonPosition = ownerWindow?.getWindowButtonPosition?.();
|
||||||
|
if (!buttonPosition) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const estimatedClusterWidth = Math.max(52, Math.round(buttonPosition.y * 4));
|
||||||
|
const trailingPadding = Math.max(8, Math.round(buttonPosition.y * 0.6));
|
||||||
|
const macosLeftInset = Math.max(0, Math.round(buttonPosition.x + estimatedClusterWidth + trailingPadding));
|
||||||
|
|
||||||
|
return { macosLeftInset };
|
||||||
|
});
|
||||||
|
|
||||||
safeHandle('app:showItemInFolder', async (_, itemPath: string) => {
|
safeHandle('app:showItemInFolder', async (_, itemPath: string) => {
|
||||||
return shell.showItemInFolder(itemPath);
|
return shell.showItemInFolder(itemPath);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ export const electronAPI: ElectronAPI = {
|
|||||||
// App
|
// App
|
||||||
app: {
|
app: {
|
||||||
getDataPaths: () => ipcRenderer.invoke('app:getDataPaths'),
|
getDataPaths: () => ipcRenderer.invoke('app:getDataPaths'),
|
||||||
|
getTitleBarMetrics: () => ipcRenderer.invoke('app:getTitleBarMetrics'),
|
||||||
openFolder: (folderPath: string) => ipcRenderer.invoke('app:openFolder', folderPath),
|
openFolder: (folderPath: string) => ipcRenderer.invoke('app:openFolder', folderPath),
|
||||||
showItemInFolder: (itemPath: string) => ipcRenderer.invoke('app:showItemInFolder', itemPath),
|
showItemInFolder: (itemPath: string) => ipcRenderer.invoke('app:showItemInFolder', itemPath),
|
||||||
selectFolder: (title?: string) => ipcRenderer.invoke('app:selectFolder', title),
|
selectFolder: (title?: string) => ipcRenderer.invoke('app:selectFolder', title),
|
||||||
|
|||||||
@@ -505,6 +505,7 @@ export interface ElectronAPI {
|
|||||||
};
|
};
|
||||||
app: {
|
app: {
|
||||||
getDataPaths: () => Promise<{ database: string; posts: string; media: string }>;
|
getDataPaths: () => Promise<{ database: string; posts: string; media: string }>;
|
||||||
|
getTitleBarMetrics: () => Promise<{ macosLeftInset: number } | null>;
|
||||||
openFolder: (folderPath: string) => Promise<string>;
|
openFolder: (folderPath: string) => Promise<string>;
|
||||||
showItemInFolder: (itemPath: string) => Promise<void>;
|
showItemInFolder: (itemPath: string) => Promise<void>;
|
||||||
selectFolder: (title?: string) => Promise<string | null>;
|
selectFolder: (title?: string) => Promise<string | null>;
|
||||||
|
|||||||
@@ -23,6 +23,10 @@
|
|||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.window-titlebar.is-mac .window-titlebar-menu-bar {
|
||||||
|
margin-left: max(var(--bds-titlebar-macos-left-inset, 78px), calc(6px + var(--bds-titlebar-overlay-left, 0px)));
|
||||||
|
}
|
||||||
|
|
||||||
.window-titlebar-menu-button {
|
.window-titlebar-menu-button {
|
||||||
height: 24px;
|
height: 24px;
|
||||||
border: none;
|
border: none;
|
||||||
|
|||||||
@@ -133,6 +133,83 @@ export const WindowTitleBar: React.FC = () => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isMac) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootStyle = document.documentElement.style;
|
||||||
|
let isDisposed = false;
|
||||||
|
let resolutionQuery: MediaQueryList | null = null;
|
||||||
|
|
||||||
|
const syncMacInset = async () => {
|
||||||
|
const metrics = await window.electronAPI?.app?.getTitleBarMetrics?.();
|
||||||
|
if (isDisposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metrics && Number.isFinite(metrics.macosLeftInset)) {
|
||||||
|
rootStyle.setProperty('--bds-titlebar-macos-left-inset', `${Math.max(0, Math.round(metrics.macosLeftInset))}px`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const bindResolutionListener = () => {
|
||||||
|
if (typeof window.matchMedia !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolutionQuery && typeof resolutionQuery.removeEventListener === 'function') {
|
||||||
|
resolutionQuery.removeEventListener('change', onResolutionChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolutionQuery = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
|
||||||
|
if (typeof resolutionQuery.addEventListener === 'function') {
|
||||||
|
resolutionQuery.addEventListener('change', onResolutionChange);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onResolutionChange = () => {
|
||||||
|
void syncMacInset();
|
||||||
|
bindResolutionListener();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onResize = () => {
|
||||||
|
void syncMacInset();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onVisibilityChange = () => {
|
||||||
|
if (!document.hidden) {
|
||||||
|
void syncMacInset();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const canListenToWindowEvents = typeof window.addEventListener === 'function';
|
||||||
|
const canListenToDocumentEvents = typeof document.addEventListener === 'function';
|
||||||
|
|
||||||
|
void syncMacInset();
|
||||||
|
bindResolutionListener();
|
||||||
|
if (canListenToWindowEvents) {
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
}
|
||||||
|
if (canListenToDocumentEvents) {
|
||||||
|
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isDisposed = true;
|
||||||
|
if (canListenToWindowEvents && typeof window.removeEventListener === 'function') {
|
||||||
|
window.removeEventListener('resize', onResize);
|
||||||
|
}
|
||||||
|
if (canListenToDocumentEvents && typeof document.removeEventListener === 'function') {
|
||||||
|
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolutionQuery && typeof resolutionQuery.removeEventListener === 'function') {
|
||||||
|
resolutionQuery.removeEventListener('change', onResolutionChange);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isMac]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateTitle = () => {
|
const updateTitle = () => {
|
||||||
setWindowTitle(document.title || 'Blogging Desktop Server');
|
setWindowTitle(document.title || 'Blogging Desktop Server');
|
||||||
@@ -383,7 +460,7 @@ export const WindowTitleBar: React.FC = () => {
|
|||||||
const activeMenu = openMenu ? visibleMenuGroups.find(group => group.label === openMenu.label) : null;
|
const activeMenu = openMenu ? visibleMenuGroups.find(group => group.label === openMenu.label) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="window-titlebar" data-testid="window-titlebar" ref={menuRootRef}>
|
<div className={`window-titlebar${isMac ? ' is-mac' : ''}`} data-testid="window-titlebar" ref={menuRootRef}>
|
||||||
<div className="window-titlebar-menu-bar" data-testid="window-titlebar-menu-bar">
|
<div className="window-titlebar-menu-bar" data-testid="window-titlebar-menu-bar">
|
||||||
{visibleMenuGroups.map(group => (
|
{visibleMenuGroups.map(group => (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ vi.mock('electron', () => ({
|
|||||||
app: {
|
app: {
|
||||||
quit: vi.fn(),
|
quit: vi.fn(),
|
||||||
},
|
},
|
||||||
|
BrowserWindow: {
|
||||||
|
fromWebContents: 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);
|
||||||
@@ -1344,6 +1347,25 @@ describe('IPC Handlers', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('app:getTitleBarMetrics', () => {
|
||||||
|
it('should return dynamic macOS title bar left inset from native window button position', async () => {
|
||||||
|
const { BrowserWindow } = await import('electron');
|
||||||
|
const sender = {};
|
||||||
|
const event = { sender };
|
||||||
|
|
||||||
|
vi.mocked(BrowserWindow.fromWebContents).mockReturnValue({
|
||||||
|
getWindowButtonPosition: vi.fn(() => ({ x: 14, y: 14 })),
|
||||||
|
} as unknown as ReturnType<typeof BrowserWindow.fromWebContents>);
|
||||||
|
|
||||||
|
const result = await invokeHandlerWithEvent(event, 'app:getTitleBarMetrics');
|
||||||
|
|
||||||
|
expect(BrowserWindow.fromWebContents).toHaveBeenCalledWith(sender);
|
||||||
|
expect(result).toEqual({
|
||||||
|
macosLeftInset: 78,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('app:triggerMenuAction', () => {
|
describe('app:triggerMenuAction', () => {
|
||||||
it('should forward custom titlebar action to renderer menu channel', async () => {
|
it('should forward custom titlebar action to renderer menu channel', async () => {
|
||||||
const send = vi.fn();
|
const send = vi.fn();
|
||||||
|
|||||||
@@ -5,13 +5,53 @@ import { WindowTitleBar } from '../../../src/renderer/components/WindowTitleBar/
|
|||||||
import { useAppStore } from '../../../src/renderer/store';
|
import { useAppStore } from '../../../src/renderer/store';
|
||||||
|
|
||||||
describe('WindowTitleBar', () => {
|
describe('WindowTitleBar', () => {
|
||||||
|
const originalNavigatorPlatform = navigator.platform;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
Object.defineProperty(navigator, 'platform', {
|
||||||
|
value: originalNavigatorPlatform,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
useAppStore.setState({
|
useAppStore.setState({
|
||||||
sidebarVisible: true,
|
sidebarVisible: true,
|
||||||
panelVisible: false,
|
panelVisible: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('applies a macOS class to the title bar root for platform-specific spacing', () => {
|
||||||
|
Object.defineProperty(navigator, 'platform', {
|
||||||
|
value: 'MacIntel',
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<WindowTitleBar />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('window-titlebar')).toHaveClass('is-mac');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets macOS title bar inset CSS variable from dynamic native metrics', async () => {
|
||||||
|
Object.defineProperty(navigator, 'platform', {
|
||||||
|
value: 'MacIntel',
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getTitleBarMetrics = vi.fn().mockResolvedValue({ macosLeftInset: 102 });
|
||||||
|
window.electronAPI.app = {
|
||||||
|
...(window.electronAPI.app || {}),
|
||||||
|
getTitleBarMetrics,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<WindowTitleBar />);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getTitleBarMetrics).toHaveBeenCalled();
|
||||||
|
expect(document.documentElement.style.getPropertyValue('--bds-titlebar-macos-left-inset')).toBe('102px');
|
||||||
|
});
|
||||||
|
|
||||||
it('renders a right-side sidebar toggle button and toggles store state', () => {
|
it('renders a right-side sidebar toggle button and toggles store state', () => {
|
||||||
render(<WindowTitleBar />);
|
render(<WindowTitleBar />);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user