fix(desktop): support page zoom shortcuts
This commit is contained in:
1 parent
c08c329b5d
commit
1554e8a4a0
6 files changed
+363
No files matched your search
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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('日志目录不存在');}\""
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user