diff --git a/packages/desktop/config/app-menu.js b/packages/desktop/config/app-menu.js new file mode 100644 index 00000000..8641f393 --- /dev/null +++ b/packages/desktop/config/app-menu.js @@ -0,0 +1,76 @@ +const PAGE_ZOOM_MENU_ITEMS = Object.freeze([ + { label: 'Actual Size', accelerator: 'CommandOrControl+0', action: 'reset' }, + { label: 'Zoom In', accelerator: 'CommandOrControl+Plus', action: 'zoomIn' }, + { label: 'Zoom Out', accelerator: 'CommandOrControl+-', action: 'zoomOut' }, +]); + +function buildPageZoomMenuItems({ onPageZoomAction = () => {} } = {}) { + return PAGE_ZOOM_MENU_ITEMS.map(({ label, accelerator, action }) => ({ + label, + accelerator, + click: (_menuItem, browserWindow) => onPageZoomAction(action, browserWindow?.webContents), + })); +} + +function buildAppMenuTemplate({ + isMac = process.platform === 'darwin', + onPageZoomAction, +} = {}) { + return [ + ...(isMac ? [{ role: 'appMenu' }] : []), + { role: 'fileMenu' }, + { role: 'editMenu' }, + { + label: isMac ? 'View' : '&View', + submenu: [ + { role: 'reload' }, + { role: 'forceReload' }, + { role: 'toggleDevTools' }, + { type: 'separator' }, + ...buildPageZoomMenuItems({ onPageZoomAction }), + { type: 'separator' }, + { role: 'togglefullscreen' }, + ], + }, + { role: 'windowMenu' }, + ]; +} + +function getPageZoomShortcutAction(input) { + if (!input || input.type !== 'keyDown') return null; + if (!(input.control || input.meta) || input.alt) return null; + + const key = typeof input.key === 'string' ? input.key : ''; + const code = typeof input.code === 'string' ? input.code : ''; + + if (key === '0' || code === 'Digit0' || code === 'Numpad0') { + return 'reset'; + } + + if (key === '-' || key === '_' || code === 'Minus' || code === 'NumpadSubtract') { + return 'zoomOut'; + } + + if ( + key === '+' || + key === '=' || + key === 'Plus' || + code === 'Equal' || + code === 'NumpadAdd' + ) { + return 'zoomIn'; + } + + return null; +} + +function shouldBlockPageZoomShortcut(input) { + return getPageZoomShortcutAction(input) !== null; +} + +module.exports = { + buildAppMenuTemplate, + buildPageZoomMenuItems, + getPageZoomShortcutAction, + shouldBlockPageZoomShortcut, +}; diff --git a/packages/desktop/config/app-menu.test.js b/packages/desktop/config/app-menu.test.js new file mode 100644 index 00000000..735780ca --- /dev/null +++ b/packages/desktop/config/app-menu.test.js @@ -0,0 +1,111 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + buildAppMenuTemplate, + buildPageZoomMenuItems, + getPageZoomShortcutAction, + shouldBlockPageZoomShortcut, +} = require('./app-menu'); + +test('desktop app menu includes explicit page zoom items in the View menu', () => { + const template = buildAppMenuTemplate({ isMac: false }); + const viewMenu = template.find((item) => item.label === '&View'); + + assert.ok(viewMenu); + assert.deepEqual( + viewMenu.submenu.map((item) => item.role || item.type || item.label), + [ + 'reload', + 'forceReload', + 'toggleDevTools', + 'separator', + 'Actual Size', + 'Zoom In', + 'Zoom Out', + 'separator', + 'togglefullscreen', + ], + ); +}); + +test('desktop app menu keeps the macOS app menu while using explicit zoom items', () => { + const template = buildAppMenuTemplate({ isMac: true }); + + assert.equal(template[0]?.role, 'appMenu'); + assert.equal( + template.some((item) => item.role === 'viewMenu'), + false, + ); +}); + +test('desktop page zoom menu items expose the expected accelerators and actions', () => { + const actions = []; + const mockWebContents = { id: 'focused-web-contents' }; + const items = buildPageZoomMenuItems({ + onPageZoomAction: (action, targetWebContents) => { + actions.push([action, targetWebContents]); + }, + }); + + assert.deepEqual( + items.map((item) => [item.label, item.accelerator]), + [ + ['Actual Size', 'CommandOrControl+0'], + ['Zoom In', 'CommandOrControl+Plus'], + ['Zoom Out', 'CommandOrControl+-'], + ], + ); + + for (const item of items) { + item.click({}, { webContents: mockWebContents }); + } + + assert.deepEqual(actions, [ + ['reset', mockWebContents], + ['zoomIn', mockWebContents], + ['zoomOut', mockWebContents], + ]); +}); + +test('page zoom shortcut detection covers top-row, shifted, and numpad inputs', () => { + assert.equal( + getPageZoomShortcutAction({ type: 'keyDown', control: true, meta: false, alt: false, key: '=', code: 'Equal' }), + 'zoomIn', + ); + assert.equal( + getPageZoomShortcutAction({ type: 'keyDown', control: true, meta: false, alt: false, key: '+', code: 'Equal' }), + 'zoomIn', + ); + assert.equal( + getPageZoomShortcutAction({ type: 'keyDown', control: true, meta: false, alt: false, key: '+', code: 'NumpadAdd' }), + 'zoomIn', + ); + assert.equal( + getPageZoomShortcutAction({ type: 'keyDown', control: true, meta: false, alt: false, key: '-', code: 'Minus' }), + 'zoomOut', + ); + assert.equal( + getPageZoomShortcutAction({ type: 'keyDown', control: true, meta: false, alt: false, key: '_', code: 'Minus' }), + 'zoomOut', + ); + assert.equal( + getPageZoomShortcutAction({ type: 'keyDown', control: true, meta: false, alt: false, key: '0', code: 'Digit0' }), + 'reset', + ); +}); + +test('page zoom shortcut detection ignores unrelated shortcuts', () => { + assert.equal( + shouldBlockPageZoomShortcut({ type: 'keyDown', control: true, meta: false, alt: false, key: 'i', code: 'KeyI' }), + false, + ); + assert.equal( + shouldBlockPageZoomShortcut({ type: 'keyDown', control: true, meta: false, alt: true, key: '=', code: 'Equal' }), + false, + ); + assert.equal( + shouldBlockPageZoomShortcut({ type: 'keyUp', control: true, meta: false, alt: false, key: '=', code: 'Equal' }), + false, + ); +}); diff --git a/packages/desktop/config/page-zoom.js b/packages/desktop/config/page-zoom.js new file mode 100644 index 00000000..f99d8c94 --- /dev/null +++ b/packages/desktop/config/page-zoom.js @@ -0,0 +1,61 @@ +const DEFAULT_PAGE_ZOOM_LEVEL = 0; +const PAGE_ZOOM_LEVEL_STEP = 1; +const PAGE_ZOOM_MIN_LEVEL = Math.log(0.5) / Math.log(1.2); +const PAGE_ZOOM_MAX_LEVEL = Math.log(3) / Math.log(1.2); +const VISUAL_ZOOM_LIMITS = Object.freeze({ + minimum: 1, + maximum: 1, +}); + +function clampPageZoomLevel(level) { + return Math.min(PAGE_ZOOM_MAX_LEVEL, Math.max(PAGE_ZOOM_MIN_LEVEL, level)); +} + +function getNextPageZoomLevel(currentLevel, action) { + const safeCurrentLevel = Number.isFinite(currentLevel) + ? currentLevel + : DEFAULT_PAGE_ZOOM_LEVEL; + + switch (action) { + case 'zoomIn': + return clampPageZoomLevel(safeCurrentLevel + PAGE_ZOOM_LEVEL_STEP); + case 'zoomOut': + return clampPageZoomLevel(safeCurrentLevel - PAGE_ZOOM_LEVEL_STEP); + case 'reset': + return DEFAULT_PAGE_ZOOM_LEVEL; + default: + return null; + } +} + +function getPageZoomActionFromDirection(direction) { + if (direction === 'in') return 'zoomIn'; + if (direction === 'out') return 'zoomOut'; + return null; +} + +function applyPageZoomAction(webContents, action) { + if ( + !webContents || + typeof webContents.getZoomLevel !== 'function' || + typeof webContents.setZoomLevel !== 'function' + ) { + return null; + } + + const nextLevel = getNextPageZoomLevel(webContents.getZoomLevel(), action); + if (nextLevel === null) return null; + + webContents.setZoomLevel(nextLevel); + return nextLevel; +} + +module.exports = { + DEFAULT_PAGE_ZOOM_LEVEL, + PAGE_ZOOM_MAX_LEVEL, + PAGE_ZOOM_MIN_LEVEL, + VISUAL_ZOOM_LIMITS, + applyPageZoomAction, + getPageZoomActionFromDirection, + getNextPageZoomLevel, +}; diff --git a/packages/desktop/config/page-zoom.test.js b/packages/desktop/config/page-zoom.test.js new file mode 100644 index 00000000..17342c66 --- /dev/null +++ b/packages/desktop/config/page-zoom.test.js @@ -0,0 +1,69 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + DEFAULT_PAGE_ZOOM_LEVEL, + PAGE_ZOOM_MAX_LEVEL, + PAGE_ZOOM_MIN_LEVEL, + VISUAL_ZOOM_LIMITS, + applyPageZoomAction, + getPageZoomActionFromDirection, + getNextPageZoomLevel, +} = require('./page-zoom'); + +test('page zoom level helper moves in, out, and reset actions', () => { + assert.equal(getNextPageZoomLevel(DEFAULT_PAGE_ZOOM_LEVEL, 'zoomIn'), 1); + assert.equal(getNextPageZoomLevel(DEFAULT_PAGE_ZOOM_LEVEL, 'zoomOut'), -1); + assert.equal(getNextPageZoomLevel(2, 'reset'), DEFAULT_PAGE_ZOOM_LEVEL); +}); + +test('page zoom level helper clamps to Electron zoom bounds', () => { + assert.equal(getNextPageZoomLevel(PAGE_ZOOM_MAX_LEVEL, 'zoomIn'), PAGE_ZOOM_MAX_LEVEL); + assert.equal(getNextPageZoomLevel(PAGE_ZOOM_MIN_LEVEL, 'zoomOut'), PAGE_ZOOM_MIN_LEVEL); +}); + +test('page zoom level helper ignores unknown actions', () => { + assert.equal(getNextPageZoomLevel(DEFAULT_PAGE_ZOOM_LEVEL, 'noop'), null); +}); + +test('page zoom direction helper only maps supported wheel directions', () => { + assert.equal(getPageZoomActionFromDirection('in'), 'zoomIn'); + assert.equal(getPageZoomActionFromDirection('out'), 'zoomOut'); + assert.equal(getPageZoomActionFromDirection('sideways'), null); +}); + +test('visual zoom limits stay locked to 100 percent to avoid pinch drift', () => { + assert.deepEqual(VISUAL_ZOOM_LIMITS, { minimum: 1, maximum: 1 }); +}); + +test('page zoom action applies the next level to a webContents-like target', () => { + const webContents = { + zoomLevel: DEFAULT_PAGE_ZOOM_LEVEL, + getZoomLevel() { + return this.zoomLevel; + }, + setZoomLevel(nextLevel) { + this.zoomLevel = nextLevel; + }, + }; + + assert.equal(applyPageZoomAction(webContents, 'zoomIn'), 1); + assert.equal(webContents.zoomLevel, 1); + assert.equal(applyPageZoomAction(webContents, 'zoomOut'), DEFAULT_PAGE_ZOOM_LEVEL); + assert.equal(webContents.zoomLevel, DEFAULT_PAGE_ZOOM_LEVEL); + assert.equal(applyPageZoomAction(webContents, 'reset'), DEFAULT_PAGE_ZOOM_LEVEL); +}); + +test('page zoom action leaves unsupported targets unchanged', () => { + const webContents = { + getZoomLevel() { + return DEFAULT_PAGE_ZOOM_LEVEL; + }, + setZoomLevel() { + throw new Error('should not be called'); + }, + }; + + assert.equal(applyPageZoomAction(webContents, 'noop'), null); + assert.equal(applyPageZoomAction(null, 'zoomIn'), null); +}); diff --git a/packages/desktop/main.js b/packages/desktop/main.js index 149277ca..a7132fc0 100644 --- a/packages/desktop/main.js +++ b/packages/desktop/main.js @@ -32,6 +32,16 @@ const { DEFAULT_CONFIG } = require('./config/update-config'); const { createGlobalDispatcherFromProxyDecision } = require('./config/proxy-dispatcher'); +const { + buildAppMenuTemplate, + getPageZoomShortcutAction, +} = require('./config/app-menu'); +const { + DEFAULT_PAGE_ZOOM_LEVEL, + VISUAL_ZOOM_LIMITS, + applyPageZoomAction, + getPageZoomActionFromDirection, +} = require('./config/page-zoom'); const { setupRemoteStorageHandlers } = require('./remote-storage'); const path = require('path'); @@ -467,6 +477,41 @@ function createWindow() { }, }); + const handlePageZoomAction = (action, targetWebContents = mainWindow?.webContents) => { + applyPageZoomAction(targetWebContents, action); + }; + + Menu.setApplicationMenu( + Menu.buildFromTemplate( + buildAppMenuTemplate({ + isMac: process.platform === 'darwin', + onPageZoomAction: handlePageZoomAction, + }) + ) + ); + mainWindow.webContents.setZoomLevel(DEFAULT_PAGE_ZOOM_LEVEL); + void mainWindow.webContents + .setVisualZoomLevelLimits(VISUAL_ZOOM_LIMITS.minimum, VISUAL_ZOOM_LIMITS.maximum) + .catch((error) => { + console.warn('[Main Process] Failed to keep visual zoom locked:', error); + }); + + mainWindow.webContents.on('before-input-event', (event, input) => { + const action = getPageZoomShortcutAction(input); + if (!action) return; + + event.preventDefault(); + handlePageZoomAction(action); + }); + + // Keep pinch zoom disabled so keyboard/menu reset stays authoritative. + // Wheel-based page zoom still arrives through Electron's zoom-changed event. + mainWindow.webContents.on('zoom-changed', (_event, zoomDirection) => { + const action = getPageZoomActionFromDirection(zoomDirection); + if (!action) return; + handlePageZoomAction(action); + }); + // Enable native-like context menu for text inputs (cut/copy/paste/selectAll). // Electron doesn't provide this by default, which makes right-click paste // unavailable on Windows. diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 45d761c3..b8e2165e 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -14,6 +14,7 @@ "package": "electron-builder", "package:ci": "electron-builder --publish never", "dev": "cross-env NODE_ENV=development electron .", + "test": "node --test config/*.test.js", "logs:view": "node -e \"const path=require('path');const os=require('os');const {execSync}=require('child_process');const logDir=path.join(os.homedir(),'AppData','Roaming','PromptOptimizer','logs');console.log('日志目录:',logDir);try{execSync('explorer '+logDir);}catch(e){console.log('请手动打开:',logDir);}\"", "logs:clean": "node -e \"const fs=require('fs');const path=require('path');const os=require('os');const logDir=path.join(os.homedir(),'AppData','Roaming','PromptOptimizer','logs');if(fs.existsSync(logDir)){fs.rmSync(logDir,{recursive:true});console.log('日志已清理');}else{console.log('日志目录不存在');}\"" },