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) => 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; }; // 桌面 dock 收紧常态与悬浮尺寸;触屏尺寸单独保留以保证点击目标。 const DOCK_METRICS: Record, DockMetrics> = { default: { base: 28, magnified: 34, icon: 14, iconMagnified: 16, 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, 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(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 ( { if (motionEnabled) mouseX.set(event.clientX); }} onPointerLeave={() => mouseX.set(Number.POSITIVE_INFINITY)} > {renderDockItems(items, { mouseX, metrics, motionEnabled: motionEnabled && !showLabels, compact: size === "compact", showLabel: showLabels })} ); }); type DockItemRenderProps = { mouseX: MotionValue; 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( {dangerGroup.map((command) => ( ))} , ); dangerGroup = []; }; for (const item of items) { if (item.kind === "separator") { flushDangerGroup(); result.push(); continue; } if (item.kind === "switch") { flushDangerGroup(); result.push(); index += 1; continue; } if (item.danger) { dangerGroup.push(item); index += 1; continue; } flushDangerGroup(); result.push(); index += 1; } flushDangerGroup(); return result; } function DockCommandButton({ command, mouseX, metrics, motionEnabled, compact, showLabel }: { command: FloatingDockCommand; mouseX: MotionValue; metrics: DockMetrics; motionEnabled: boolean; compact: boolean; showLabel: boolean }) { const ref = useRef(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 ( setHovered(true)} onMouseLeave={() => setHovered(false)} onFocus={() => setFocused(true)} onBlur={() => setFocused(false)} onClick={command.onClick} > {command.icon} {command.displayLabel || command.label} {command.badge !== undefined ? {command.badge} : null} ); } return ( {/* 放大项留在 Flex 流内,由布局推开邻项,保持 Aceternity Floating Dock 的空间关系。 */} setHovered(true)} onMouseLeave={() => setHovered(false)} onFocus={(event) => setFocused(event.currentTarget.matches(":focus-visible"))} onBlur={() => setFocused(false)} onMouseDown={() => setFocused(false)} onClick={command.onClick} > {command.icon} {showTooltip ? ( {command.label} ) : null} ); } function DockSwitch({ entry, compact, showLabel, motionEnabled, metrics }: { entry: FloatingDockSwitch; compact: boolean; showLabel: boolean; motionEnabled: boolean; metrics: DockMetrics }) { const reducedMotion = useReducedMotion(); const [hoveredId, setHoveredId] = useState(null); const [focusedId, setFocusedId] = useState(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 ( { 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); }} > {entry.options.map((option) => { const checked = option.value === entry.value; const showTooltip = !labeled && (hoveredId === option.id || focusedId === option.id); return ( {showTooltip ? ( {option.label} ) : null} ); })} ); } function DockSeparator({ compact, labeled }: { compact: boolean; labeled: boolean }) { return ; } 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; }