diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 7078b8c..89a582d 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -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 fsPromises from 'fs/promises'; import { eq } from 'drizzle-orm'; @@ -697,6 +697,20 @@ export function registerIpcHandlers(): void { 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) => { return shell.showItemInFolder(itemPath); }); diff --git a/src/main/preload.ts b/src/main/preload.ts index fb2d8a6..f684544 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -137,6 +137,7 @@ export const electronAPI: ElectronAPI = { // App app: { getDataPaths: () => ipcRenderer.invoke('app:getDataPaths'), + getTitleBarMetrics: () => ipcRenderer.invoke('app:getTitleBarMetrics'), openFolder: (folderPath: string) => ipcRenderer.invoke('app:openFolder', folderPath), showItemInFolder: (itemPath: string) => ipcRenderer.invoke('app:showItemInFolder', itemPath), selectFolder: (title?: string) => ipcRenderer.invoke('app:selectFolder', title), diff --git a/src/main/shared/electronApi.ts b/src/main/shared/electronApi.ts index 4637263..4e0b82f 100644 --- a/src/main/shared/electronApi.ts +++ b/src/main/shared/electronApi.ts @@ -505,6 +505,7 @@ export interface ElectronAPI { }; app: { getDataPaths: () => Promise<{ database: string; posts: string; media: string }>; + getTitleBarMetrics: () => Promise<{ macosLeftInset: number } | null>; openFolder: (folderPath: string) => Promise; showItemInFolder: (itemPath: string) => Promise; selectFolder: (title?: string) => Promise; diff --git a/src/renderer/components/WindowTitleBar/WindowTitleBar.css b/src/renderer/components/WindowTitleBar/WindowTitleBar.css index fd49ab8..3afa2bc 100644 --- a/src/renderer/components/WindowTitleBar/WindowTitleBar.css +++ b/src/renderer/components/WindowTitleBar/WindowTitleBar.css @@ -23,6 +23,10 @@ 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 { height: 24px; border: none; diff --git a/src/renderer/components/WindowTitleBar/WindowTitleBar.tsx b/src/renderer/components/WindowTitleBar/WindowTitleBar.tsx index 654a832..24935c8 100644 --- a/src/renderer/components/WindowTitleBar/WindowTitleBar.tsx +++ b/src/renderer/components/WindowTitleBar/WindowTitleBar.tsx @@ -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(() => { const updateTitle = () => { 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; return ( -
+
{visibleMenuGroups.map(group => (