From 067cbd5ab2fc6f5fa1082a366deedbe8b175742a Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 15 May 2026 15:03:25 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(window):=20=E7=94=A8=20CSS?= =?UTF-8?q?=20zoom=20nudge=20=E4=BF=AE=E5=A4=8D=E4=BB=BB=E5=8A=A1=E6=A0=8F?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E5=AD=97=E4=BD=93=E5=8F=98=E5=A4=A7=E4=B8=94?= =?UTF-8?q?=E4=B8=8D=E5=BC=95=E5=85=A5=E9=87=8D=E5=A4=8D=E6=9C=80=E5=A4=A7?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 撤回上次错误的 toggle 改动:恢复 9848b8b2 的 restore 不重新最大化取舍,避免用户在任务栏点击恢复时看到窗口"被弹两次" - 新增 applyWindowsViewportZoomNudge:通过短暂将 documentElement.style.zoom 设为 1.0001 并在两帧内重置,强制 Chromium 重算 layout metrics 修复字体变大,零可见动画、不动窗口 - maximised + drift + restore 路径从仅 dispatch resize 改为先 zoom nudge 再 dispatch resize - 锁定 windowStateUi.test.ts 中 shouldToggleMaximisedWindowForScaleFix('restore', true)=false 取舍并补注释禁止再次反转 - windowsScaleFix.test.ts 加 jsdom 环境,新增双帧 zoom nudge 行为测试 --- frontend/src/App.tsx | 8 +++++- frontend/src/utils/windowStateUi.test.ts | 11 +++----- frontend/src/utils/windowStateUi.ts | 13 +++++---- frontend/src/utils/windowsScaleFix.test.ts | 32 +++++++++++++++++++++- frontend/src/utils/windowsScaleFix.ts | 31 +++++++++++++++++++++ 5 files changed, 81 insertions(+), 14 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f6e8ff3..53c47fe 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -58,7 +58,7 @@ import { type SecurityUpdateRepairSource, type SecurityUpdateSettingsFocusTarget, } from './utils/securityUpdateRepairFlow'; -import { getWindowsScaleFixNudgedWidth, hasWindowsViewportScaleDrift } from './utils/windowsScaleFix'; +import { applyWindowsViewportZoomNudge, getWindowsScaleFixNudgedWidth, hasWindowsViewportScaleDrift } from './utils/windowsScaleFix'; import { SHORTCUT_ACTION_META, SHORTCUT_ACTION_ORDER, @@ -670,6 +670,12 @@ function App() { if (isMaximised) { if (!shouldToggleMaximisedWindowForScaleFix(reason, hasViewportScaleDrift)) { + // restore 场景刻意不走 Unmaximise→Maximise(避免可见的重复最大化抖动)。 + // 改用 CSS zoom nudge 强制 Chromium 重算 layout,让 viewport drift 后残留的 + // 字体度量恢复正确——同样能修字体,且无动画。 + if (hasViewportScaleDrift) { + applyWindowsViewportZoomNudge(); + } window.dispatchEvent(new Event('resize')); lastFixAt = Date.now(); return; diff --git a/frontend/src/utils/windowStateUi.test.ts b/frontend/src/utils/windowStateUi.test.ts index d8910c0..4708f65 100644 --- a/frontend/src/utils/windowStateUi.test.ts +++ b/frontend/src/utils/windowStateUi.test.ts @@ -20,13 +20,10 @@ describe('windowStateUi', () => { it('applies the Windows scale fix when a minimized taskbar window is restored with viewport drift', () => { expect(shouldApplyWindowsScaleFix('restore', true)).toBe(true); expect(shouldApplyWindowsScaleFix('restore', false)).toBe(false); - }); - - it('toggles maximised windows on restore so taskbar-restored fonts return to the correct size', () => { - // maximised 状态下 OS 拒绝 SetSize nudge,唯一可行的修复是切一次 maximise; - // 重复触发由 inFlight 互斥 + 700ms 冷却 + ratio-change 合并到 activationTimer 防御。 - expect(shouldToggleMaximisedWindowForScaleFix('restore', true)).toBe(true); - expect(shouldToggleMaximisedWindowForScaleFix('restore', false)).toBe(false); + // 关键:restore 场景刻意不再触发 maximised 窗口的 toggle —— Unmaximise → Maximise 在 + // 任务栏恢复的真实交互里会被用户肉眼看见为"重复最大化"动画,比偶发字体变大更糟。 + // 这是 9848b8b2 已有的取舍,禁止再次被"修复"成 true。 + expect(shouldToggleMaximisedWindowForScaleFix('restore', true)).toBe(false); }); it('debounces resize-triggered Windows scale checks until window transitions settle', () => { diff --git a/frontend/src/utils/windowStateUi.ts b/frontend/src/utils/windowStateUi.ts index 0275285..b5b1706 100644 --- a/frontend/src/utils/windowStateUi.ts +++ b/frontend/src/utils/windowStateUi.ts @@ -8,14 +8,17 @@ export const shouldApplyWindowsScaleFix = ( hasViewportScaleDrift: boolean, ): boolean => (reason === 'ratio-change' || reason === 'restore') && hasViewportScaleDrift; -// maximised 窗口在 Windows 上无法通过 SetSize nudge 修复 viewport drift(OS 拒绝 resize 已 maximized 窗口), -// 唯一能让 WebView2 重新计算缩放的办法是 Unmaximise → Maximise 切换一次。restore 场景(任务栏点击恢复) -// 必须允许这条路径,否则用户从最小化状态恢复后字体会保持错误大小。重复触发由 inFlight 互斥与 lastFixAt -// 冷却 + checkDevicePixelRatio 在 minimisedSeen 上下文转发到 activationTimer 共同防御,无需额外禁用 toggle。 +// 关于 restore 场景为何刻意不走 toggle(见 9848b8b2): +// maximised 窗口在 Windows 上无法通过 SetSize nudge 修复 viewport drift(OS 拒绝 resize), +// 唯一能让 WebView2 重新计算缩放的办法是 Unmaximise → Maximise,但在任务栏图标点击恢复的 +// 真实场景下,用户会肉眼看到窗口"被弹两次"的重复最大化动画——比偶发字体变大更糟。 +// 取舍:restore 时只 dispatch resize 让 React 重算布局,宁可字体保持当前缩放, +// 也不要可见的重复最大化抖动。ratio-change(DPR 变化,例如把窗口拖到另一块显示器)则 +// 允许 toggle,因为那种场景下用户预期会有视觉过渡。 export const shouldToggleMaximisedWindowForScaleFix = ( reason: WindowScaleFixReason, hasViewportScaleDrift: boolean, -): boolean => (reason === 'ratio-change' || reason === 'restore') && hasViewportScaleDrift; +): boolean => reason === 'ratio-change' && hasViewportScaleDrift; export const resolveWindowsScaleCheckDelayMs = (trigger: WindowsScaleCheckTrigger): number => trigger === 'resize' ? 240 : 0; diff --git a/frontend/src/utils/windowsScaleFix.test.ts b/frontend/src/utils/windowsScaleFix.test.ts index 6cb78dc..a752b1a 100644 --- a/frontend/src/utils/windowsScaleFix.test.ts +++ b/frontend/src/utils/windowsScaleFix.test.ts @@ -1,5 +1,7 @@ -import { describe, expect, it } from 'vitest'; +// @vitest-environment jsdom +import { describe, expect, it, vi } from 'vitest'; import { + applyWindowsViewportZoomNudge, computeWindowsViewportScaleRatio, getWindowsScaleFixNudgedWidth, hasWindowsViewportScaleDrift, @@ -42,4 +44,32 @@ 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(); + }); }); diff --git a/frontend/src/utils/windowsScaleFix.ts b/frontend/src/utils/windowsScaleFix.ts index fe40ff6..49574a8 100644 --- a/frontend/src/utils/windowsScaleFix.ts +++ b/frontend/src/utils/windowsScaleFix.ts @@ -44,3 +44,34 @@ 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); + }); +};