From 22d65d0192bb57f60fd5f0beb147411e245bc90e Mon Sep 17 00:00:00 2001 From: AetherNo2332 <130164460+AetherNo2332@users.noreply.github.com> Date: Mon, 21 Sep 2026 07:05:03 +0800 Subject: [PATCH] =?UTF-8?q?fix(web):=20=E5=9F=BA=E7=A1=80=E6=8E=A7?= =?UTF-8?q?=E4=BB=B6=20FloatingDock=20-=20=E5=AE=BF=E4=B8=BB=E6=B2=A1?= =?UTF-8?q?=E6=9C=89=20matchMedia=20=E6=97=B6=E4=B8=8D=E5=86=8D=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E5=A4=B1=E8=B4=A5=20(#563)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Original-Commit: 3da77b3931a675de06b02c1bba93f112d253041b fix(web): 基础控件 FloatingDock - 宿主没有 matchMedia 时不再渲染失败 `window` 存在不代表 `matchMedia` 存在(SSR 垫片、嵌入式 webview、部分 DOM polyfill), 而 FloatingDock 在渲染期直接取 `window.matchMedia("(pointer: coarse)").matches`, 取不到就抛 TypeError,整个 dock(画布工具栏/素材托盘/缩放控件)渲染失败。 仓库里同一模式已有先例:canvas-workspace-tool-panel 用 typeof 守卫。 抽 coarsePointerQuery():两个都探测,取不到按"非触屏"处理,useEffect 也提前返回; 能取到时行为不变。新增源码级守卫用例(上游原态失败、补丁后通过)。 --- .../components/ui/aceternity/floating-dock.tsx | 12 ++++++++++-- web/test/floating-dock-pointer-guard.test.tsx | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 web/test/floating-dock-pointer-guard.test.tsx diff --git a/web/src/components/ui/aceternity/floating-dock.tsx b/web/src/components/ui/aceternity/floating-dock.tsx index 6f02f0f4a..9ab339528 100644 --- a/web/src/components/ui/aceternity/floating-dock.tsx +++ b/web/src/components/ui/aceternity/floating-dock.tsx @@ -65,6 +65,13 @@ const DOCK_METRICS: Record, DockMetrics> compact: { base: 26, magnified: 32, icon: 13, iconMagnified: 16, distance: 84 }, }; +// `window` 存在不代表 `matchMedia` 存在:测试与 renderToString 下它可能是 undefined, +// 渲染期直接取用会抛 TypeError。指针能力是纯增强,取不到就按"非触屏"处理。 +function coarsePointerQuery(): MediaQueryList | undefined { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") return undefined; + return window.matchMedia("(pointer: coarse)"); +} + const TOUCH_DOCK_METRICS: Record, DockMetrics> = { default: { base: 40, magnified: 40, icon: 18, iconMagnified: 18, distance: 0 }, compact: { base: 36, magnified: 36, icon: 16, iconMagnified: 16, distance: 0 }, @@ -73,12 +80,13 @@ const TOUCH_DOCK_METRICS: Record, DockMet export const FloatingDock = forwardRef(function FloatingDock({ items, size = "default", embedded = false, className, style, ariaLabel = "画布工具", showLabels = false }, forwardedRef) { const mouseX = useMotionValue(Number.POSITIVE_INFINITY); const reducedMotion = useReducedMotion(); - const [coarsePointer, setCoarsePointer] = useState(() => typeof window !== "undefined" && window.matchMedia("(pointer: coarse)").matches); + const [coarsePointer, setCoarsePointer] = useState(() => coarsePointerQuery()?.matches ?? false); // 窄屏下 dock 按钮总宽易超出可用宽度:此时允许横向滚动并禁用放大(放大依赖 overflow-visible,与滚动互斥) const [narrow, setNarrow] = useState(() => (typeof window !== "undefined" ? window.innerWidth < 768 : false)); useEffect(() => { - const media = window.matchMedia("(pointer: coarse)"); + const media = coarsePointerQuery(); + if (!media) return; const update = () => setCoarsePointer(media.matches); update(); media.addEventListener("change", update); diff --git a/web/test/floating-dock-pointer-guard.test.tsx b/web/test/floating-dock-pointer-guard.test.tsx new file mode 100644 index 000000000..6723bf97f --- /dev/null +++ b/web/test/floating-dock-pointer-guard.test.tsx @@ -0,0 +1,18 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; + +// 指针能力探测是纯增强:`window` 存在不代表 `matchMedia` 存在(SSR 垫片、嵌入式 webview、 +// 部分 DOM polyfill 下它是 undefined),而 FloatingDock 在**渲染期**就取 +// `window.matchMedia("(pointer: coarse)")`,取不到会直接抛 TypeError 让整个 dock 渲染失败。 +// 仓库里同一模式已有先例:canvas-workspace-tool-panel.tsx 用 `typeof window.matchMedia === "function"` 守卫。 +describe("floating dock pointer capability probe", () => { + const source = readFileSync(new URL("../src/components/ui/aceternity/floating-dock.tsx", import.meta.url), "utf8"); + + test("probes matchMedia through a guarded helper", () => { + expect(source).toContain('typeof window.matchMedia !== "function"'); + expect(source).toContain("function coarsePointerQuery()"); + // 不允许再出现"直接取用 matchMedia 结果"的写法(初始化与 useEffect 都不得裸取) + expect(source).not.toMatch(/window\.matchMedia\("\(pointer: coarse\)"\)\.matches/); + expect(source).not.toMatch(/const media = window\.matchMedia/); + }); +});