refactor(web): 界面工具 - 代码优化,替换重复的复制、打包、项目选择和主题切换实现
删除未再引用的文字扫光组件。复制提示、压缩包读写、项目多选和主题揭示的对外行为保持不变。
This commit is contained in:
@@ -1,219 +1,222 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState, type ComponentPropsWithoutRef } from "react";
|
||||||
import { Moon, Sun } from "lucide-react";
|
|
||||||
import { flushSync } from "react-dom";
|
import { flushSync } from "react-dom";
|
||||||
|
import { Moon, Sun } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export type TransitionVariant = "circle" | "square" | "triangle" | "diamond" | "hexagon" | "rectangle" | "star";
|
export type TransitionVariant = "circle" | "square" | "triangle" | "diamond" | "hexagon" | "rectangle" | "star";
|
||||||
|
|
||||||
interface AnimatedThemeTogglerProps extends React.ComponentPropsWithoutRef<"button"> {
|
type ThemeName = "light" | "dark";
|
||||||
|
type Point = { x: number; y: number };
|
||||||
|
type ThemeButtonProps = ComponentPropsWithoutRef<"button">;
|
||||||
|
|
||||||
|
interface AnimatedThemeTogglerProps extends ThemeButtonProps {
|
||||||
duration?: number;
|
duration?: number;
|
||||||
variant?: TransitionVariant;
|
variant?: TransitionVariant;
|
||||||
/** When true, the transition expands from the viewport center instead of the button center. */
|
|
||||||
fromCenter?: boolean;
|
fromCenter?: boolean;
|
||||||
theme?: "light" | "dark";
|
theme?: ThemeName;
|
||||||
targetTheme?: "light" | "dark";
|
targetTheme?: ThemeName;
|
||||||
onThemeChange?: (theme: "light" | "dark") => void;
|
onThemeChange?: (theme: ThemeName) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let themeTransitionInFlight = false;
|
let revealLocked = false;
|
||||||
let themeClipAnimation: Animation | null = null;
|
let revealAnimation: Animation | null = null;
|
||||||
|
|
||||||
function polygonCollapsed(cx: number, cy: number, vertexCount: number): string {
|
function readTheme(): ThemeName {
|
||||||
const pairs = Array.from({ length: vertexCount }, () => `${cx}px ${cy}px`).join(", ");
|
return document.documentElement.classList.contains("dark") ? "dark" : "light";
|
||||||
return `polygon(${pairs})`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getThemeTransitionClipPaths(variant: TransitionVariant, cx: number, cy: number, maxRadius: number, viewportWidth: number, viewportHeight: number): [string, string] {
|
function oppositeTheme(theme: ThemeName): ThemeName {
|
||||||
switch (variant) {
|
return theme === "dark" ? "light" : "dark";
|
||||||
case "circle":
|
}
|
||||||
return [`circle(0px at ${cx}px ${cy}px)`, `circle(${maxRadius}px at ${cx}px ${cy}px)`];
|
|
||||||
case "square": {
|
function originPoint(button: HTMLButtonElement, fromCenter: boolean): Point {
|
||||||
const halfW = Math.max(cx, viewportWidth - cx);
|
const viewportWidth = window.visualViewport?.width ?? window.innerWidth;
|
||||||
const halfH = Math.max(cy, viewportHeight - cy);
|
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
||||||
const halfSide = Math.max(halfW, halfH) * 1.05;
|
if (fromCenter) return { x: viewportWidth / 2, y: viewportHeight / 2 };
|
||||||
const end = [`${cx - halfSide}px ${cy - halfSide}px`, `${cx + halfSide}px ${cy - halfSide}px`, `${cx + halfSide}px ${cy + halfSide}px`, `${cx - halfSide}px ${cy + halfSide}px`].join(", ");
|
const bounds = button.getBoundingClientRect();
|
||||||
return [polygonCollapsed(cx, cy, 4), `polygon(${end})`];
|
return { x: bounds.left + bounds.width / 2, y: bounds.top + bounds.height / 2 };
|
||||||
}
|
}
|
||||||
case "triangle": {
|
|
||||||
const scale = maxRadius * 2.2;
|
function reach(point: Point): number {
|
||||||
const dx = (Math.sqrt(3) / 2) * scale;
|
const viewportWidth = window.visualViewport?.width ?? window.innerWidth;
|
||||||
const verts = [`${cx}px ${cy - scale}px`, `${cx + dx}px ${cy + 0.5 * scale}px`, `${cx - dx}px ${cy + 0.5 * scale}px`].join(", ");
|
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
||||||
return [polygonCollapsed(cx, cy, 3), `polygon(${verts})`];
|
return Math.hypot(Math.max(point.x, viewportWidth - point.x), Math.max(point.y, viewportHeight - point.y));
|
||||||
}
|
}
|
||||||
case "diamond": {
|
|
||||||
// Slightly larger than the view-transition circle radius so axis-aligned coverage matches the circle reveal.
|
function collapsed(point: Point, count: number): string {
|
||||||
const R = maxRadius * Math.SQRT2;
|
const vertex = `${point.x}px ${point.y}px`;
|
||||||
const end = [`${cx}px ${cy - R}px`, `${cx + R}px ${cy}px`, `${cx}px ${cy + R}px`, `${cx - R}px ${cy}px`].join(", ");
|
return `polygon(${Array.from({ length: count }, () => vertex).join(", ")})`;
|
||||||
return [polygonCollapsed(cx, cy, 4), `polygon(${end})`];
|
}
|
||||||
}
|
|
||||||
case "hexagon": {
|
function polygonAt(point: Point, vertices: Point[]): string {
|
||||||
const R = maxRadius * Math.SQRT2;
|
return `polygon(${vertices.map((vertex) => `${vertex.x}px ${vertex.y}px`).join(", ")})`;
|
||||||
const verts: string[] = [];
|
}
|
||||||
for (let i = 0; i < 6; i++) {
|
|
||||||
const a = -Math.PI / 2 + (i * Math.PI) / 3;
|
function regularVertices(point: Point, radius: number, count: number, rotation = -Math.PI / 2): Point[] {
|
||||||
verts.push(`${cx + R * Math.cos(a)}px ${cy + R * Math.sin(a)}px`);
|
return Array.from({ length: count }, (_, index) => {
|
||||||
}
|
const angle = rotation + (index * Math.PI * 2) / count;
|
||||||
return [polygonCollapsed(cx, cy, 6), `polygon(${verts.join(", ")})`];
|
return { x: point.x + radius * Math.cos(angle), y: point.y + radius * Math.sin(angle) };
|
||||||
}
|
});
|
||||||
case "rectangle": {
|
}
|
||||||
const halfW = Math.max(cx, viewportWidth - cx);
|
|
||||||
const halfH = Math.max(cy, viewportHeight - cy);
|
function starVertices(point: Point, radius: number): Point[] {
|
||||||
const end = [`${cx - halfW}px ${cy - halfH}px`, `${cx + halfW}px ${cy - halfH}px`, `${cx + halfW}px ${cy + halfH}px`, `${cx - halfW}px ${cy + halfH}px`].join(", ");
|
const inner = radius * 0.42;
|
||||||
return [polygonCollapsed(cx, cy, 4), `polygon(${end})`];
|
return Array.from({ length: 5 }, (_, index) => {
|
||||||
}
|
const outerAngle = -Math.PI / 2 + (index * Math.PI * 2) / 5;
|
||||||
case "star": {
|
const innerAngle = outerAngle + Math.PI / 5;
|
||||||
// Small overscan so the last frames never leave a 1px seam before the transition group ends.
|
return [
|
||||||
const R = maxRadius * Math.SQRT2 * 1.03;
|
{ x: point.x + radius * Math.cos(outerAngle), y: point.y + radius * Math.sin(outerAngle) },
|
||||||
const innerRatio = 0.42;
|
{ x: point.x + inner * Math.cos(innerAngle), y: point.y + inner * Math.sin(innerAngle) },
|
||||||
const starPolygon = (radius: number) => {
|
];
|
||||||
const verts: string[] = [];
|
}).flat();
|
||||||
for (let i = 0; i < 5; i++) {
|
}
|
||||||
const outerA = -Math.PI / 2 + (i * 2 * Math.PI) / 5;
|
|
||||||
verts.push(`${cx + radius * Math.cos(outerA)}px ${cy + radius * Math.sin(outerA)}px`);
|
function revealFrames(variant: TransitionVariant, point: Point, radius: number): [string, string] {
|
||||||
const innerA = outerA + Math.PI / 5;
|
const viewportWidth = window.visualViewport?.width ?? window.innerWidth;
|
||||||
verts.push(`${cx + radius * innerRatio * Math.cos(innerA)}px ${cy + radius * innerRatio * Math.sin(innerA)}px`);
|
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
||||||
}
|
if (variant === "circle") {
|
||||||
return `polygon(${verts.join(", ")})`;
|
return [`circle(0px at ${point.x}px ${point.y}px)`, `circle(${radius}px at ${point.x}px ${point.y}px)`];
|
||||||
};
|
|
||||||
const startR = Math.max(2, R * 0.025);
|
|
||||||
return [starPolygon(startR), starPolygon(R)];
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return [`circle(0px at ${cx}px ${cy}px)`, `circle(${maxRadius}px at ${cx}px ${cy}px)`];
|
|
||||||
}
|
}
|
||||||
|
if (variant === "square" || variant === "rectangle") {
|
||||||
|
const halfWidth = Math.max(point.x, viewportWidth - point.x);
|
||||||
|
const halfHeight = Math.max(point.y, viewportHeight - point.y);
|
||||||
|
const side = variant === "square" ? Math.max(halfWidth, halfHeight) * 1.05 : 0;
|
||||||
|
const spanX = variant === "square" ? side : halfWidth;
|
||||||
|
const spanY = variant === "square" ? side : halfHeight;
|
||||||
|
const end = [
|
||||||
|
{ x: point.x - spanX, y: point.y - spanY },
|
||||||
|
{ x: point.x + spanX, y: point.y - spanY },
|
||||||
|
{ x: point.x + spanX, y: point.y + spanY },
|
||||||
|
{ x: point.x - spanX, y: point.y + spanY },
|
||||||
|
];
|
||||||
|
return [collapsed(point, 4), polygonAt(point, end)];
|
||||||
|
}
|
||||||
|
if (variant === "triangle") {
|
||||||
|
const scale = radius * 2.2;
|
||||||
|
const dx = (Math.sqrt(3) / 2) * scale;
|
||||||
|
const end = [
|
||||||
|
{ x: point.x, y: point.y - scale },
|
||||||
|
{ x: point.x + dx, y: point.y + scale / 2 },
|
||||||
|
{ x: point.x - dx, y: point.y + scale / 2 },
|
||||||
|
];
|
||||||
|
return [collapsed(point, 3), polygonAt(point, end)];
|
||||||
|
}
|
||||||
|
if (variant === "diamond") {
|
||||||
|
const side = radius * Math.SQRT2;
|
||||||
|
return [collapsed(point, 4), polygonAt(point, regularVertices(point, side, 4))];
|
||||||
|
}
|
||||||
|
if (variant === "hexagon") {
|
||||||
|
const side = radius * Math.SQRT2;
|
||||||
|
return [collapsed(point, 6), polygonAt(point, regularVertices(point, side, 6))];
|
||||||
|
}
|
||||||
|
const outer = radius * Math.SQRT2 * 1.03;
|
||||||
|
return [polygonAt(point, starVertices(point, Math.max(2, outer * 0.025))), polygonAt(point, starVertices(point, outer))];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AnimatedThemeToggler = ({ children, className, duration = 400, variant, fromCenter = false, theme, targetTheme, onThemeChange, ...props }: AnimatedThemeTogglerProps) => {
|
function paintTheme(next: ThemeName, onThemeChange?: (theme: ThemeName) => void) {
|
||||||
const shape = variant ?? "circle";
|
document.documentElement.classList.toggle("dark", next === "dark");
|
||||||
const [isDark, setIsDark] = useState(false);
|
document.documentElement.style.colorScheme = next;
|
||||||
|
onThemeChange?.(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function revealImmediately(next: ThemeName, onThemeChange?: (theme: ThemeName) => void) {
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.dataset.themeSwitching = "true";
|
||||||
|
flushSync(() => paintTheme(next, onThemeChange));
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
delete root.dataset.themeSwitching;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AnimatedThemeToggler = ({ children, className, duration = 400, variant = "circle", fromCenter = false, theme, targetTheme, onThemeChange, ...buttonProps }: AnimatedThemeTogglerProps) => {
|
||||||
|
const [dark, setDark] = useState(false);
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (theme) {
|
if (theme) {
|
||||||
setIsDark(theme === "dark");
|
setDark(theme === "dark");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const sync = () => setDark(readTheme() === "dark");
|
||||||
const updateTheme = () => {
|
sync();
|
||||||
setIsDark(document.documentElement.classList.contains("dark"));
|
const observer = new MutationObserver(sync);
|
||||||
};
|
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
|
||||||
|
|
||||||
updateTheme();
|
|
||||||
|
|
||||||
const observer = new MutationObserver(updateTheme);
|
|
||||||
observer.observe(document.documentElement, {
|
|
||||||
attributes: true,
|
|
||||||
attributeFilter: ["class"],
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
const toggleTheme = useCallback(() => {
|
const changeTheme = useCallback(() => {
|
||||||
const button = buttonRef.current;
|
const button = buttonRef.current;
|
||||||
if (!button) return;
|
if (!button) return;
|
||||||
const currentTheme = document.documentElement.classList.contains("dark") ? "dark" : "light";
|
const current = readTheme();
|
||||||
const nextTheme = targetTheme ?? (currentTheme === "dark" ? "light" : "dark");
|
const next = targetTheme ?? oppositeTheme(current);
|
||||||
if (nextTheme === currentTheme) return;
|
if (next === current) return;
|
||||||
|
|
||||||
const viewportWidth = window.visualViewport?.width ?? window.innerWidth;
|
|
||||||
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
|
||||||
|
|
||||||
let x: number;
|
|
||||||
let y: number;
|
|
||||||
if (fromCenter) {
|
|
||||||
x = viewportWidth / 2;
|
|
||||||
y = viewportHeight / 2;
|
|
||||||
} else {
|
|
||||||
const { top, left, width, height } = button.getBoundingClientRect();
|
|
||||||
x = left + width / 2;
|
|
||||||
y = top + height / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxRadius = Math.hypot(Math.max(x, viewportWidth - x), Math.max(y, viewportHeight - y));
|
|
||||||
|
|
||||||
const applyTheme = () => {
|
|
||||||
setIsDark(nextTheme === "dark");
|
|
||||||
document.documentElement.classList.toggle("dark", nextTheme === "dark");
|
|
||||||
document.documentElement.style.colorScheme = nextTheme;
|
|
||||||
onThemeChange?.(nextTheme);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
const point = originPoint(button, fromCenter);
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const canvasMounted = Boolean(document.querySelector("[data-canvas-world-layer]"));
|
const canvasOpen = root.querySelector("[data-canvas-world-layer]") !== null;
|
||||||
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
if (canvasMounted || reducedMotion || typeof document.startViewTransition !== "function") {
|
const canReveal = typeof document.startViewTransition === "function";
|
||||||
root.dataset.themeSwitching = "true";
|
if (canvasOpen || reducedMotion || !canReveal) {
|
||||||
flushSync(applyTheme);
|
setDark(next === "dark");
|
||||||
requestAnimationFrame(() => delete root.dataset.themeSwitching);
|
revealImmediately(next, onThemeChange);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (themeTransitionInFlight) return;
|
if (revealLocked) return;
|
||||||
themeTransitionInFlight = true;
|
|
||||||
|
|
||||||
const clipPath = getThemeTransitionClipPaths(shape, x, y, maxRadius, viewportWidth, viewportHeight);
|
|
||||||
|
|
||||||
|
revealLocked = true;
|
||||||
|
const frames = revealFrames(variant, point, reach(point));
|
||||||
root.dataset.magicuiThemeVt = "active";
|
root.dataset.magicuiThemeVt = "active";
|
||||||
root.style.setProperty("--magicui-theme-toggle-vt-duration", `${duration}ms`);
|
root.style.setProperty("--magicui-theme-toggle-vt-duration", `${duration}ms`);
|
||||||
// Pin the collapsed clip-path via CSS so Firefox does not paint the new
|
root.style.setProperty("--magicui-theme-vt-clip-from", frames[0]);
|
||||||
// theme unclipped between snapshot and the ready.then() JS animation.
|
let closed = false;
|
||||||
root.style.setProperty("--magicui-theme-vt-clip-from", clipPath[0]);
|
const finish = () => {
|
||||||
let cleaned = false;
|
if (closed) return;
|
||||||
const cleanup = () => {
|
closed = true;
|
||||||
if (cleaned) return;
|
revealAnimation?.cancel();
|
||||||
cleaned = true;
|
revealAnimation = null;
|
||||||
themeClipAnimation?.cancel();
|
revealLocked = false;
|
||||||
themeClipAnimation = null;
|
|
||||||
themeTransitionInFlight = false;
|
|
||||||
delete root.dataset.magicuiThemeVt;
|
delete root.dataset.magicuiThemeVt;
|
||||||
root.style.removeProperty("--magicui-theme-toggle-vt-duration");
|
root.style.removeProperty("--magicui-theme-toggle-vt-duration");
|
||||||
root.style.removeProperty("--magicui-theme-vt-clip-from");
|
root.style.removeProperty("--magicui-theme-vt-clip-from");
|
||||||
};
|
};
|
||||||
|
|
||||||
let transition: ReturnType<NonNullable<typeof document.startViewTransition>>;
|
let transition: ViewTransition;
|
||||||
try {
|
try {
|
||||||
transition = document.startViewTransition(() => {
|
transition = document.startViewTransition(() => {
|
||||||
flushSync(applyTheme);
|
flushSync(() => {
|
||||||
|
setDark(next === "dark");
|
||||||
|
paintTheme(next, onThemeChange);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
cleanup();
|
finish();
|
||||||
root.dataset.themeSwitching = "true";
|
setDark(next === "dark");
|
||||||
flushSync(applyTheme);
|
revealImmediately(next, onThemeChange);
|
||||||
requestAnimationFrame(() => delete root.dataset.themeSwitching);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (typeof transition?.finished?.finally === "function") {
|
|
||||||
void transition.finished.then(cleanup, cleanup);
|
|
||||||
} else {
|
|
||||||
cleanup();
|
|
||||||
}
|
|
||||||
|
|
||||||
const ready = transition?.ready;
|
void transition.finished.then(finish, finish);
|
||||||
if (ready && typeof ready.then === "function") {
|
void transition.ready
|
||||||
void ready.then(() => {
|
.then(() => {
|
||||||
if (cleaned) return;
|
if (closed) return;
|
||||||
themeClipAnimation?.cancel();
|
revealAnimation = root.animate(
|
||||||
themeClipAnimation = document.documentElement.animate(
|
{ clipPath: frames },
|
||||||
{
|
|
||||||
clipPath,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
duration,
|
duration,
|
||||||
// Star: linear avoids easing overshoot that fights polygon interpolation at t→1; VT group duration is synced above.
|
easing: variant === "star" ? "linear" : "ease-in-out",
|
||||||
easing: shape === "star" ? "linear" : "ease-in-out",
|
|
||||||
fill: "forwards",
|
fill: "forwards",
|
||||||
pseudoElement: "::view-transition-new(root)",
|
pseudoElement: "::view-transition-new(root)",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}).catch(() => undefined);
|
})
|
||||||
}
|
.catch(() => undefined);
|
||||||
}, [shape, fromCenter, duration, targetTheme, onThemeChange]);
|
}, [duration, fromCenter, onThemeChange, targetTheme, variant]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button type="button" ref={buttonRef} onClick={toggleTheme} className={cn(className)} {...props}>
|
<button type="button" ref={buttonRef} onClick={changeTheme} className={cn(className)} {...buttonProps}>
|
||||||
{children ?? (isDark ? <Sun /> : <Moon />)}
|
{children ?? (dark ? <Sun /> : <Moon />)}
|
||||||
<span className="sr-only">{props["aria-label"] || "切换主题"}</span>
|
<span className="sr-only">{buttonProps["aria-label"] || "切换主题"}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,226 +0,0 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { animate, motion, useInView, useMotionValue, useReducedMotion, useTransform, type HTMLMotionProps } from "motion/react";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const DEFAULT_COLORS = ["#c679c4", "#fa3d1d", "#ffb005", "#e1e1fe", "#0358f7"];
|
|
||||||
const BAND_HALF = 17;
|
|
||||||
const SWEEP_START = -BAND_HALF;
|
|
||||||
const SWEEP_END = 100 + BAND_HALF;
|
|
||||||
|
|
||||||
const sweepEase = (t: number) => (t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2);
|
|
||||||
|
|
||||||
function buildGradient(pos: number, colors: string[], textColor: string) {
|
|
||||||
const bandStart = pos - BAND_HALF;
|
|
||||||
const bandEnd = pos + BAND_HALF;
|
|
||||||
|
|
||||||
if (bandStart >= 100) {
|
|
||||||
return `linear-gradient(90deg, ${textColor}, ${textColor})`;
|
|
||||||
}
|
|
||||||
const n = colors.length;
|
|
||||||
const parts: string[] = [];
|
|
||||||
|
|
||||||
if (bandStart > 0) parts.push(`${textColor} 0%`, `${textColor} ${bandStart.toFixed(2)}%`);
|
|
||||||
|
|
||||||
colors.forEach((c, i) => {
|
|
||||||
const pct = n === 1 ? pos : bandStart + (i / (n - 1)) * BAND_HALF * 2;
|
|
||||||
parts.push(`${c} ${pct.toFixed(2)}%`);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (bandEnd < 100) parts.push(`transparent ${bandEnd.toFixed(2)}%`, `transparent 100%`);
|
|
||||||
|
|
||||||
return `linear-gradient(90deg, ${parts.join(", ")})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function measureWidths(el: HTMLElement, texts: string[]) {
|
|
||||||
const ghost = el.cloneNode() as HTMLElement;
|
|
||||||
Object.assign(ghost.style, {
|
|
||||||
position: "absolute",
|
|
||||||
visibility: "hidden",
|
|
||||||
pointerEvents: "none",
|
|
||||||
width: "auto",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
});
|
|
||||||
el.parentElement!.appendChild(ghost);
|
|
||||||
const widths = texts.map((t) => {
|
|
||||||
ghost.textContent = t;
|
|
||||||
return ghost.getBoundingClientRect().width;
|
|
||||||
});
|
|
||||||
ghost.remove();
|
|
||||||
return widths;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Props for {@link DiaTextReveal}.
|
|
||||||
*/
|
|
||||||
export interface DiaTextRevealProps extends Omit<HTMLMotionProps<"span">, "ref" | "children" | "style" | "animate" | "transition" | "color"> {
|
|
||||||
/**
|
|
||||||
* Text to reveal. Pass multiple strings to rotate when {@link DiaTextRevealProps.repeat} is `true`.
|
|
||||||
*/
|
|
||||||
text: string | string[];
|
|
||||||
/**
|
|
||||||
* Colors sampled across the moving gradient band. Defaults to a built-in palette.
|
|
||||||
*/
|
|
||||||
colors?: string[];
|
|
||||||
/**
|
|
||||||
* CSS color for revealed text after the sweep and for leading/trailing regions during the animation.
|
|
||||||
* @defaultValue `"var(--foreground)"`
|
|
||||||
*/
|
|
||||||
textColor?: string;
|
|
||||||
/**
|
|
||||||
* Duration of one sweep pass, in seconds.
|
|
||||||
* @defaultValue `1.5`
|
|
||||||
*/
|
|
||||||
duration?: number;
|
|
||||||
/**
|
|
||||||
* Delay before the sweep starts, in seconds.
|
|
||||||
* @defaultValue `0`
|
|
||||||
*/
|
|
||||||
delay?: number;
|
|
||||||
/**
|
|
||||||
* When `text` is an array, replay the sweep and advance to the next string after each completion.
|
|
||||||
* @defaultValue `false`
|
|
||||||
*/
|
|
||||||
repeat?: boolean;
|
|
||||||
/**
|
|
||||||
* Pause between cycles when {@link DiaTextRevealProps.repeat} is `true`, in seconds.
|
|
||||||
* @defaultValue `0.5`
|
|
||||||
*/
|
|
||||||
repeatDelay?: number;
|
|
||||||
/**
|
|
||||||
* If `true`, the animation starts only after the element enters the viewport.
|
|
||||||
* @defaultValue `true`
|
|
||||||
*/
|
|
||||||
startOnView?: boolean;
|
|
||||||
/**
|
|
||||||
* Passed to `useInView`: if `true`, in-view detection fires at most once (no replay on scroll-back).
|
|
||||||
* @defaultValue `true`
|
|
||||||
*/
|
|
||||||
once?: boolean;
|
|
||||||
/**
|
|
||||||
* Additional class names for the animated `span` (e.g. typography utilities).
|
|
||||||
*/
|
|
||||||
className?: string;
|
|
||||||
/**
|
|
||||||
* When `text` has multiple entries, use the widest string’s width for layout instead of animating width per line.
|
|
||||||
* @defaultValue `false`
|
|
||||||
*/
|
|
||||||
fixedWidth?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DiaTextReveal({ text, colors = DEFAULT_COLORS, textColor = "var(--foreground)", duration = 1.5, delay = 0, repeat = false, repeatDelay = 0.5, startOnView = true, once = true, className, fixedWidth = false, ...props }: DiaTextRevealProps) {
|
|
||||||
const texts = Array.isArray(text) ? text : [text];
|
|
||||||
const isMulti = texts.length > 1;
|
|
||||||
const prefersReducedMotion = useReducedMotion();
|
|
||||||
|
|
||||||
const spanRef = useRef<HTMLSpanElement>(null);
|
|
||||||
const optsRef = useRef({
|
|
||||||
colors,
|
|
||||||
textColor,
|
|
||||||
duration,
|
|
||||||
delay,
|
|
||||||
repeat,
|
|
||||||
repeatDelay,
|
|
||||||
texts,
|
|
||||||
});
|
|
||||||
optsRef.current = {
|
|
||||||
colors,
|
|
||||||
textColor,
|
|
||||||
duration,
|
|
||||||
delay,
|
|
||||||
repeat,
|
|
||||||
repeatDelay,
|
|
||||||
texts,
|
|
||||||
};
|
|
||||||
|
|
||||||
const indexRef = useRef(0);
|
|
||||||
const hasPlayedRef = useRef(false);
|
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
||||||
const playRef = useRef<() => void>(null!);
|
|
||||||
const stopRef = useRef<(() => void) | null>(null);
|
|
||||||
|
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
|
||||||
const [measuredWidths, setMeasuredWidths] = useState<number[]>([]);
|
|
||||||
|
|
||||||
const sweepPos = useMotionValue(SWEEP_START);
|
|
||||||
|
|
||||||
const backgroundImage = useTransform(sweepPos, (pos) => buildGradient(pos, optsRef.current.colors, optsRef.current.textColor));
|
|
||||||
|
|
||||||
const isInView = useInView(spanRef, { once, amount: 0.1 });
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const el = spanRef.current;
|
|
||||||
if (!el || !isMulti) return;
|
|
||||||
setMeasuredWidths(measureWidths(el, texts));
|
|
||||||
}, [Array.isArray(text) ? text.join("\0") : text]);
|
|
||||||
|
|
||||||
playRef.current = () => {
|
|
||||||
const { duration, delay, repeat, repeatDelay, texts } = optsRef.current;
|
|
||||||
|
|
||||||
sweepPos.set(SWEEP_START);
|
|
||||||
|
|
||||||
const controls = animate(sweepPos, SWEEP_END, {
|
|
||||||
duration,
|
|
||||||
delay,
|
|
||||||
ease: sweepEase,
|
|
||||||
onComplete() {
|
|
||||||
if (!repeat) return;
|
|
||||||
timerRef.current = setTimeout(() => {
|
|
||||||
const next = (indexRef.current + 1) % texts.length;
|
|
||||||
indexRef.current = next;
|
|
||||||
setActiveIndex(next);
|
|
||||||
playRef.current();
|
|
||||||
}, repeatDelay * 1000);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
stopRef.current = () => controls.stop();
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (prefersReducedMotion) {
|
|
||||||
sweepPos.set(SWEEP_END);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (startOnView && !isInView) return;
|
|
||||||
if (once && hasPlayedRef.current) return;
|
|
||||||
hasPlayedRef.current = true;
|
|
||||||
playRef.current();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
stopRef.current?.();
|
|
||||||
clearTimeout(timerRef.current);
|
|
||||||
};
|
|
||||||
}, [isInView, startOnView, once, prefersReducedMotion, sweepPos]);
|
|
||||||
|
|
||||||
const fixedW = isMulti && fixedWidth && measuredWidths.length > 0 ? Math.max(...measuredWidths) : undefined;
|
|
||||||
|
|
||||||
const animatedW = isMulti && !fixedWidth && measuredWidths[activeIndex] != null ? measuredWidths[activeIndex] : undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.span
|
|
||||||
ref={spanRef}
|
|
||||||
className={cn("align-bottom leading-[100%] text-inherit", className)}
|
|
||||||
style={{
|
|
||||||
transform: "translateY(-2px)",
|
|
||||||
color: "transparent",
|
|
||||||
backgroundClip: "text",
|
|
||||||
WebkitBackgroundClip: "text",
|
|
||||||
backgroundSize: "100% 100%",
|
|
||||||
backgroundImage,
|
|
||||||
...(isMulti && {
|
|
||||||
display: "inline-block",
|
|
||||||
overflow: "hidden",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
verticalAlign: "text-center",
|
|
||||||
...(fixedW != null && { width: fixedW }),
|
|
||||||
}),
|
|
||||||
}}
|
|
||||||
animate={animatedW != null ? { width: animatedW } : undefined}
|
|
||||||
transition={{ duration: 0.4, ease: [0.4, 0, 0.2, 1] }}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{texts[activeIndex]}
|
|
||||||
</motion.span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { App } from "antd";
|
|
||||||
import copy from "copy-to-clipboard";
|
import copy from "copy-to-clipboard";
|
||||||
|
import { App } from "antd";
|
||||||
|
|
||||||
|
const COPIED_HINT = "已复制";
|
||||||
|
|
||||||
export function useCopyText() {
|
export function useCopyText() {
|
||||||
const { message } = App.useApp();
|
const feedback = App.useApp().message;
|
||||||
|
return (value: string, successText = COPIED_HINT) => {
|
||||||
return (value: string, successText = "已复制") => {
|
copy(String(value));
|
||||||
copy(value);
|
feedback.success(successText);
|
||||||
message.success(successText);
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { clsx, type ClassValue } from "clsx";
|
import { type ClassValue, clsx } from "clsx";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
function joinClassNames(parts: ClassValue[]): string {
|
||||||
return twMerge(clsx(inputs));
|
const resolved = twMerge(clsx(parts));
|
||||||
|
return resolved.length > 0 ? resolved : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cn(...values: ClassValue[]): string {
|
||||||
|
return joinClassNames(values);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,26 @@
|
|||||||
import { unzipSync, zipSync } from "fflate";
|
import { unzipSync as unpack, zipSync as pack } from "fflate";
|
||||||
|
|
||||||
type ZipFile = {
|
type ZipSource = BlobPart;
|
||||||
name: string;
|
|
||||||
data: BlobPart;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function createZip(files: ZipFile[]) {
|
async function toBytes(data: ZipSource): Promise<Uint8Array> {
|
||||||
const entries = await Promise.all(
|
const buffer = await new Blob([data]).arrayBuffer();
|
||||||
files.map(async (file) => {
|
return new Uint8Array(buffer);
|
||||||
const data = new Uint8Array(await new Blob([file.data]).arrayBuffer());
|
|
||||||
return [file.name, data] as const;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return new Blob([zipSync(Object.fromEntries(entries), { level: 0 })], { type: "application/zip" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readZip(file: Blob) {
|
export async function createZip(files: Array<{ name: string; data: ZipSource }>): Promise<Blob> {
|
||||||
const entries = unzipSync(new Uint8Array(await file.arrayBuffer()));
|
const entries: Record<string, Uint8Array> = {};
|
||||||
return new Map(Object.entries(entries).map(([name, data]) => [name, new Blob([data])]));
|
for (const file of files) {
|
||||||
|
entries[file.name] = await toBytes(file.data);
|
||||||
|
}
|
||||||
|
const archive = pack(entries, { level: 0 });
|
||||||
|
return new Blob([archive], { type: "application/zip" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readZip(file: Blob): Promise<Map<string, Blob>> {
|
||||||
|
const opened = unpack(await toBytes(file));
|
||||||
|
const extracted = new Map<string, Blob>();
|
||||||
|
for (const [name, bytes] of Object.entries(opened)) {
|
||||||
|
extracted.set(name, new Blob([bytes]));
|
||||||
|
}
|
||||||
|
return extracted;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
|
||||||
type CanvasUiStore = {
|
type ProjectId = string;
|
||||||
editingProjectId: string | null;
|
type ProjectTitle = string;
|
||||||
editingProjectTitle: string;
|
type ProjectIdList = string[];
|
||||||
selectedProjectIds: string[];
|
|
||||||
deleteProjectIds: string[];
|
type ProjectUiState = {
|
||||||
|
editingProjectId: ProjectId | null;
|
||||||
|
editingProjectTitle: ProjectTitle;
|
||||||
|
selectedProjectIds: ProjectIdList;
|
||||||
|
deleteProjectIds: ProjectIdList;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CanvasUiStore = ProjectUiState & {
|
||||||
startEditingProject: (id: string, title: string) => void;
|
startEditingProject: (id: string, title: string) => void;
|
||||||
setEditingProjectTitle: (title: string) => void;
|
setEditingProjectTitle: (title: string) => void;
|
||||||
stopEditingProject: () => void;
|
stopEditingProject: () => void;
|
||||||
@@ -13,15 +20,42 @@ type CanvasUiStore = {
|
|||||||
removeSelectedProjectIds: (ids: string[]) => void;
|
removeSelectedProjectIds: (ids: string[]) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useCanvasUiStore = create<CanvasUiStore>((set) => ({
|
const emptyProjectUi: ProjectUiState = {
|
||||||
editingProjectId: null,
|
editingProjectId: null,
|
||||||
editingProjectTitle: "",
|
editingProjectTitle: "",
|
||||||
selectedProjectIds: [],
|
selectedProjectIds: [] as ProjectIdList,
|
||||||
deleteProjectIds: [],
|
deleteProjectIds: [] as ProjectIdList,
|
||||||
startEditingProject: (editingProjectId, editingProjectTitle) => set({ editingProjectId, editingProjectTitle }),
|
};
|
||||||
setEditingProjectTitle: (editingProjectTitle) => set({ editingProjectTitle }),
|
|
||||||
stopEditingProject: () => set({ editingProjectId: null }),
|
function selectProject(ids: readonly string[], id: string, selected: boolean): string[] {
|
||||||
toggleSelectedProjectId: (id, selected) => set((state) => ({ selectedProjectIds: selected ? [...new Set([...state.selectedProjectIds, id])] : state.selectedProjectIds.filter((item) => item !== id) })),
|
if (!selected) return ids.filter((item) => item !== id);
|
||||||
setDeleteProjectIds: (deleteProjectIds) => set({ deleteProjectIds }),
|
if (ids.includes(id)) return [...ids];
|
||||||
removeSelectedProjectIds: (ids) => set((state) => ({ selectedProjectIds: state.selectedProjectIds.filter((id) => !ids.includes(id)) })),
|
return [...ids, id];
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropProjects(ids: readonly string[], removed: readonly string[]): string[] {
|
||||||
|
const blocked = new Set(removed);
|
||||||
|
return ids.filter((id) => !blocked.has(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCanvasUiStore = create<CanvasUiStore>((set) => ({
|
||||||
|
...emptyProjectUi,
|
||||||
|
startEditingProject: (id, title) => {
|
||||||
|
set({ editingProjectId: id, editingProjectTitle: title });
|
||||||
|
},
|
||||||
|
setEditingProjectTitle: (title) => {
|
||||||
|
set({ editingProjectTitle: title });
|
||||||
|
},
|
||||||
|
stopEditingProject: () => {
|
||||||
|
set({ editingProjectId: null });
|
||||||
|
},
|
||||||
|
toggleSelectedProjectId: (id, selected) => {
|
||||||
|
set((state) => ({ selectedProjectIds: selectProject(state.selectedProjectIds, id, selected) }));
|
||||||
|
},
|
||||||
|
setDeleteProjectIds: (ids) => {
|
||||||
|
set({ deleteProjectIds: ids });
|
||||||
|
},
|
||||||
|
removeSelectedProjectIds: (ids) => {
|
||||||
|
set((state) => ({ selectedProjectIds: dropProjects(state.selectedProjectIds, ids) }));
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
17
web/test/m9-clean-room-ui.test.ts
Normal file
17
web/test/m9-clean-room-ui.test.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
describe("M9 clean-room utility contract", () => {
|
||||||
|
test("keeps conditional class values", () => {
|
||||||
|
expect(cn("root", false && "hidden", ["active", null], { selected: true, muted: false })).toBe("root active selected");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lets tailwind-merge resolve conflicting utilities", () => {
|
||||||
|
expect(cn("px-2", "px-6", "text-sm")).toBe("px-6 text-sm");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns an empty string when no class is enabled", () => {
|
||||||
|
expect(cn(undefined, null, false)).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user