diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index eee8167e3f..2daa380e2e 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -13,6 +13,8 @@ export enum IpcChannel { App_RestartTray = 'app:restart-tray', App_SetTheme = 'app:set-theme', App_SetAutoUpdate = 'app:set-auto-update', + App_SetZoomFactor = 'app:set-zoom-factor', + ZoomFactorUpdated = 'app:zoom-factor-updated', App_IsBinaryExist = 'app:is-binary-exist', App_GetBinaryPath = 'app:get-binary-path', diff --git a/packages/shared/config/constant.ts b/packages/shared/config/constant.ts index df8160113c..911a3cf909 100644 --- a/packages/shared/config/constant.ts +++ b/packages/shared/config/constant.ts @@ -134,6 +134,14 @@ export const textExts = [ '.tf' // Technology File ] +export const ZOOM_LEVELS = [0.25, 0.33, 0.5, 0.67, 0.75, 0.8, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2, 2.5, 3, 4, 5] + +// 从 ZOOM_LEVELS 生成 Ant Design Select 所需的 options 结构 +export const ZOOM_OPTIONS = ZOOM_LEVELS.map((level) => ({ + value: level, + label: `${Math.round(level * 100)}%` +})) + export const ZOOM_SHORTCUTS = [ { key: 'zoom_in', diff --git a/src/main/ipc.ts b/src/main/ipc.ts index f221b6126c..b399abea30 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -141,6 +141,17 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { notifyThemeChange() }) + // zoom factor + ipcMain.handle(IpcChannel.App_SetZoomFactor, (_, factor: number) => { + configManager.setZoomFactor(factor) + const windows = BrowserWindow.getAllWindows() + windows.forEach((win) => { + if (!win.isDestroyed()) { + win.webContents.setZoomFactor(factor) + } + }) + }) + // clear cache ipcMain.handle(IpcChannel.App_ClearCache, async () => { const sessions = [session.defaultSession, session.fromPartition('persist:webview')] diff --git a/src/main/services/ShortcutService.ts b/src/main/services/ShortcutService.ts index 03caa02d24..c384f57fa9 100644 --- a/src/main/services/ShortcutService.ts +++ b/src/main/services/ShortcutService.ts @@ -1,3 +1,5 @@ +import { ZOOM_LEVELS } from '@shared/config/constant' +import { IpcChannel } from '@shared/IpcChannel' import { Shortcut } from '@types' import { BrowserWindow, globalShortcut } from 'electron' import Logger from 'electron-log' @@ -14,9 +16,9 @@ const windowOnHandlers = new Map void; on function getShortcutHandler(shortcut: Shortcut) { switch (shortcut.key) { case 'zoom_in': - return (window: BrowserWindow) => handleZoom(0.1)(window) + return (window: BrowserWindow) => handleZoom(1)(window) case 'zoom_out': - return (window: BrowserWindow) => handleZoom(-0.1)(window) + return (window: BrowserWindow) => handleZoom(-1)(window) case 'zoom_reset': return (window: BrowserWindow) => { window.webContents.setZoomFactor(1) @@ -42,10 +44,39 @@ function formatShortcutKey(shortcut: string[]): string { function handleZoom(delta: number) { return (window: BrowserWindow) => { const currentZoom = configManager.getZoomFactor() - const newZoom = Number((currentZoom + delta).toFixed(1)) - if (newZoom >= 0.1 && newZoom <= 5.0) { - window.webContents.setZoomFactor(newZoom) + let currentIndex = ZOOM_LEVELS.indexOf(currentZoom) + + // 如果当前缩放比例不在预设列表中,找到最接近的 + if (currentIndex === -1) { + let closestIndex = 0 + let minDiff = Math.abs(ZOOM_LEVELS[0] - currentZoom) + for (let i = 1; i < ZOOM_LEVELS.length; i++) { + const diff = Math.abs(ZOOM_LEVELS[i] - currentZoom) + if (diff < minDiff) { + minDiff = diff + closestIndex = i + } + } + currentIndex = closestIndex + } + + let nextIndex = currentIndex + delta + + // 边界检查 + if (nextIndex < 0) { + nextIndex = 0 // 已经是最小值 + } else if (nextIndex >= ZOOM_LEVELS.length) { + nextIndex = ZOOM_LEVELS.length - 1 // 已经是最大值 + } + + const newZoom = ZOOM_LEVELS[nextIndex] + + if (newZoom !== currentZoom) { + // 只有在实际改变时才更新 configManager.setZoomFactor(newZoom) + // 通知所有渲染进程更新 zoomFactor + window.webContents.setZoomFactor(newZoom) + window.webContents.send(IpcChannel.ZoomFactorUpdated, newZoom) } } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 3f040fa90d..ce33f67166 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -19,6 +19,7 @@ const api = { setTrayOnClose: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTrayOnClose, isActive), restartTray: () => ipcRenderer.invoke(IpcChannel.App_RestartTray), setTheme: (theme: 'light' | 'dark' | 'auto') => ipcRenderer.invoke(IpcChannel.App_SetTheme, theme), + setZoomFactor: (factor: number) => ipcRenderer.invoke(IpcChannel.App_SetZoomFactor, factor), setAutoUpdate: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetAutoUpdate, isActive), openWebsite: (url: string) => ipcRenderer.invoke(IpcChannel.Open_Website, url), clearCache: () => ipcRenderer.invoke(IpcChannel.App_ClearCache), @@ -190,6 +191,17 @@ const api = { subscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Subscribe), unsubscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Unsubscribe), onUpdate: (action: any) => ipcRenderer.invoke(IpcChannel.StoreSync_OnUpdate, action) + }, + // 新增:监听主进程的 zoom factor 更新 + onZoomFactorUpdate: (callback: (factor: number) => void) => { + const listener = (_event: Electron.IpcRendererEvent, factor: number) => { + callback(factor) + } + ipcRenderer.on(IpcChannel.ZoomFactorUpdated, listener) + // 返回一个移除监听器的函数 + return () => { + ipcRenderer.removeListener(IpcChannel.ZoomFactorUpdated, listener) + } } } diff --git a/src/renderer/src/hooks/useAppInit.ts b/src/renderer/src/hooks/useAppInit.ts index b31c325400..29108a42c2 100644 --- a/src/renderer/src/hooks/useAppInit.ts +++ b/src/renderer/src/hooks/useAppInit.ts @@ -18,7 +18,17 @@ import useUpdateHandler from './useUpdateHandler' export function useAppInit() { const dispatch = useAppDispatch() - const { proxyUrl, language, windowStyle, autoCheckUpdate, proxyMode, customCss, enableDataCollection } = useSettings() + const { + proxyUrl, + language, + windowStyle, + autoCheckUpdate, + proxyMode, + customCss, + enableDataCollection, + setZoomFactor, + zoomFactor + } = useSettings() const { minappShow } = useRuntime() const { setDefaultModel, setTopicNamingModel, setTranslateModel } = useDefaultModel() const avatar = useLiveQuery(() => db.settings.get('image://avatar')) @@ -31,6 +41,19 @@ export function useAppInit() { avatar?.value && dispatch(setAvatar(avatar.value)) }, [avatar, dispatch]) + useEffect(() => { + const removeZoomListener = window.api.onZoomFactorUpdate((factor) => { + setZoomFactor(factor) + }) + return () => { + removeZoomListener() + } + }, [setZoomFactor]) + + useEffect(() => { + setZoomFactor(zoomFactor) + }, [setZoomFactor, zoomFactor]) + useEffect(() => { document.getElementById('spinner')?.remove() runAsyncFunction(async () => { diff --git a/src/renderer/src/hooks/useSettings.ts b/src/renderer/src/hooks/useSettings.ts index ba6185aba9..4e1e9e9599 100644 --- a/src/renderer/src/hooks/useSettings.ts +++ b/src/renderer/src/hooks/useSettings.ts @@ -14,7 +14,8 @@ import { setTopicPosition, setTray as _setTray, setTrayOnClose, - setWindowStyle + setWindowStyle, + setZoomFactor } from '@renderer/store/settings' import { SidebarIcon, ThemeMode, TranslateLanguageVarious } from '@renderer/types' @@ -79,6 +80,10 @@ export function useSettings() { }, setAssistantIconType(assistantIconType: AssistantIconType) { dispatch(setAssistantIconType(assistantIconType)) + }, + setZoomFactor(factor: number) { + dispatch(setZoomFactor(factor)) + window.api.setZoomFactor(factor) } } } diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 65cb423d78..18c2690649 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -1475,6 +1475,7 @@ "theme.window.style.opaque": "Opaque Window", "theme.window.style.title": "Window Style", "theme.window.style.transparent": "Transparent Window", + "zoom.title": "Page Zoom", "title": "Settings", "topic.position": "Topic position", "topic.position.left": "Left", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index fe14a49e4f..56d79a7124 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -1473,6 +1473,7 @@ "theme.window.style.opaque": "不透明ウィンドウ", "theme.window.style.title": "ウィンドウスタイル", "theme.window.style.transparent": "透明ウィンドウ", + "zoom.title": "ページズーム", "title": "設定", "topic.position": "トピックの位置", "topic.position.left": "左", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 6a60701f76..ea8735f2b9 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -1473,6 +1473,7 @@ "theme.window.style.opaque": "Непрозрачное окно", "theme.window.style.title": "Стиль окна", "theme.window.style.transparent": "Прозрачное окно", + "zoom.title": "Масштаб страницы", "title": "Настройки", "topic.position": "Позиция топиков", "topic.position.left": "Слева", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 8c405cf4a1..6ea64e00f5 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1475,6 +1475,7 @@ "theme.window.style.opaque": "不透明窗口", "theme.window.style.title": "窗口样式", "theme.window.style.transparent": "透明窗口", + "zoom.title": "页面缩放", "title": "设置", "topic.position": "话题位置", "topic.position.left": "左侧", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 3e87fd67c3..3f3cf34c0d 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1474,6 +1474,7 @@ "theme.window.style.opaque": "不透明視窗", "theme.window.style.title": "視窗樣式", "theme.window.style.transparent": "透明視窗", + "zoom.title": "頁面縮放", "title": "設定", "topic.position": "話題位置", "topic.position.left": "左側", diff --git a/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx b/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx index 70734fca55..7db2ec0889 100644 --- a/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx +++ b/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx @@ -13,7 +13,8 @@ import { setSidebarIcons } from '@renderer/store/settings' import { ThemeMode } from '@renderer/types' -import { Button, Input, Segmented, Switch } from 'antd' +import { ZOOM_OPTIONS } from '@shared/config/constant' +import { Button, Input, Segmented, Select, Switch } from 'antd' import { FC, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -33,7 +34,9 @@ const DisplaySettings: FC = () => { showTopicTime, customCss, sidebarIcons, - assistantIconType + assistantIconType, + zoomFactor, + setZoomFactor } = useSettings() const { theme: themeMode } = useTheme() const { t } = useTranslation() @@ -106,6 +109,11 @@ const DisplaySettings: FC = () => { {t('settings.theme.title')} + + + {t('settings.zoom.title')} +