🐛 fix(window): 直接调 WebView2 zoom factor 零感知修复 Windows 字体异常

- 新增 ResetWebViewZoom RPC:从 ctx 反射拿 Wails 内部 *edge.Chromium,调 PutZoomFactor(1.0) 强制 WebView2 重算 D2D/DirectWrite 字体度量,完全不动窗口零动画
- 自动路径:maximised + restore + drift 时直接调 backend reset,告别 9848b8b2 之后字体偶发变大的取舍
- 手动路径:保留 Ctrl+Shift+0 快捷键作为兜底(优先 WebView2 reset,失败回退 toggle)
- 撤回上一版 CSS zoom nudge:实测在 WebView2 上不能修字体度量(度量缓存在 D2D 层不在 Chromium layout 层)
- 反射做了 3 层签名校验(frontend value / chromium 字段 / PutZoomFactor 方法签名),wails 升级破坏接口时返回 error 不让进程崩溃
- 新增 windows-only 反射逻辑测试 4 个、跨平台 RPC 行为测试 2 个、Ctrl+Shift+0 快捷键注册测试
This commit is contained in:
Syngnat
2026-05-15 16:01:18 +08:00
parent 32d51f3c25
commit 2580e4d6f3
10 changed files with 304 additions and 69 deletions

View File

@@ -58,7 +58,7 @@ import {
type SecurityUpdateRepairSource,
type SecurityUpdateSettingsFocusTarget,
} from './utils/securityUpdateRepairFlow';
import { applyWindowsViewportZoomNudge, getWindowsScaleFixNudgedWidth, hasWindowsViewportScaleDrift } from './utils/windowsScaleFix';
import { getWindowsScaleFixNudgedWidth, hasWindowsViewportScaleDrift } from './utils/windowsScaleFix';
import {
SHORTCUT_ACTION_META,
SHORTCUT_ACTION_ORDER,
@@ -670,11 +670,20 @@ function App() {
if (isMaximised) {
if (!shouldToggleMaximisedWindowForScaleFix(reason, hasViewportScaleDrift)) {
// restore 场景刻意不走 Unmaximise→Maximise避免可见的重复最大化抖动
// 改用 CSS zoom nudge 强制 Chromium 重算 layout让 viewport drift 后残留的
// 字体度量恢复正确——同样能修字体,且无动画。
// restore + drift任务栏点击恢复后字体异常变大的零感知修复路径
// 调 backend App.ResetWebViewZoom 触发 WebView2 ICoreWebView2Controller::put_ZoomFactor(1.0)
// 让 WebView2 重算 D2D/DirectWrite 字体度量。完全不动窗口、零动画。
// backend 失败wails 升级破坏反射 / 非 Windows时回退到 dispatch resize 兜底;
// 用户仍可按 Ctrl+Shift+0 手动 toggle 修复。
if (hasViewportScaleDrift) {
applyWindowsViewportZoomNudge();
try {
const res = await (window as any).go?.app?.App?.ResetWebViewZoom?.();
if (!res?.success) {
console.warn('ResetWebViewZoom unavailable in fixWindowScaleIfNeeded:', res?.message);
}
} catch (e) {
console.warn('ResetWebViewZoom call failed in fixWindowScaleIfNeeded', e);
}
}
window.dispatchEvent(new Event('resize'));
lastFixAt = Date.now();
@@ -2317,6 +2326,57 @@ function App() {
}
void handleTitleBarWindowToggle();
};
// handleManualResetWindowZoom 由 resetWindowZoom 快捷键(默认 Ctrl+Shift+0触发
// 作为自动路径失败时的兜底入口。
//
// 优先调 backend App.ResetWebViewZoom 走 WebView2 zoom reset零动画零感知
// 失败时回退到 Unmaximise→Maximise toggle —— 用户主动按了快捷键,预期看见动画。
const handleManualResetWindowZoom = React.useCallback(async () => {
if (!isWindowsPlatform()) {
message.info('该功能仅在 Windows 平台生效');
return;
}
try {
const res = await (window as any).go?.app?.App?.ResetWebViewZoom?.();
if (res?.success) {
window.dispatchEvent(new Event('resize'));
message.success('已重置窗口缩放');
return;
}
console.warn('ResetWebViewZoom backend reported failure, falling back to maximise toggle:', res?.message);
} catch (e) {
console.warn('ResetWebViewZoom backend unavailable, falling back to maximise toggle', e);
}
try {
const isFullscreen = await WindowIsFullscreen().catch(() => false);
if (isFullscreen) {
message.info('全屏状态下无法重置缩放,请先退出全屏');
return;
}
const isMaximised = await WindowIsMaximised().catch(() => false);
if (isMaximised) {
WindowUnmaximise();
await new Promise((resolve) => window.setTimeout(resolve, 96));
WindowMaximise();
await new Promise((resolve) => window.setTimeout(resolve, 96));
} else {
const size = await WindowGetSize().catch(() => null);
const width = Math.trunc(Number(size?.w) || 0);
const height = Math.trunc(Number(size?.h) || 0);
if (width > 0 && height > 0) {
WindowSetSize(getWindowsScaleFixNudgedWidth(width), height);
await new Promise((resolve) => window.setTimeout(resolve, 28));
WindowSetSize(width, height);
}
}
window.dispatchEvent(new Event('resize'));
message.success('已重置窗口缩放(回退方案)');
} catch (e) {
console.warn('重置窗口缩放失败', e);
message.error('重置窗口缩放失败');
}
}, []);
// Sidebar Resizing
const sidebarDragRef = React.useRef<{ startX: number, startWidth: number } | null>(null);
@@ -2576,6 +2636,9 @@ function App() {
void handleTitleBarWindowToggle();
}
break;
case 'resetWindowZoom':
void handleManualResetWindowZoom();
break;
}
};
@@ -2583,7 +2646,7 @@ function App() {
return () => {
window.removeEventListener('keydown', handleGlobalShortcut);
};
}, [handleNewQuery, handleTitleBarWindowToggle, isMacRuntime, shortcutOptions, themeMode, setTheme, useNativeMacWindowControls]);
}, [handleManualResetWindowZoom, handleNewQuery, handleTitleBarWindowToggle, isMacRuntime, shortcutOptions, themeMode, setTheme, useNativeMacWindowControls]);
useEffect(() => {
if (!capturingShortcutAction) {

View File

@@ -128,6 +128,19 @@ describe('shortcut defaults', () => {
scope: 'queryEditor',
});
});
// Windows 任务栏恢复后字体异常变大的兜底入口(方案 3
// 自动 fix 路径9848b8b2刻意不再 toggle 以避免可见动画,由该快捷键给用户主动触发的修复入口。
it('registers reset window zoom shortcut with default Ctrl+Shift+0', () => {
expect(DEFAULT_SHORTCUT_OPTIONS.resetWindowZoom).toEqual({
combo: 'Ctrl+Shift+0',
enabled: true,
});
expect(SHORTCUT_ACTION_META.resetWindowZoom).toMatchObject({
label: '重置窗口缩放',
allowInEditable: true,
});
});
});
// ─── comboToMonacoKeyBinding ─────────────────────────────────────────

View File

@@ -9,7 +9,8 @@ export type ShortcutAction =
| 'toggleLogPanel'
| 'toggleTheme'
| 'openShortcutManager'
| 'toggleMacFullscreen';
| 'toggleMacFullscreen'
| 'resetWindowZoom';
export interface ShortcutBinding {
combo: string;
@@ -87,6 +88,7 @@ export const SHORTCUT_ACTION_ORDER: ShortcutAction[] = [
'toggleTheme',
'openShortcutManager',
'toggleMacFullscreen',
'resetWindowZoom',
];
export const SHORTCUT_ACTION_META: Record<ShortcutAction, ShortcutActionMeta> = {
@@ -135,6 +137,11 @@ export const SHORTCUT_ACTION_META: Record<ShortcutAction, ShortcutActionMeta> =
description: 'macOS 原生窗口控制模式下的全屏切换⌃⌘F',
platformOnly: 'mac',
},
resetWindowZoom: {
label: '重置窗口缩放',
description: 'Windows 任务栏恢复后字体异常变大时主动触发;会切一次最大化让 WebView2 重算字体度量',
allowInEditable: true,
},
};
export const DEFAULT_SHORTCUT_OPTIONS: ShortcutOptions = {
@@ -147,6 +154,7 @@ export const DEFAULT_SHORTCUT_OPTIONS: ShortcutOptions = {
toggleTheme: { combo: 'Ctrl+Shift+D', enabled: true },
openShortcutManager: { combo: 'Ctrl+,', enabled: true },
toggleMacFullscreen: { combo: 'Ctrl+Meta+F', enabled: true },
resetWindowZoom: { combo: 'Ctrl+Shift+0', enabled: true },
};
const normalizeKeyToken = (value: string): string => {

View File

@@ -1,7 +1,5 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from 'vitest';
import { describe, expect, it } from 'vitest';
import {
applyWindowsViewportZoomNudge,
computeWindowsViewportScaleRatio,
getWindowsScaleFixNudgedWidth,
hasWindowsViewportScaleDrift,
@@ -44,32 +42,4 @@ describe('windowsScaleFix', () => {
expect(getWindowsScaleFixNudgedWidth(960)).toBe(959);
expect(getWindowsScaleFixNudgedWidth(420)).toBe(421);
});
it('applies and resets the CSS zoom nudge across two animation frames', () => {
const rafCallbacks: FrameRequestCallback[] = [];
const rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
rafCallbacks.push(cb);
return rafCallbacks.length;
});
const root = document.documentElement;
const style = root.style as CSSStyleDeclaration & { zoom?: string };
style.zoom = '';
applyWindowsViewportZoomNudge();
// 第一阶段zoom 已被设置为 1.0001
expect(style.zoom).toBe('1.0001');
expect(rafCallbacks).toHaveLength(1);
// 第一帧:调度第二帧 reset
rafCallbacks[0]?.(0);
expect(style.zoom).toBe('1.0001');
expect(rafCallbacks).toHaveLength(2);
// 第二帧:恢复 zoom
rafCallbacks[1]?.(0);
expect(style.zoom).toBe('');
rafSpy.mockRestore();
});
});

View File

@@ -44,34 +44,3 @@ export const getWindowsScaleFixNudgedWidth = (width: number): number => {
}
return normalizedWidth > 480 ? normalizedWidth - 1 : normalizedWidth + 1;
};
// applyWindowsViewportZoomNudge 通过短暂改变 documentElement 的 CSS zoom 触发 Chromium
// 重新计算 layout metrics。用于 maximised 窗口在 restore 场景下 viewport drift 修复的
// 无动画路径:不重新最大化(避免 9848b8b2 修复的可见"重复最大化"抖动),也不调 WebView2
// COM API避免 Windows 平台特定代码)。
//
// 为什么这样能修Chromium 在切换 zoom factor 时会重算所有 px 度量与 currentColor/font-size
// 派生值drift 后残留的旧度量被丢弃。1.0001 与 1 在视觉上不可分辨但属于 invalidation 阈值
// 之外,强制触发完整 layout 重排。
//
// 用 requestAnimationFrame 两帧而不是立即 reset让 Chromium 在第一帧完成 nudge layout、
// 第二帧恢复——避免单帧合成被合并掉。
export const applyWindowsViewportZoomNudge = (): void => {
if (typeof document === 'undefined' || !document.documentElement) {
return;
}
const root = document.documentElement;
const style = root.style as CSSStyleDeclaration & { zoom?: string };
const previousZoom = style.zoom ?? '';
style.zoom = '1.0001';
const reset = () => {
style.zoom = previousZoom;
};
if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') {
reset();
return;
}
window.requestAnimationFrame(() => {
window.requestAnimationFrame(reset);
});
};