mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-30 04:44:28 +08:00
@@ -0,0 +1,269 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { Moon, Sun } from "lucide-react"
|
||||
import { flushSync } from "react-dom"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type TransitionVariant =
|
||||
| "circle"
|
||||
| "square"
|
||||
| "triangle"
|
||||
| "diamond"
|
||||
| "hexagon"
|
||||
| "rectangle"
|
||||
| "star"
|
||||
|
||||
interface AnimatedThemeTogglerProps extends React.ComponentPropsWithoutRef<"button"> {
|
||||
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
|
||||
}
|
||||
|
||||
function polygonCollapsed(cx: number, cy: number, vertexCount: number): string {
|
||||
const pairs = Array.from(
|
||||
{ length: vertexCount },
|
||||
() => `${cx}px ${cy}px`
|
||||
).join(", ")
|
||||
return `polygon(${pairs})`
|
||||
}
|
||||
|
||||
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)`,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export const AnimatedThemeToggler = ({
|
||||
children,
|
||||
className,
|
||||
duration = 400,
|
||||
variant,
|
||||
fromCenter = false,
|
||||
theme,
|
||||
targetTheme,
|
||||
onThemeChange,
|
||||
...props
|
||||
}: AnimatedThemeTogglerProps) => {
|
||||
const shape = variant ?? "circle"
|
||||
const [isDark, setIsDark] = useState(false)
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (theme) {
|
||||
setIsDark(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"],
|
||||
})
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [theme])
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
const button = buttonRef.current
|
||||
if (!button) 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 = () => {
|
||||
const nextTheme = targetTheme ?? (isDark ? "light" : "dark")
|
||||
if (nextTheme === (isDark ? "dark" : "light")) return
|
||||
setIsDark(nextTheme === "dark")
|
||||
document.documentElement.classList.toggle("dark", nextTheme === "dark")
|
||||
document.documentElement.style.colorScheme = nextTheme
|
||||
onThemeChange?.(nextTheme)
|
||||
}
|
||||
|
||||
if (typeof document.startViewTransition !== "function") {
|
||||
applyTheme()
|
||||
return
|
||||
}
|
||||
|
||||
const clipPath = getThemeTransitionClipPaths(
|
||||
shape,
|
||||
x,
|
||||
y,
|
||||
maxRadius,
|
||||
viewportWidth,
|
||||
viewportHeight
|
||||
)
|
||||
|
||||
const root = document.documentElement
|
||||
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])
|
||||
const cleanup = () => {
|
||||
delete root.dataset.magicuiThemeVt
|
||||
root.style.removeProperty("--magicui-theme-toggle-vt-duration")
|
||||
root.style.removeProperty("--magicui-theme-vt-clip-from")
|
||||
}
|
||||
|
||||
const transition = document.startViewTransition(() => {
|
||||
flushSync(applyTheme)
|
||||
})
|
||||
if (typeof transition?.finished?.finally === "function") {
|
||||
transition.finished.finally(cleanup)
|
||||
} else {
|
||||
cleanup()
|
||||
}
|
||||
|
||||
const ready = transition?.ready
|
||||
if (ready && typeof ready.then === "function") {
|
||||
ready.then(() => {
|
||||
document.documentElement.animate(
|
||||
{
|
||||
clipPath,
|
||||
},
|
||||
{
|
||||
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",
|
||||
fill: "forwards",
|
||||
pseudoElement: "::view-transition-new(root)",
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
}, [shape, fromCenter, duration, isDark, targetTheme, onThemeChange])
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
"use client"
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user