mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
🐛 fix(desktop): adjust mac fullscreen titlebar spacing (#15693)
This commit is contained in:
@@ -91,6 +91,13 @@ export default class BrowserWindowsCtr extends ControllerModule {
|
||||
});
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
isWindowFullScreen() {
|
||||
return this.withSenderIdentifier((identifier) => {
|
||||
return this.app.browserManager.isWindowFullScreen(identifier);
|
||||
});
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
setWindowAlwaysOnTop(flag: boolean) {
|
||||
this.withSenderIdentifier((identifier) => {
|
||||
|
||||
@@ -29,6 +29,7 @@ const mockCloseWindow = vi.fn();
|
||||
const mockMinimizeWindow = vi.fn();
|
||||
const mockMaximizeWindow = vi.fn();
|
||||
const mockIsWindowMaximized = vi.fn();
|
||||
const mockIsWindowFullScreen = vi.fn();
|
||||
const mockRetrieveByIdentifier = vi.fn();
|
||||
const mockStartSession = vi.fn();
|
||||
const testSenderIdentifierString: string = 'test-window-event-id';
|
||||
@@ -58,6 +59,7 @@ const mockApp = {
|
||||
minimizeWindow: mockMinimizeWindow,
|
||||
maximizeWindow: mockMaximizeWindow,
|
||||
isWindowMaximized: mockIsWindowMaximized,
|
||||
isWindowFullScreen: mockIsWindowFullScreen,
|
||||
retrieveByIdentifier: mockRetrieveByIdentifier.mockImplementation(
|
||||
(identifier: AppBrowsersIdentifiers | string) => {
|
||||
if (identifier === 'some-other-window') {
|
||||
@@ -166,6 +168,20 @@ describe('BrowserWindowsCtr', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('isWindowFullScreen', () => {
|
||||
it('should return fullscreen state for the sender window', () => {
|
||||
mockIsWindowFullScreen.mockReturnValueOnce(true);
|
||||
|
||||
const sender = {} as any;
|
||||
const context = { sender, event: { sender } as any } as IpcContext;
|
||||
const result = runWithIpcContext(context, () => browserWindowsCtr.isWindowFullScreen());
|
||||
|
||||
expect(mockGetIdentifierByWebContents).toHaveBeenCalledWith(context.sender);
|
||||
expect(mockIsWindowFullScreen).toHaveBeenCalledWith(testSenderIdentifierString);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('interceptRoute', () => {
|
||||
const baseParams = { source: 'link-click' as const };
|
||||
|
||||
|
||||
@@ -219,6 +219,7 @@ export default class Browser {
|
||||
this.setupReadyToShowListener(browserWindow);
|
||||
this.setupCloseListener(browserWindow);
|
||||
this.setupFocusListener(browserWindow);
|
||||
this.setupFullscreenListener(browserWindow);
|
||||
this.setupTopLevelNavigationListener(browserWindow);
|
||||
this.setupWillPreventUnloadListener(browserWindow);
|
||||
this.setupContextMenu(browserWindow);
|
||||
@@ -309,6 +310,18 @@ export default class Browser {
|
||||
});
|
||||
}
|
||||
|
||||
private setupFullscreenListener(browserWindow: BrowserWindow): void {
|
||||
logger.debug(`[${this.identifier}] Setting up fullscreen event listeners.`);
|
||||
|
||||
browserWindow.on('enter-full-screen', () => {
|
||||
this.broadcast('windowFullscreenChanged', { isFullScreen: true });
|
||||
});
|
||||
|
||||
browserWindow.on('leave-full-screen', () => {
|
||||
this.broadcast('windowFullscreenChanged', { isFullScreen: false });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup context menu with platform-specific features
|
||||
* Delegates to MenuManager for consistent platform behavior
|
||||
|
||||
@@ -368,6 +368,11 @@ export class BrowserManager {
|
||||
return browser?.browserWindow.isMaximized() ?? false;
|
||||
}
|
||||
|
||||
isWindowFullScreen(identifier: string) {
|
||||
const browser = this.browsers.get(identifier);
|
||||
return browser?.browserWindow.isFullScreen() ?? false;
|
||||
}
|
||||
|
||||
setWindowSize(identifier: string, size: { height?: number; width?: number }) {
|
||||
const browser = this.browsers.get(identifier);
|
||||
browser?.setWindowSize(size);
|
||||
|
||||
@@ -541,6 +541,30 @@ describe('Browser', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('fullscreen events', () => {
|
||||
it('should broadcast fullscreen state changes', () => {
|
||||
const enterHandler = mockBrowserWindow.on.mock.calls.find(
|
||||
(call) => call[0] === 'enter-full-screen',
|
||||
)?.[1];
|
||||
const leaveHandler = mockBrowserWindow.on.mock.calls.find(
|
||||
(call) => call[0] === 'leave-full-screen',
|
||||
)?.[1];
|
||||
|
||||
expect(enterHandler).toBeDefined();
|
||||
expect(leaveHandler).toBeDefined();
|
||||
|
||||
enterHandler();
|
||||
expect(mockBrowserWindow.webContents.send).toHaveBeenCalledWith('windowFullscreenChanged', {
|
||||
isFullScreen: true,
|
||||
});
|
||||
|
||||
leaveHandler();
|
||||
expect(mockBrowserWindow.webContents.send).toHaveBeenCalledWith('windowFullscreenChanged', {
|
||||
isFullScreen: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('close', () => {
|
||||
it('should close window', () => {
|
||||
browser.close();
|
||||
|
||||
@@ -527,12 +527,7 @@ export class FlatListBuilder {
|
||||
if (!nextContinuation) return;
|
||||
|
||||
if (this.shouldDrainParentContinuations(nextContinuation.parentId, processedIds)) {
|
||||
this.buildFlatListRecursive(
|
||||
nextContinuation.parentId,
|
||||
flatList,
|
||||
processedIds,
|
||||
allMessages,
|
||||
);
|
||||
this.buildFlatListRecursive(nextContinuation.parentId, flatList, processedIds, allMessages);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -586,7 +581,9 @@ export class FlatListBuilder {
|
||||
|
||||
if (this.isAgentCouncilMode(parentMessage)) return true;
|
||||
|
||||
const taskChildren = children.filter((childId) => this.messageMap.get(childId)?.role === 'task');
|
||||
const taskChildren = children.filter(
|
||||
(childId) => this.messageMap.get(childId)?.role === 'task',
|
||||
);
|
||||
return taskChildren.length > 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"lexical": "^0.42.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@lobehub/editor": "^4",
|
||||
"@lobehub/editor": "^4.17.1",
|
||||
"debug": "*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,4 +5,5 @@ export interface SystemBroadcastEvents {
|
||||
systemThemeChanged: (data: { themeMode: ThemeAppearance }) => void;
|
||||
themeChanged: (data: { themeMode: ThemeMode }) => void;
|
||||
windowFocused: () => void;
|
||||
windowFullscreenChanged: (data: { isFullScreen: boolean }) => void;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useWatchBroadcast } from '@lobechat/electron-client-ipc';
|
||||
import { ActionIcon, Flexbox, Popover, Tooltip } from '@lobehub/ui';
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
import { ArrowLeft, ArrowRight, Clock } from 'lucide-react';
|
||||
@@ -7,6 +8,7 @@ import { memo, useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ToggleLeftPanelButton from '@/features/NavPanel/ToggleLeftPanelButton';
|
||||
import { electronSystemService } from '@/services/electron/system';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
import type { GlobalState } from '@/store/global/initialState';
|
||||
import { systemStatusSelectors } from '@/store/global/selectors';
|
||||
@@ -14,17 +16,11 @@ import { electronStylish } from '@/styles/electron';
|
||||
import { isMacOS } from '@/utils/platform';
|
||||
|
||||
import { useNavigationHistory } from '../navigation/useNavigationHistory';
|
||||
import { TITLE_BAR_HORIZONTAL_PADDING } from './layout';
|
||||
import { getMacTrafficLightPadding } from './layout';
|
||||
import RecentlyViewed from './RecentlyViewed';
|
||||
|
||||
const isMac = isMacOS();
|
||||
|
||||
// Reserve space for macOS traffic lights so the toggle sits to their right.
|
||||
// Matches the popup TitleBar's MAC_TRAFFIC_LIGHT_WIDTH (80) minus the titlebar's
|
||||
// own horizontal padding, which already offsets the left edge.
|
||||
const MAC_TRAFFIC_LIGHT_WIDTH = 80;
|
||||
const macTrafficLightPadding = MAC_TRAFFIC_LIGHT_WIDTH - TITLE_BAR_HORIZONTAL_PADDING;
|
||||
|
||||
// A persistent titlebar toggle must not share the sidebar toggle's id, or it
|
||||
// would create a duplicate DOM id and get caught by NavPanelDraggable's hover CSS.
|
||||
const NAV_TOGGLE_ID = 'titlebar_toggle_left_panel_button';
|
||||
@@ -52,9 +48,35 @@ const NavigationBar = memo(() => {
|
||||
const { t } = useTranslation('electron');
|
||||
const { canGoBack, canGoForward, goBack, goForward } = useNavigationHistory();
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [isWindowFullScreen, setIsWindowFullScreen] = useState(false);
|
||||
|
||||
const leftPanelWidth = useNavPanelWidth();
|
||||
|
||||
useWatchBroadcast('windowFullscreenChanged', ({ isFullScreen }) => {
|
||||
if (isMac) setIsWindowFullScreen(isFullScreen);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMac) return;
|
||||
|
||||
let disposed = false;
|
||||
|
||||
const syncFullScreenState = async () => {
|
||||
try {
|
||||
const isFullScreen = await electronSystemService.isWindowFullScreen();
|
||||
if (!disposed) setIsWindowFullScreen(isFullScreen);
|
||||
} catch {
|
||||
if (!disposed) setIsWindowFullScreen(false);
|
||||
}
|
||||
};
|
||||
|
||||
void syncFullScreenState();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Toggle history popover
|
||||
const toggleHistoryOpen = useCallback(() => {
|
||||
setHistoryOpen((prev) => !prev);
|
||||
@@ -80,6 +102,7 @@ const NavigationBar = memo(() => {
|
||||
const tooltipContent = t('navigation.recentView');
|
||||
|
||||
const isLeftPanelVisible = leftPanelWidth > 0;
|
||||
const macTrafficLightPadding = getMacTrafficLightPadding(isMac, isWindowFullScreen);
|
||||
|
||||
return (
|
||||
<Flexbox
|
||||
@@ -89,7 +112,7 @@ const NavigationBar = memo(() => {
|
||||
gap={8}
|
||||
justify={isMac ? 'space-between' : 'end'}
|
||||
style={{
|
||||
paddingLeft: isMac ? macTrafficLightPadding : 0,
|
||||
paddingLeft: macTrafficLightPadding,
|
||||
paddingRight: 8,
|
||||
// Expanded: span the sidebar width so the right group hugs its right edge.
|
||||
// Collapsed (macOS): shrink to content so the controls cluster at the left edge.
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getTitleBarLayoutConfig, TITLE_BAR_HORIZONTAL_PADDING } from './layout';
|
||||
import {
|
||||
getMacTrafficLightPadding,
|
||||
getTitleBarLayoutConfig,
|
||||
MAC_TRAFFIC_LIGHT_WIDTH,
|
||||
TITLE_BAR_HORIZONTAL_PADDING,
|
||||
} from './layout';
|
||||
|
||||
describe('getTitleBarLayoutConfig', () => {
|
||||
it('reserves right-side space for native Windows controls', () => {
|
||||
@@ -27,3 +32,19 @@ describe('getTitleBarLayoutConfig', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMacTrafficLightPadding', () => {
|
||||
it('reserves traffic-light space for regular macOS windows', () => {
|
||||
expect(getMacTrafficLightPadding(true, false)).toBe(
|
||||
MAC_TRAFFIC_LIGHT_WIDTH - TITLE_BAR_HORIZONTAL_PADDING,
|
||||
);
|
||||
});
|
||||
|
||||
it('removes traffic-light space for macOS fullscreen windows', () => {
|
||||
expect(getMacTrafficLightPadding(true, true)).toBe(0);
|
||||
});
|
||||
|
||||
it('does not reserve traffic-light space outside macOS', () => {
|
||||
expect(getMacTrafficLightPadding(false, false)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
export const TITLE_BAR_HORIZONTAL_PADDING = 12;
|
||||
export const WINDOWS_NATIVE_CONTROL_WIDTH = 150;
|
||||
export const MAC_TRAFFIC_LIGHT_WIDTH = 80;
|
||||
|
||||
export const getMacTrafficLightPadding = (isMac: boolean, isFullScreen: boolean): number => {
|
||||
if (!isMac || isFullScreen) return 0;
|
||||
|
||||
return MAC_TRAFFIC_LIGHT_WIDTH - TITLE_BAR_HORIZONTAL_PADDING;
|
||||
};
|
||||
|
||||
export interface TitleBarLayoutConfig {
|
||||
padding: string;
|
||||
|
||||
@@ -36,6 +36,10 @@ class ElectronSystemService {
|
||||
return this.ipc.windows.isWindowMaximized();
|
||||
}
|
||||
|
||||
async isWindowFullScreen(): Promise<boolean> {
|
||||
return this.ipc.windows.isWindowFullScreen();
|
||||
}
|
||||
|
||||
async minimizeWindow(): Promise<void> {
|
||||
return this.ipc.windows.minimizeWindow();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user