refactor(web): 界面工具 - 代码优化,替换重复的复制、打包、项目选择和主题切换实现
删除未再引用的文字扫光组件。复制提示、压缩包读写、项目多选和主题揭示的对外行为保持不变。
This commit is contained in:
@@ -1,219 +1,222 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState, type ComponentPropsWithoutRef } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
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;
|
||||
variant?: TransitionVariant;
|
||||
/** When true, the transition expands from the viewport center instead of the button center. */
|
||||
fromCenter?: boolean;
|
||||
theme?: "light" | "dark";
|
||||
targetTheme?: "light" | "dark";
|
||||
onThemeChange?: (theme: "light" | "dark") => void;
|
||||
theme?: ThemeName;
|
||||
targetTheme?: ThemeName;
|
||||
onThemeChange?: (theme: ThemeName) => void;
|
||||
}
|
||||
|
||||
let themeTransitionInFlight = false;
|
||||
let themeClipAnimation: Animation | null = null;
|
||||
let revealLocked = false;
|
||||
let revealAnimation: Animation | null = null;
|
||||
|
||||
function polygonCollapsed(cx: number, cy: number, vertexCount: number): string {
|
||||
const pairs = Array.from({ length: vertexCount }, () => `${cx}px ${cy}px`).join(", ");
|
||||
return `polygon(${pairs})`;
|
||||
function readTheme(): ThemeName {
|
||||
return document.documentElement.classList.contains("dark") ? "dark" : "light";
|
||||
}
|
||||
|
||||
function getThemeTransitionClipPaths(variant: TransitionVariant, cx: number, cy: number, maxRadius: number, viewportWidth: number, viewportHeight: number): [string, string] {
|
||||
switch (variant) {
|
||||
case "circle":
|
||||
return [`circle(0px at ${cx}px ${cy}px)`, `circle(${maxRadius}px at ${cx}px ${cy}px)`];
|
||||
case "square": {
|
||||
const halfW = Math.max(cx, viewportWidth - cx);
|
||||
const halfH = Math.max(cy, viewportHeight - cy);
|
||||
const halfSide = Math.max(halfW, halfH) * 1.05;
|
||||
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(", ");
|
||||
return [polygonCollapsed(cx, cy, 4), `polygon(${end})`];
|
||||
}
|
||||
case "triangle": {
|
||||
const scale = maxRadius * 2.2;
|
||||
const dx = (Math.sqrt(3) / 2) * scale;
|
||||
const verts = [`${cx}px ${cy - scale}px`, `${cx + dx}px ${cy + 0.5 * scale}px`, `${cx - dx}px ${cy + 0.5 * scale}px`].join(", ");
|
||||
return [polygonCollapsed(cx, cy, 3), `polygon(${verts})`];
|
||||
}
|
||||
case "diamond": {
|
||||
// Slightly larger than the view-transition circle radius so axis-aligned coverage matches the circle reveal.
|
||||
const R = maxRadius * Math.SQRT2;
|
||||
const end = [`${cx}px ${cy - R}px`, `${cx + R}px ${cy}px`, `${cx}px ${cy + R}px`, `${cx - R}px ${cy}px`].join(", ");
|
||||
return [polygonCollapsed(cx, cy, 4), `polygon(${end})`];
|
||||
}
|
||||
case "hexagon": {
|
||||
const R = maxRadius * Math.SQRT2;
|
||||
const verts: string[] = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const a = -Math.PI / 2 + (i * Math.PI) / 3;
|
||||
verts.push(`${cx + R * Math.cos(a)}px ${cy + R * Math.sin(a)}px`);
|
||||
}
|
||||
return [polygonCollapsed(cx, cy, 6), `polygon(${verts.join(", ")})`];
|
||||
}
|
||||
case "rectangle": {
|
||||
const halfW = Math.max(cx, viewportWidth - cx);
|
||||
const halfH = Math.max(cy, viewportHeight - cy);
|
||||
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(", ");
|
||||
return [polygonCollapsed(cx, cy, 4), `polygon(${end})`];
|
||||
}
|
||||
case "star": {
|
||||
// Small overscan so the last frames never leave a 1px seam before the transition group ends.
|
||||
const R = maxRadius * Math.SQRT2 * 1.03;
|
||||
const innerRatio = 0.42;
|
||||
const starPolygon = (radius: number) => {
|
||||
const verts: string[] = [];
|
||||
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`);
|
||||
const innerA = outerA + Math.PI / 5;
|
||||
verts.push(`${cx + radius * innerRatio * Math.cos(innerA)}px ${cy + radius * innerRatio * Math.sin(innerA)}px`);
|
||||
}
|
||||
return `polygon(${verts.join(", ")})`;
|
||||
};
|
||||
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)`];
|
||||
function oppositeTheme(theme: ThemeName): ThemeName {
|
||||
return theme === "dark" ? "light" : "dark";
|
||||
}
|
||||
|
||||
function originPoint(button: HTMLButtonElement, fromCenter: boolean): Point {
|
||||
const viewportWidth = window.visualViewport?.width ?? window.innerWidth;
|
||||
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
||||
if (fromCenter) return { x: viewportWidth / 2, y: viewportHeight / 2 };
|
||||
const bounds = button.getBoundingClientRect();
|
||||
return { x: bounds.left + bounds.width / 2, y: bounds.top + bounds.height / 2 };
|
||||
}
|
||||
|
||||
function reach(point: Point): number {
|
||||
const viewportWidth = window.visualViewport?.width ?? window.innerWidth;
|
||||
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
||||
return Math.hypot(Math.max(point.x, viewportWidth - point.x), Math.max(point.y, viewportHeight - point.y));
|
||||
}
|
||||
|
||||
function collapsed(point: Point, count: number): string {
|
||||
const vertex = `${point.x}px ${point.y}px`;
|
||||
return `polygon(${Array.from({ length: count }, () => vertex).join(", ")})`;
|
||||
}
|
||||
|
||||
function polygonAt(point: Point, vertices: Point[]): string {
|
||||
return `polygon(${vertices.map((vertex) => `${vertex.x}px ${vertex.y}px`).join(", ")})`;
|
||||
}
|
||||
|
||||
function regularVertices(point: Point, radius: number, count: number, rotation = -Math.PI / 2): Point[] {
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const angle = rotation + (index * Math.PI * 2) / count;
|
||||
return { x: point.x + radius * Math.cos(angle), y: point.y + radius * Math.sin(angle) };
|
||||
});
|
||||
}
|
||||
|
||||
function starVertices(point: Point, radius: number): Point[] {
|
||||
const inner = radius * 0.42;
|
||||
return Array.from({ length: 5 }, (_, index) => {
|
||||
const outerAngle = -Math.PI / 2 + (index * Math.PI * 2) / 5;
|
||||
const innerAngle = outerAngle + Math.PI / 5;
|
||||
return [
|
||||
{ x: point.x + radius * Math.cos(outerAngle), y: point.y + radius * Math.sin(outerAngle) },
|
||||
{ x: point.x + inner * Math.cos(innerAngle), y: point.y + inner * Math.sin(innerAngle) },
|
||||
];
|
||||
}).flat();
|
||||
}
|
||||
|
||||
function revealFrames(variant: TransitionVariant, point: Point, radius: number): [string, string] {
|
||||
const viewportWidth = window.visualViewport?.width ?? window.innerWidth;
|
||||
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
||||
if (variant === "circle") {
|
||||
return [`circle(0px at ${point.x}px ${point.y}px)`, `circle(${radius}px at ${point.x}px ${point.y}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) => {
|
||||
const shape = variant ?? "circle";
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
function paintTheme(next: ThemeName, onThemeChange?: (theme: ThemeName) => void) {
|
||||
document.documentElement.classList.toggle("dark", next === "dark");
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
if (theme) {
|
||||
setIsDark(theme === "dark");
|
||||
setDark(theme === "dark");
|
||||
return;
|
||||
}
|
||||
|
||||
const updateTheme = () => {
|
||||
setIsDark(document.documentElement.classList.contains("dark"));
|
||||
};
|
||||
|
||||
updateTheme();
|
||||
|
||||
const observer = new MutationObserver(updateTheme);
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
|
||||
const sync = () => setDark(readTheme() === "dark");
|
||||
sync();
|
||||
const observer = new MutationObserver(sync);
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
|
||||
return () => observer.disconnect();
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
const changeTheme = useCallback(() => {
|
||||
const button = buttonRef.current;
|
||||
if (!button) return;
|
||||
const currentTheme = document.documentElement.classList.contains("dark") ? "dark" : "light";
|
||||
const nextTheme = targetTheme ?? (currentTheme === "dark" ? "light" : "dark");
|
||||
if (nextTheme === currentTheme) 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 current = readTheme();
|
||||
const next = targetTheme ?? oppositeTheme(current);
|
||||
if (next === current) return;
|
||||
|
||||
const point = originPoint(button, fromCenter);
|
||||
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;
|
||||
if (canvasMounted || reducedMotion || typeof document.startViewTransition !== "function") {
|
||||
root.dataset.themeSwitching = "true";
|
||||
flushSync(applyTheme);
|
||||
requestAnimationFrame(() => delete root.dataset.themeSwitching);
|
||||
const canReveal = typeof document.startViewTransition === "function";
|
||||
if (canvasOpen || reducedMotion || !canReveal) {
|
||||
setDark(next === "dark");
|
||||
revealImmediately(next, onThemeChange);
|
||||
return;
|
||||
}
|
||||
if (themeTransitionInFlight) return;
|
||||
themeTransitionInFlight = true;
|
||||
|
||||
const clipPath = getThemeTransitionClipPaths(shape, x, y, maxRadius, viewportWidth, viewportHeight);
|
||||
if (revealLocked) return;
|
||||
|
||||
revealLocked = true;
|
||||
const frames = revealFrames(variant, point, reach(point));
|
||||
root.dataset.magicuiThemeVt = "active";
|
||||
root.style.setProperty("--magicui-theme-toggle-vt-duration", `${duration}ms`);
|
||||
// Pin the collapsed clip-path via CSS so Firefox does not paint the new
|
||||
// theme unclipped between snapshot and the ready.then() JS animation.
|
||||
root.style.setProperty("--magicui-theme-vt-clip-from", clipPath[0]);
|
||||
let cleaned = false;
|
||||
const cleanup = () => {
|
||||
if (cleaned) return;
|
||||
cleaned = true;
|
||||
themeClipAnimation?.cancel();
|
||||
themeClipAnimation = null;
|
||||
themeTransitionInFlight = false;
|
||||
root.style.setProperty("--magicui-theme-vt-clip-from", frames[0]);
|
||||
let closed = false;
|
||||
const finish = () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
revealAnimation?.cancel();
|
||||
revealAnimation = null;
|
||||
revealLocked = false;
|
||||
delete root.dataset.magicuiThemeVt;
|
||||
root.style.removeProperty("--magicui-theme-toggle-vt-duration");
|
||||
root.style.removeProperty("--magicui-theme-vt-clip-from");
|
||||
};
|
||||
|
||||
let transition: ReturnType<NonNullable<typeof document.startViewTransition>>;
|
||||
let transition: ViewTransition;
|
||||
try {
|
||||
transition = document.startViewTransition(() => {
|
||||
flushSync(applyTheme);
|
||||
flushSync(() => {
|
||||
setDark(next === "dark");
|
||||
paintTheme(next, onThemeChange);
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
cleanup();
|
||||
root.dataset.themeSwitching = "true";
|
||||
flushSync(applyTheme);
|
||||
requestAnimationFrame(() => delete root.dataset.themeSwitching);
|
||||
finish();
|
||||
setDark(next === "dark");
|
||||
revealImmediately(next, onThemeChange);
|
||||
return;
|
||||
}
|
||||
if (typeof transition?.finished?.finally === "function") {
|
||||
void transition.finished.then(cleanup, cleanup);
|
||||
} else {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
const ready = transition?.ready;
|
||||
if (ready && typeof ready.then === "function") {
|
||||
void ready.then(() => {
|
||||
if (cleaned) return;
|
||||
themeClipAnimation?.cancel();
|
||||
themeClipAnimation = document.documentElement.animate(
|
||||
{
|
||||
clipPath,
|
||||
},
|
||||
void transition.finished.then(finish, finish);
|
||||
void transition.ready
|
||||
.then(() => {
|
||||
if (closed) return;
|
||||
revealAnimation = root.animate(
|
||||
{ clipPath: frames },
|
||||
{
|
||||
duration,
|
||||
// Star: linear avoids easing overshoot that fights polygon interpolation at t→1; VT group duration is synced above.
|
||||
easing: shape === "star" ? "linear" : "ease-in-out",
|
||||
easing: variant === "star" ? "linear" : "ease-in-out",
|
||||
fill: "forwards",
|
||||
pseudoElement: "::view-transition-new(root)",
|
||||
},
|
||||
);
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
}, [shape, fromCenter, duration, targetTheme, onThemeChange]);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, [duration, fromCenter, onThemeChange, targetTheme, variant]);
|
||||
|
||||
return (
|
||||
<button type="button" ref={buttonRef} onClick={toggleTheme} className={cn(className)} {...props}>
|
||||
{children ?? (isDark ? <Sun /> : <Moon />)}
|
||||
<span className="sr-only">{props["aria-label"] || "切换主题"}</span>
|
||||
<button type="button" ref={buttonRef} onClick={changeTheme} className={cn(className)} {...buttonProps}>
|
||||
{children ?? (dark ? <Sun /> : <Moon />)}
|
||||
<span className="sr-only">{buttonProps["aria-label"] || "切换主题"}</span>
|
||||
</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 { App } from "antd";
|
||||
|
||||
const COPIED_HINT = "已复制";
|
||||
|
||||
export function useCopyText() {
|
||||
const { message } = App.useApp();
|
||||
|
||||
return (value: string, successText = "已复制") => {
|
||||
copy(value);
|
||||
message.success(successText);
|
||||
const feedback = App.useApp().message;
|
||||
return (value: string, successText = COPIED_HINT) => {
|
||||
copy(String(value));
|
||||
feedback.success(successText);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
function joinClassNames(parts: ClassValue[]): string {
|
||||
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 = {
|
||||
name: string;
|
||||
data: BlobPart;
|
||||
};
|
||||
type ZipSource = BlobPart;
|
||||
|
||||
export async function createZip(files: ZipFile[]) {
|
||||
const entries = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
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" });
|
||||
async function toBytes(data: ZipSource): Promise<Uint8Array> {
|
||||
const buffer = await new Blob([data]).arrayBuffer();
|
||||
return new Uint8Array(buffer);
|
||||
}
|
||||
|
||||
export async function readZip(file: Blob) {
|
||||
const entries = unzipSync(new Uint8Array(await file.arrayBuffer()));
|
||||
return new Map(Object.entries(entries).map(([name, data]) => [name, new Blob([data])]));
|
||||
export async function createZip(files: Array<{ name: string; data: ZipSource }>): Promise<Blob> {
|
||||
const entries: Record<string, Uint8Array> = {};
|
||||
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";
|
||||
|
||||
type CanvasUiStore = {
|
||||
editingProjectId: string | null;
|
||||
editingProjectTitle: string;
|
||||
selectedProjectIds: string[];
|
||||
deleteProjectIds: string[];
|
||||
type ProjectId = string;
|
||||
type ProjectTitle = string;
|
||||
type ProjectIdList = string[];
|
||||
|
||||
type ProjectUiState = {
|
||||
editingProjectId: ProjectId | null;
|
||||
editingProjectTitle: ProjectTitle;
|
||||
selectedProjectIds: ProjectIdList;
|
||||
deleteProjectIds: ProjectIdList;
|
||||
};
|
||||
|
||||
type CanvasUiStore = ProjectUiState & {
|
||||
startEditingProject: (id: string, title: string) => void;
|
||||
setEditingProjectTitle: (title: string) => void;
|
||||
stopEditingProject: () => void;
|
||||
@@ -13,15 +20,42 @@ type CanvasUiStore = {
|
||||
removeSelectedProjectIds: (ids: string[]) => void;
|
||||
};
|
||||
|
||||
export const useCanvasUiStore = create<CanvasUiStore>((set) => ({
|
||||
const emptyProjectUi: ProjectUiState = {
|
||||
editingProjectId: null,
|
||||
editingProjectTitle: "",
|
||||
selectedProjectIds: [],
|
||||
deleteProjectIds: [],
|
||||
startEditingProject: (editingProjectId, editingProjectTitle) => set({ editingProjectId, editingProjectTitle }),
|
||||
setEditingProjectTitle: (editingProjectTitle) => set({ editingProjectTitle }),
|
||||
stopEditingProject: () => set({ editingProjectId: null }),
|
||||
toggleSelectedProjectId: (id, selected) => set((state) => ({ selectedProjectIds: selected ? [...new Set([...state.selectedProjectIds, id])] : state.selectedProjectIds.filter((item) => item !== id) })),
|
||||
setDeleteProjectIds: (deleteProjectIds) => set({ deleteProjectIds }),
|
||||
removeSelectedProjectIds: (ids) => set((state) => ({ selectedProjectIds: state.selectedProjectIds.filter((id) => !ids.includes(id)) })),
|
||||
selectedProjectIds: [] as ProjectIdList,
|
||||
deleteProjectIds: [] as ProjectIdList,
|
||||
};
|
||||
|
||||
function selectProject(ids: readonly string[], id: string, selected: boolean): string[] {
|
||||
if (!selected) return ids.filter((item) => item !== id);
|
||||
if (ids.includes(id)) return [...ids];
|
||||
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) }));
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user