Files
ttstd-infinite-canvas/web/src/components/ui/aceternity/floating-dock.tsx
AetherNo2332 0f909a77b0 fix(web): 基础控件 FloatingDock - 宿主没有 matchMedia 时不再渲染失败 (#563)
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 也提前返回;
能取到时行为不变。新增源码级守卫用例(上游原态失败、补丁后通过)。
2026-09-21 09:37:19 +08:00

397 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { AnimatePresence, motion, useMotionValue, useReducedMotion, useSpring, useTransform, type MotionValue } from "motion/react";
import { forwardRef, useEffect, useRef, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { aceternityMotion } from "@/lib/aceternity-motion";
export type FloatingDockCommand = {
kind?: "command";
id: string;
label: string;
displayLabel?: string;
badge?: ReactNode;
icon: ReactNode;
wide?: boolean;
quiet?: boolean;
onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
active?: boolean;
disabled?: boolean;
danger?: boolean;
/** 面板展开型工具——使用 aria-expanded 而非 aria-pressed */
expands?: boolean;
};
export type FloatingDockSwitchOption = {
id: string;
label: string;
icon: ReactNode;
value: string;
displayLabel?: string;
};
export type FloatingDockSwitch = {
kind: "switch";
id: string;
label: string;
value: string;
options: FloatingDockSwitchOption[];
onChange: (value: string) => void;
};
export type FloatingDockEntry = FloatingDockCommand | FloatingDockSwitch | { kind: "separator"; id: string };
type FloatingDockProps = {
items: FloatingDockEntry[];
size?: "default" | "compact";
embedded?: boolean;
className?: string;
style?: CSSProperties;
ariaLabel?: string;
showLabels?: boolean;
};
type DockMetrics = {
base: number;
magnified: number;
icon: number;
iconMagnified: number;
distance: number;
};
// magnification 收敛:base 略增以适配圆角方块,magnified 幅度从 +14 降到 +8,
// 避免 dock 整体跳动,同时保留 Aceternity 接近放大身份。
const DOCK_METRICS: Record<NonNullable<FloatingDockProps["size"]>, DockMetrics> = {
default: { base: 30, magnified: 38, icon: 15, iconMagnified: 18, distance: 100 },
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<NonNullable<FloatingDockProps["size"]>, DockMetrics> = {
default: { base: 40, magnified: 40, icon: 18, iconMagnified: 18, distance: 0 },
compact: { base: 36, magnified: 36, icon: 16, iconMagnified: 16, distance: 0 },
};
export const FloatingDock = forwardRef<HTMLDivElement, FloatingDockProps>(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(() => coarsePointerQuery()?.matches ?? false);
// 窄屏下 dock 按钮总宽易超出可用宽度:此时允许横向滚动并禁用放大(放大依赖 overflow-visible,与滚动互斥)
const [narrow, setNarrow] = useState(() => (typeof window !== "undefined" ? window.innerWidth < 768 : false));
useEffect(() => {
const media = coarsePointerQuery();
if (!media) return;
const update = () => setCoarsePointer(media.matches);
update();
media.addEventListener("change", update);
return () => media.removeEventListener("change", update);
}, []);
useEffect(() => {
const update = () => setNarrow(window.innerWidth < 768);
window.addEventListener("resize", update);
return () => window.removeEventListener("resize", update);
}, []);
// scrollable 场景(触屏或窄屏)禁用放大并允许横向滚动,保证按钮始终可达
const scrollable = coarsePointer || narrow;
const motionEnabled = !reducedMotion && !scrollable;
const metrics = coarsePointer ? TOUCH_DOCK_METRICS[size] : DOCK_METRICS[size];
return (
<motion.div
ref={forwardedRef}
role="toolbar"
aria-label={ariaLabel}
className={cn(
"aceternity-floating-dock flex",
scrollable ? "overflow-x-auto" : "overflow-visible",
showLabels ? "items-center" : "items-end",
embedded ? "shadow-none" : "border backdrop-blur-2xl",
showLabels
? embedded
? size === "compact"
? "h-9 gap-0.5 px-0.5"
: "h-10 gap-0.5 px-0.5"
: size === "compact"
? "h-10 gap-0.5 rounded-[var(--dock-radius-compact)] px-1.5"
: "h-11 gap-0.5 rounded-[var(--dock-radius-tight)] px-2"
: coarsePointer
? embedded
? size === "compact"
? "h-10 gap-1 px-0.5"
: "h-11 gap-1 px-0.5"
: size === "compact"
? "h-11 gap-1 rounded-[var(--dock-radius-tight)] px-1.5 pb-1"
: "h-12 gap-1 rounded-[var(--panel-radius)] px-2 pb-1"
: embedded
? size === "compact"
? "h-8 gap-0.5 px-0.5 pb-0.5"
: "h-9 gap-0.5 px-0.5 pb-0.5"
: size === "compact"
? "h-8 gap-0.5 rounded-[var(--r-lg)] px-1 pb-1"
: "h-10 gap-0.5 rounded-[var(--dock-radius)] px-1.5 pb-1",
className,
)}
style={style}
onPointerMove={(event) => {
if (motionEnabled) mouseX.set(event.clientX);
}}
onPointerLeave={() => mouseX.set(Number.POSITIVE_INFINITY)}
>
{renderDockItems(items, { mouseX, metrics, motionEnabled: motionEnabled && !showLabels, compact: size === "compact", showLabel: showLabels })}
</motion.div>
);
});
type DockItemRenderProps = {
mouseX: MotionValue<number>;
metrics: DockMetrics;
motionEnabled: boolean;
compact: boolean;
showLabel: boolean;
};
/**
* 渲染 Dock 条目,将连续的 danger 命令包裹在 is-danger-group 容器中实现视觉隔离。
* 满足"危险操作必须与常规按钮隔离"的硬约束。
*/
function renderDockItems(items: FloatingDockEntry[], props: DockItemRenderProps) {
const result: ReactNode[] = [];
let dangerGroup: FloatingDockCommand[] = [];
let index = 0;
const flushDangerGroup = () => {
if (!dangerGroup.length) return;
const groupKey = `danger-group-${index}`;
result.push(
<span key={groupKey} className="aceternity-dock-danger-group flex shrink-0 items-end gap-0.5 rounded-[calc(var(--dock-item-radius)+2px)] px-0.5">
{dangerGroup.map((command) => (
<DockCommandButton key={command.id} command={command} mouseX={props.mouseX} metrics={props.metrics} motionEnabled={props.motionEnabled} compact={props.compact} showLabel={props.showLabel} />
))}
</span>,
);
dangerGroup = [];
};
for (const item of items) {
if (item.kind === "separator") {
flushDangerGroup();
result.push(<DockSeparator key={item.id} compact={props.compact} labeled={props.showLabel} />);
continue;
}
if (item.kind === "switch") {
flushDangerGroup();
result.push(<DockSwitch key={item.id} entry={item} compact={props.compact} showLabel={props.showLabel} motionEnabled={props.motionEnabled} metrics={props.metrics} />);
index += 1;
continue;
}
if (item.danger) {
dangerGroup.push(item);
index += 1;
continue;
}
flushDangerGroup();
result.push(<DockCommandButton key={item.id} command={item} mouseX={props.mouseX} metrics={props.metrics} motionEnabled={props.motionEnabled} compact={props.compact} showLabel={props.showLabel} />);
index += 1;
}
flushDangerGroup();
return result;
}
function DockCommandButton({ command, mouseX, metrics, motionEnabled, compact, showLabel }: { command: FloatingDockCommand; mouseX: MotionValue<number>; metrics: DockMetrics; motionEnabled: boolean; compact: boolean; showLabel: boolean }) {
const ref = useRef<HTMLSpanElement>(null);
const [focused, setFocused] = useState(false);
const [hovered, setHovered] = useState(false);
const distance = useTransform(mouseX, (value) => {
const bounds = ref.current?.getBoundingClientRect();
if (!bounds || !Number.isFinite(value)) return Number.POSITIVE_INFINITY;
return value - bounds.left - bounds.width / 2;
});
const itemTarget = useTransform(distance, (value) => proximitySize(value, metrics.base, metrics.magnified, metrics.distance, motionEnabled));
const iconTarget = useTransform(distance, (value) => proximitySize(value, metrics.icon, metrics.iconMagnified, metrics.distance, motionEnabled));
const itemSize = useSpring(itemTarget, aceternityMotion.spring.dock);
const iconSize = useSpring(iconTarget, aceternityMotion.spring.dock);
// 鼠标点击产生的 focus 不能阻塞提示收起,只有键盘可见焦点才持续显示提示。
const showTooltip = !showLabel && (hovered || focused) && !command.disabled;
// scrollable 场景自定义 tooltip 会被 overflow 裁剪,用原生 title 兜底
const nativeTitle = !motionEnabled ? command.label : undefined;
if (showLabel) {
return (
<motion.span ref={ref} className="relative block h-8 shrink-0">
<motion.button
type="button"
aria-label={command.label}
aria-expanded={command.expands ? command.active || undefined : undefined}
aria-pressed={command.expands ? undefined : command.active || undefined}
disabled={command.disabled}
className={cn(
"aceternity-dock-command is-labeled group inline-flex h-8 items-center justify-center gap-1.5 whitespace-nowrap rounded-[var(--dock-item-radius)] border-0 px-2.5 outline-none",
command.active && "is-active",
command.danger && "is-danger",
)}
whileTap={!command.disabled ? { scale: 0.96 } : undefined}
transition={aceternityMotion.spring.dock}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
onClick={command.onClick}
>
<span className="grid size-3.5 shrink-0 place-items-center">{command.icon}</span>
<span className="inline-flex h-4 items-center text-[var(--fs-label)] font-medium leading-none">{command.displayLabel || command.label}</span>
{command.badge !== undefined ? <span className="aceternity-dock-command-badge inline-flex min-w-4 items-center justify-center rounded-full px-1 text-[var(--fs-micro)] font-bold leading-4">{command.badge}</span> : null}
</motion.button>
</motion.span>
);
}
return (
<motion.span ref={ref} className={cn("relative block shrink-0", command.wide && "min-w-[var(--dock-precision-width)]")} style={{ width: itemSize, height: itemSize }}>
{/* 放大项留在 Flex 流内,由布局推开邻项,保持 Aceternity Floating Dock 的空间关系。 */}
<motion.button
type="button"
aria-label={command.label}
title={nativeTitle}
aria-expanded={command.expands ? command.active || undefined : undefined}
aria-pressed={command.expands ? undefined : command.active || undefined}
disabled={command.disabled}
className={cn("aceternity-dock-command group relative grid size-full place-items-center rounded-[var(--dock-item-radius)] border outline-none", command.quiet && "is-quiet", command.active && "is-active", command.danger && "is-danger")}
whileTap={motionEnabled && !command.disabled ? { scale: 0.92 } : undefined}
transition={aceternityMotion.spring.dock}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onFocus={(event) => setFocused(event.currentTarget.matches(":focus-visible"))}
onBlur={() => setFocused(false)}
onMouseDown={() => setFocused(false)}
onClick={command.onClick}
>
<motion.span className={cn("grid place-items-center", command.wide && "w-full")} style={command.wide ? { height: iconSize } : { width: iconSize, height: iconSize }}>
{command.icon}
</motion.span>
<AnimatePresence>
{showTooltip ? (
<motion.span
initial={{ opacity: 0, y: 7, scale: 0.94 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 4, scale: 0.96, transition: { duration: 0 } }}
transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }}
className={cn(
"aceternity-dock-tooltip pointer-events-none absolute left-1/2 z-[var(--dock-tooltip-z)] -translate-x-1/2 whitespace-nowrap border font-medium shadow-xl backdrop-blur-xl",
compact ? "-top-7 rounded-md px-1.5 py-0.5 text-[var(--fs-micro)]" : "-top-8 rounded-md px-2 py-1 text-[var(--fs-tiny)]",
)}
>
{command.label}
</motion.span>
) : null}
</AnimatePresence>
</motion.button>
</motion.span>
);
}
function DockSwitch({ entry, compact, showLabel, motionEnabled, metrics }: { entry: FloatingDockSwitch; compact: boolean; showLabel: boolean; motionEnabled: boolean; metrics: DockMetrics }) {
const reducedMotion = useReducedMotion();
const [hoveredId, setHoveredId] = useState<string | null>(null);
const [focusedId, setFocusedId] = useState<string | null>(null);
const selectedIndex = Math.max(0, entry.options.findIndex((option) => option.value === entry.value));
const touch = metrics.base >= 40;
const labeled = showLabel || entry.options.some((option) => option.displayLabel);
const slot = labeled ? (touch ? 68 : compact ? 58 : 64) : touch ? 32 : compact ? 24 : 26;
const slotHeight = labeled ? metrics.base : slot;
const gap = labeled ? (touch ? 4 : 3) : touch ? 10 : compact ? 8 : 10;
const padX = labeled ? (touch ? 5 : 4) : touch ? 7 : compact ? 6 : 7;
return (
<span
role="radiogroup"
aria-label={entry.label}
className={cn("aceternity-dock-switch relative flex shrink-0 self-end items-center", labeled && "is-labeled")}
onKeyDown={(event) => {
if (event.key !== "ArrowRight" && event.key !== "ArrowLeft") return;
event.preventDefault();
const direction = event.key === "ArrowRight" ? 1 : -1;
const next = entry.options[(selectedIndex + direction + entry.options.length) % entry.options.length];
if (next) entry.onChange(next.value);
}}
>
<span
className="aceternity-dock-switch-track relative inline-flex items-center"
style={{ gap, padding: `${touch ? 4 : 3}px ${padX}px` }}
>
<motion.span
aria-hidden
className={cn("aceternity-dock-switch-thumb pointer-events-none absolute top-1/2", labeled ? "rounded-[var(--dock-item-radius)]" : "rounded-full")}
initial={false}
animate={{ x: selectedIndex * (slot + gap), y: "-50%" }}
transition={reducedMotion || !motionEnabled ? { duration: 0 } : aceternityMotion.spring.dock}
style={{ width: slot, height: slotHeight, left: padX }}
/>
{entry.options.map((option) => {
const checked = option.value === entry.value;
const showTooltip = !labeled && (hoveredId === option.id || focusedId === option.id);
return (
<span key={option.id} className="relative inline-flex shrink-0">
<button
type="button"
role="radio"
aria-checked={checked}
aria-label={option.label}
title={!motionEnabled ? option.label : undefined}
className={cn("aceternity-dock-switch-option relative z-[1] inline-flex items-center justify-center border-0 outline-none", labeled ? "gap-1 rounded-[var(--dock-item-radius)] px-2" : "rounded-full")}
style={{ width: slot, height: slotHeight }}
onMouseEnter={() => setHoveredId(option.id)}
onMouseLeave={() => setHoveredId((current) => current === option.id ? null : current)}
onFocus={(event) => {
if (event.currentTarget.matches(":focus-visible")) setFocusedId(option.id);
}}
onBlur={() => setFocusedId((current) => current === option.id ? null : current)}
onMouseDown={() => setFocusedId(null)}
onClick={() => {
if (!checked) entry.onChange(option.value);
}}
>
<span className="grid size-4 shrink-0 place-items-center">{option.icon}</span>
{labeled ? <span className="shrink-0 whitespace-nowrap text-[length:var(--fs-micro)] font-semibold leading-none">{option.displayLabel || option.label}</span> : null}
</button>
<AnimatePresence>
{showTooltip ? (
<motion.span
initial={{ opacity: 0, y: 7, scale: 0.94 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 4, scale: 0.96, transition: { duration: 0 } }}
transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }}
className={cn(
"aceternity-dock-tooltip pointer-events-none absolute left-1/2 z-[var(--dock-tooltip-z)] -translate-x-1/2 whitespace-nowrap border font-medium shadow-xl backdrop-blur-xl",
compact ? "-top-7 rounded-md px-1.5 py-0.5 text-[var(--fs-micro)]" : "-top-8 rounded-md px-2 py-1 text-[var(--fs-tiny)]",
)}
>
{option.label}
</motion.span>
) : null}
</AnimatePresence>
</span>
);
})}
</span>
</span>
);
}
function DockSeparator({ compact, labeled }: { compact: boolean; labeled: boolean }) {
return <span aria-hidden className={cn("aceternity-dock-separator shrink-0 self-center", labeled ? "mx-1.5 h-6 w-px" : compact ? "mx-0.5 mb-0.5 h-3.5 w-px" : "mx-0.5 mb-0.5 h-4 w-px")} />;
}
function proximitySize(distance: number, base: number, magnified: number, range: number, enabled: boolean) {
if (!enabled || !Number.isFinite(distance)) return base;
const proximity = 1 - Math.min(Math.abs(distance) / range, 1);
return base + (magnified - base) * proximity * proximity;
}