From 6195d8d4f70446199c609bea46e5fac9c7b23175 Mon Sep 17 00:00:00 2001 From: ddcat666 <26564566+ddcat666@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:40:08 +0800 Subject: [PATCH] =?UTF-8?q?refactor(web):=20=E7=95=8C=E9=9D=A2=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=20-=20=E4=BB=A3=E7=A0=81=E4=BC=98=E5=8C=96=EF=BC=8C?= =?UTF-8?q?=E6=9B=BF=E6=8D=A2=E9=87=8D=E5=A4=8D=E7=9A=84=E5=A4=8D=E5=88=B6?= =?UTF-8?q?=E3=80=81=E6=89=93=E5=8C=85=E3=80=81=E9=A1=B9=E7=9B=AE=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E5=92=8C=E4=B8=BB=E9=A2=98=E5=88=87=E6=8D=A2=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 删除未再引用的文字扫光组件。复制提示、压缩包读写、项目多选和主题揭示的对外行为保持不变。 --- .../components/ui/animated-theme-toggler.tsx | 323 +++++++++--------- web/src/components/ui/dia-text-reveal.tsx | 226 ------------ web/src/hooks/use-copy-text.ts | 13 +- web/src/lib/utils.ts | 11 +- web/src/lib/zip.ts | 37 +- web/src/stores/canvas/use-canvas-ui-store.ts | 62 +++- web/test/m9-clean-room-ui.test.ts | 17 + 7 files changed, 264 insertions(+), 425 deletions(-) delete mode 100644 web/src/components/ui/dia-text-reveal.tsx create mode 100644 web/test/m9-clean-room-ui.test.ts diff --git a/web/src/components/ui/animated-theme-toggler.tsx b/web/src/components/ui/animated-theme-toggler.tsx index e9e9f080f..af3dad56c 100644 --- a/web/src/components/ui/animated-theme-toggler.tsx +++ b/web/src/components/ui/animated-theme-toggler.tsx @@ -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(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>; + 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 ( - ); }; diff --git a/web/src/components/ui/dia-text-reveal.tsx b/web/src/components/ui/dia-text-reveal.tsx deleted file mode 100644 index 373cf02ca..000000000 --- a/web/src/components/ui/dia-text-reveal.tsx +++ /dev/null @@ -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, "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(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>(undefined); - const playRef = useRef<() => void>(null!); - const stopRef = useRef<(() => void) | null>(null); - - const [activeIndex, setActiveIndex] = useState(0); - const [measuredWidths, setMeasuredWidths] = useState([]); - - 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 ( - - {texts[activeIndex]} - - ); -} diff --git a/web/src/hooks/use-copy-text.ts b/web/src/hooks/use-copy-text.ts index 9d2c6ffc0..be48d431a 100644 --- a/web/src/hooks/use-copy-text.ts +++ b/web/src/hooks/use-copy-text.ts @@ -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); }; } diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index e6a8be071..49d1f67bc 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -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); } diff --git a/web/src/lib/zip.ts b/web/src/lib/zip.ts index 2633e3d7c..fd556a64b 100644 --- a/web/src/lib/zip.ts +++ b/web/src/lib/zip.ts @@ -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 { + 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 { + const entries: Record = {}; + 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> { + const opened = unpack(await toBytes(file)); + const extracted = new Map(); + for (const [name, bytes] of Object.entries(opened)) { + extracted.set(name, new Blob([bytes])); + } + return extracted; } diff --git a/web/src/stores/canvas/use-canvas-ui-store.ts b/web/src/stores/canvas/use-canvas-ui-store.ts index 6e266f52f..df12667b3 100644 --- a/web/src/stores/canvas/use-canvas-ui-store.ts +++ b/web/src/stores/canvas/use-canvas-ui-store.ts @@ -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((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((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) })); + }, })); diff --git a/web/test/m9-clean-room-ui.test.ts b/web/test/m9-clean-room-ui.test.ts new file mode 100644 index 000000000..dc762d3b3 --- /dev/null +++ b/web/test/m9-clean-room-ui.test.ts @@ -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(""); + }); +});