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

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

View File

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

View File

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

View File

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

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