Some checks failed
Deploy / deploy (push) Has been cancelled
- pixel-decor: PixelField/PixelDivider piksel üretimi useMemo([seed]) — 504 iterasyon + ~150 element çekmece reorder'ının her karesinde yeniden üretiliyordu - rapor-listesi: acikSira effect'i önceki-prop desenine döndü (çift render ve eslint-disable kalktı), Set lazy init, tekIl useMemo - program-tablosu: 3 dilimin spread'i useMemo - typing-animation: displayedText state yerine türetme; kaynak reset'i render sırasında — tick başına state yazımı ve effect bağımlılığı azaldı - site-top-banner: saniyelik geri sayım tiki startTransition'da - program-liste-verileri: sparkline 6 dizi geçişi tek döngüde - secimlerim-paneli: SecimSatiri profili prop'tan alır + memo (24 ayrı store aboneliği kalktı) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
247 lines
6.1 KiB
TypeScript
247 lines
6.1 KiB
TypeScript
"use client"
|
||
|
||
import {
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
type ComponentType,
|
||
type RefAttributes,
|
||
type RefObject,
|
||
} from "react"
|
||
import {
|
||
motion,
|
||
useInView,
|
||
useReducedMotion,
|
||
type DOMMotionComponents,
|
||
type HTMLMotionProps,
|
||
type MotionProps,
|
||
} from "motion/react"
|
||
|
||
import { cn } from "@/lib/utils"
|
||
|
||
const motionElements = {
|
||
article: motion.article,
|
||
div: motion.div,
|
||
h1: motion.h1,
|
||
h2: motion.h2,
|
||
h3: motion.h3,
|
||
h4: motion.h4,
|
||
h5: motion.h5,
|
||
h6: motion.h6,
|
||
li: motion.li,
|
||
p: motion.p,
|
||
section: motion.section,
|
||
span: motion.span,
|
||
} as const
|
||
|
||
type MotionElementType = Extract<
|
||
keyof DOMMotionComponents,
|
||
keyof typeof motionElements
|
||
>
|
||
type TypingAnimationMotionComponent = ComponentType<
|
||
Omit<HTMLMotionProps<"span">, "ref"> & RefAttributes<HTMLElement>
|
||
>
|
||
|
||
interface TypingAnimationProps extends Omit<MotionProps, "children"> {
|
||
children?: string
|
||
words?: string[]
|
||
className?: string
|
||
duration?: number
|
||
typeSpeed?: number
|
||
deleteSpeed?: number
|
||
delay?: number
|
||
pauseDelay?: number
|
||
loop?: boolean
|
||
as?: MotionElementType
|
||
startOnView?: boolean
|
||
showCursor?: boolean
|
||
blinkCursor?: boolean
|
||
cursorStyle?: "line" | "block" | "underscore"
|
||
}
|
||
|
||
export function TypingAnimation({
|
||
children,
|
||
words,
|
||
className,
|
||
duration = 100,
|
||
typeSpeed,
|
||
deleteSpeed,
|
||
delay = 0,
|
||
pauseDelay = 1000,
|
||
loop = false,
|
||
as: Component = "span",
|
||
startOnView = true,
|
||
showCursor = true,
|
||
blinkCursor = true,
|
||
cursorStyle = "line",
|
||
...props
|
||
}: TypingAnimationProps) {
|
||
const MotionComponent = motionElements[
|
||
Component
|
||
] as TypingAnimationMotionComponent
|
||
|
||
const [currentWordIndex, setCurrentWordIndex] = useState(0)
|
||
const [currentCharIndex, setCurrentCharIndex] = useState(0)
|
||
const [phase, setPhase] = useState<"typing" | "pause" | "deleting">("typing")
|
||
// Sürekli yazıp silen döngü, hareket azaltılsın diyen kullanıcı için
|
||
// rahatsız edici: ilk metin tam ve sabit gösterilir, imleç yanmaz.
|
||
const azMotion = useReducedMotion()
|
||
const elementRef = useRef<HTMLElement | null>(null)
|
||
const isInView = useInView(elementRef as RefObject<Element>, {
|
||
amount: 0.3,
|
||
once: true,
|
||
})
|
||
|
||
const wordsToAnimate = useMemo(
|
||
() => words ?? (children ? [children] : []),
|
||
[words, children]
|
||
)
|
||
const hasMultipleWords = wordsToAnimate.length > 1
|
||
|
||
const typingSpeed = typeSpeed ?? duration
|
||
const deletingSpeed = deleteSpeed ?? typingSpeed / 2
|
||
|
||
const shouldStart = startOnView ? isInView : true
|
||
const animationSourceKey = useMemo(
|
||
() => (words ? words.join("\u0000") : (children ?? "")),
|
||
[words, children]
|
||
)
|
||
|
||
// Kaynak metin değişince animasyon baştan başlar — önceki-prop deseniyle
|
||
// render sırasında (effect'te setState çift render yaratıyordu)
|
||
const [oncekiKaynak, setOncekiKaynak] = useState(animationSourceKey)
|
||
if (animationSourceKey !== oncekiKaynak) {
|
||
setOncekiKaynak(animationSourceKey)
|
||
setCurrentWordIndex(0)
|
||
setCurrentCharIndex(0)
|
||
setPhase("typing")
|
||
}
|
||
|
||
// Görünen metin state değil türetme: tick başına ekstra state yazımı ve
|
||
// grapheme dizisi allocation'ı kalkar (rerender-derived-state-no-effect)
|
||
const displayedText = useMemo(
|
||
() =>
|
||
Array.from(wordsToAnimate[currentWordIndex] || "")
|
||
.slice(0, currentCharIndex)
|
||
.join(""),
|
||
[wordsToAnimate, currentWordIndex, currentCharIndex]
|
||
)
|
||
|
||
useEffect(() => {
|
||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||
|
||
if (!azMotion && shouldStart && wordsToAnimate.length > 0) {
|
||
const timeoutDelay =
|
||
delay > 0 && currentCharIndex === 0
|
||
? delay
|
||
: phase === "typing"
|
||
? typingSpeed
|
||
: phase === "deleting"
|
||
? deletingSpeed
|
||
: pauseDelay
|
||
|
||
timeout = setTimeout(() => {
|
||
const currentWord = wordsToAnimate[currentWordIndex] || ""
|
||
const graphemes = Array.from(currentWord)
|
||
|
||
switch (phase) {
|
||
case "typing":
|
||
if (currentCharIndex < graphemes.length) {
|
||
setCurrentCharIndex(currentCharIndex + 1)
|
||
} else {
|
||
if (hasMultipleWords || loop) {
|
||
const isLastWord =
|
||
currentWordIndex === wordsToAnimate.length - 1
|
||
if (!isLastWord || loop) {
|
||
setPhase("pause")
|
||
}
|
||
}
|
||
}
|
||
break
|
||
|
||
case "pause":
|
||
setPhase("deleting")
|
||
break
|
||
|
||
case "deleting":
|
||
if (currentCharIndex > 0) {
|
||
setCurrentCharIndex(currentCharIndex - 1)
|
||
} else {
|
||
const nextIndex = (currentWordIndex + 1) % wordsToAnimate.length
|
||
setCurrentWordIndex(nextIndex)
|
||
setPhase("typing")
|
||
}
|
||
break
|
||
}
|
||
}, timeoutDelay)
|
||
}
|
||
|
||
return () => {
|
||
if (timeout !== null) {
|
||
clearTimeout(timeout)
|
||
}
|
||
}
|
||
}, [
|
||
azMotion,
|
||
shouldStart,
|
||
phase,
|
||
currentCharIndex,
|
||
currentWordIndex,
|
||
wordsToAnimate,
|
||
hasMultipleWords,
|
||
loop,
|
||
typingSpeed,
|
||
deletingSpeed,
|
||
pauseDelay,
|
||
delay,
|
||
])
|
||
|
||
const currentWordGraphemes = Array.from(
|
||
wordsToAnimate[currentWordIndex] || ""
|
||
)
|
||
const isComplete =
|
||
!loop &&
|
||
currentWordIndex === wordsToAnimate.length - 1 &&
|
||
currentCharIndex >= currentWordGraphemes.length &&
|
||
phase !== "deleting"
|
||
|
||
const shouldShowCursor =
|
||
showCursor &&
|
||
!isComplete &&
|
||
(hasMultipleWords || loop || currentCharIndex < currentWordGraphemes.length)
|
||
|
||
const getCursorChar = () => {
|
||
switch (cursorStyle) {
|
||
case "block":
|
||
return "▌"
|
||
case "underscore":
|
||
return "_"
|
||
case "line":
|
||
default:
|
||
return "|"
|
||
}
|
||
}
|
||
|
||
return (
|
||
<MotionComponent
|
||
ref={elementRef}
|
||
className={cn(
|
||
"leading-20 tracking-[-0.02em]",
|
||
Component === "span" && "inline-block",
|
||
className
|
||
)}
|
||
{...props}
|
||
>
|
||
{azMotion ? (wordsToAnimate[0] ?? "") : displayedText}
|
||
{shouldShowCursor && !azMotion && (
|
||
<span
|
||
className={cn("inline-block", blinkCursor && "animate-blink-cursor")}
|
||
>
|
||
{getCursorChar()}
|
||
</span>
|
||
)}
|
||
</MotionComponent>
|
||
)
|
||
}
|