fix(desktop): support page zoom shortcuts

This commit is contained in:
linshen committed 2026-06-22 21:47:36 +08:00
1 parent c08c329b5d
commit 1554e8a4a0
6 files changed
+363

No files matched your search

+76
View File
@@ -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,
};
+111
View File
@@ -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,
);
});
+61
View File
@@ -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,
};
+69
View File
@@ -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);
});
+45
View File
@@ -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.
+1
View File
@@ -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('日志目录不存在');}\""
},