refactor(web): 界面工具 - 代码优化,替换重复的复制、打包、项目选择和主题切换实现

删除未再引用的文字扫光组件。复制提示、压缩包读写、项目多选和主题揭示的对外行为保持不变。
This commit is contained in:
ddcat666
2026-10-01 11:40:08 +08:00
parent 12125e18f6
commit 6195d8d4f7
7 changed files with 264 additions and 425 deletions

View File

@@ -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>
); );
}; };

View File

@@ -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>
);
}

View File

@@ -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);
}; };
} }

View File

@@ -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);
} }

View File

@@ -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;
} }

View File

@@ -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) }));
},
})); }));

View 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("");
});
});