|
1 | | -import { Outlet } from 'react-router-dom'; |
| 1 | +import { useEffect, useState, useRef } from 'react'; |
| 2 | +import { Link } from 'react-router-dom'; |
| 3 | + |
| 4 | +import * as styles from './Todo.css'; |
| 5 | + |
| 6 | +import { GradientCircle } from '@/common/component/GradientCircle/GradientCircle'; |
| 7 | +import GoButton from '@/common/component/GoButton/GoButton'; |
| 8 | +import { PATH } from '@/route'; |
| 9 | + |
| 10 | +const TYPING_DURATION = 3000; |
| 11 | +const FULL_TEXT = '66일간 달성할 목표를 입력하고\n만다라트를 시작해보세요!'; |
| 12 | +const CHARARRAY = Array.from(FULL_TEXT); |
2 | 13 |
|
3 | 14 | const Todo = () => { |
| 15 | + const [displayedText, setDisplayedText] = useState(''); |
| 16 | + const [inputText, setInputText] = useState(''); |
| 17 | + const indexRef = useRef(0); |
| 18 | + const textRef = useRef(''); |
| 19 | + |
| 20 | + const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { |
| 21 | + setInputText(e.target.value); |
| 22 | + }; |
| 23 | + |
| 24 | + useEffect(() => { |
| 25 | + let startTime: number | null = null; |
| 26 | + const totalDuration = TYPING_DURATION; |
| 27 | + const totalChars = CHARARRAY.length; |
| 28 | + let isMounted = true; |
| 29 | + |
| 30 | + const step = (timestamp: number) => { |
| 31 | + if (!isMounted) { |
| 32 | + return; |
| 33 | + } |
| 34 | + if (startTime === null) { |
| 35 | + startTime = timestamp; |
| 36 | + } |
| 37 | + const elapsed = timestamp - startTime; |
| 38 | + const progress = Math.min(elapsed / totalDuration, 1); |
| 39 | + const charsToShow = Math.floor(progress * totalChars); |
| 40 | + |
| 41 | + if (charsToShow > indexRef.current) { |
| 42 | + textRef.current = CHARARRAY.slice(0, charsToShow).join(''); |
| 43 | + setDisplayedText(textRef.current); |
| 44 | + indexRef.current = charsToShow; |
| 45 | + } |
| 46 | + |
| 47 | + if (progress < 1) { |
| 48 | + requestAnimationFrame(step); |
| 49 | + } |
| 50 | + }; |
| 51 | + |
| 52 | + const rafId = requestAnimationFrame(step); |
| 53 | + |
| 54 | + return () => { |
| 55 | + isMounted = false; |
| 56 | + cancelAnimationFrame(rafId); |
| 57 | + }; |
| 58 | + }, []); |
| 59 | + |
| 60 | + const renderTextWithLineBreaks = () => |
| 61 | + displayedText.split('\n').map((line, idx) => ( |
| 62 | + <span key={idx}> |
| 63 | + {line} |
| 64 | + <br /> |
| 65 | + </span> |
| 66 | + )); |
| 67 | + |
4 | 68 | return ( |
5 | | - <div> |
6 | | - <h1>Todo</h1> |
7 | | - <Outlet /> |
8 | | - </div> |
| 69 | + <main className={styles.todoContainer}> |
| 70 | + <GradientCircle variant="topRight" /> |
| 71 | + <GradientCircle variant="bottomLeft1" /> |
| 72 | + <GradientCircle variant="bottomLeft2" /> |
| 73 | + <h2 className={styles.todoTitle}>{renderTextWithLineBreaks()}</h2> |
| 74 | + <section className={styles.todoInputContainer}> |
| 75 | + <input |
| 76 | + type="text" |
| 77 | + value={inputText} |
| 78 | + onChange={handleInputChange} |
| 79 | + placeholder="이루고 싶은 목표를 작성하세요." |
| 80 | + /> |
| 81 | + <Link to={PATH.TODO_UPPER}> |
| 82 | + <GoButton isActive={inputText.length > 0} /> |
| 83 | + </Link> |
| 84 | + </section> |
| 85 | + </main> |
9 | 86 | ); |
10 | 87 | }; |
11 | 88 |
|
|
0 commit comments