Files
ttstd-infinite-canvas/web/src/components/ui/aceternity/floating-dock.tsx

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;
};
// 桌面 dock 收紧常态与悬浮尺寸;触屏尺寸单独保留以保证点击目标。
const DOCK_METRICS: Record<NonNullable<FloatingDockProps["size"]>, 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<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-9 gap-0.5 rounded-[var(--dock-radius)] px-1.5 pb-0.5",
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}
data-icon-only
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;
}