From 7d4ab86da5dbc2cbb24ca15bee49d3092a1f99b0 Mon Sep 17 00:00:00 2001 From: jisangs Date: Tue, 26 May 2026 17:30:19 +0900 Subject: [PATCH] =?UTF-8?q?=EB=AA=A8=EB=B0=94=EC=9D=BC=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=97=90=EC=84=9C=20=EC=88=AB=EC=9E=90=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=EC=B0=BD=20=EC=8B=A4=ED=96=89=EC=8B=9C=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=EA=B0=80=20=ED=99=94=EB=A9=B4=20=EC=9C=84=EC=97=90=20?= =?UTF-8?q?=EB=B3=B4=EC=97=AC=EC=A7=80=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(game)/play/PlayClient.tsx | 153 ++++++++++++++++++-- components/game/AnswerForm.tsx | 256 +++++++++++++++++++++++++-------- package.json | 1 + 3 files changed, 336 insertions(+), 74 deletions(-) diff --git a/app/(game)/play/PlayClient.tsx b/app/(game)/play/PlayClient.tsx index a650cd7..3c21f3d 100644 --- a/app/(game)/play/PlayClient.tsx +++ b/app/(game)/play/PlayClient.tsx @@ -1,9 +1,14 @@ "use client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { FormEvent } from "react"; +import { flushSync } from "react-dom"; import AnswerForm from "@/components/game/AnswerForm"; +import type { + ActiveAnswerInput, + AnswerFormHandle, +} from "@/components/game/AnswerForm"; import FeedbackPanel from "@/components/game/FeedbackPanel"; import ScoreSummary from "@/components/game/ScoreSummary"; import StageIndicator from "@/components/game/StageIndicator"; @@ -34,8 +39,16 @@ export default function PlayClient({ problemCount }: PlayClientProps) { const nextProblem = useGameStore((state) => state.nextProblem); const resetSession = useGameStore((state) => state.resetSession); + const problemCardRef = useRef(null); + const scrollTimeoutsRef = useRef([]); + const viewportResizeCleanupRef = useRef<(() => void) | null>(null); + const answerFormRef = useRef(null); + const [answerA, setAnswerA] = useState(""); const [answerB, setAnswerB] = useState(""); + const [activeInput, setActiveInput] = useState(0); + const [focusInputOnFormMount, setFocusInputOnFormMount] = useState(false); + const [mobileFocusSpacerHeight, setMobileFocusSpacerHeight] = useState(0); const [inputError, setInputError] = useState(""); const sessionProblemCount = session?.problems.length ?? null; @@ -69,9 +82,19 @@ export default function PlayClient({ problemCount }: PlayClientProps) { useEffect(() => { setAnswerA(""); setAnswerB(""); + setActiveInput(0); setInputError(""); }, [currentProblem?.key]); + useEffect(() => { + return () => { + scrollTimeoutsRef.current.forEach((timeoutId) => { + window.clearTimeout(timeoutId); + }); + viewportResizeCleanupRef.current?.(); + }; + }, []); + function handleSubmit(event: FormEvent) { event.preventDefault(); @@ -89,6 +112,93 @@ export default function PlayClient({ problemCount }: PlayClientProps) { submitAnswer({ a: parsedA, b: parsedB }); } + function handleNextProblem() { + setFocusInputOnFormMount(true); + flushSync(() => { + nextProblem(); + }); + answerFormRef.current?.focusInput(); + } + + const scrollKatexToViewportTop = useCallback(() => { + if (window.matchMedia("(min-width: 640px)").matches) return; + + function alignKatex() { + const katexElement = + problemCardRef.current?.querySelector("span.katex"); + + if (!katexElement) return; + + const rect = katexElement.getBoundingClientRect(); + const scrollElement = + document.scrollingElement ?? document.documentElement; + const elementDocumentTop = window.pageYOffset + rect.top; + const maxScrollTop = Math.max( + 0, + scrollElement.scrollHeight - window.innerHeight, + ); + const scrollTop = Math.min( + maxScrollTop, + Math.max(0, elementDocumentTop), + ); + + if (Math.abs(window.pageYOffset - scrollTop) < 4) return; + + scrollElement.scrollTop = scrollTop; + document.body.scrollTop = scrollTop; + window.scrollTo(0, scrollTop); + } + + const visualViewport = window.visualViewport; + const keyboardHeight = Math.max( + 0, + window.innerHeight - + (visualViewport?.height ?? window.innerHeight) - + (visualViewport?.offsetTop ?? 0), + ); + + setMobileFocusSpacerHeight( + Math.max(window.innerHeight, keyboardHeight + 480), + ); + + scrollTimeoutsRef.current.forEach((timeoutId) => { + window.clearTimeout(timeoutId); + }); + scrollTimeoutsRef.current = []; + viewportResizeCleanupRef.current?.(); + viewportResizeCleanupRef.current = null; + + if (visualViewport) { + let settleTimeoutId: number | null = null; + + const handleViewportResize = () => { + if (settleTimeoutId !== null) { + window.clearTimeout(settleTimeoutId); + } + + settleTimeoutId = window.setTimeout(() => { + alignKatex(); + }, 180); + }; + + visualViewport.addEventListener("resize", handleViewportResize); + viewportResizeCleanupRef.current = () => { + visualViewport.removeEventListener("resize", handleViewportResize); + if (settleTimeoutId !== null) { + window.clearTimeout(settleTimeoutId); + } + }; + } + + window.requestAnimationFrame(() => { + window.requestAnimationFrame(alignKatex); + }); + [900, 1300].forEach((delay) => { + const timeoutId = window.setTimeout(alignKatex, delay); + scrollTimeoutsRef.current.push(timeoutId); + }); + }, []); + if (!session || sessionNeedsInit) { return (
@@ -144,7 +254,10 @@ export default function PlayClient({ problemCount }: PlayClientProps) { /> -
+

괄호를 풀어 두 항을 순서대로 입력하세요.

@@ -153,22 +266,38 @@ export default function PlayClient({ problemCount }: PlayClientProps) {
- + {!submitted ? ( + setMobileFocusSpacerHeight(0)} + onInputFocus={scrollKatexToViewportTop} + onSubmit={handleSubmit} + /> + ) : null} {currentResult ? ( + ) : null} + + {!submitted && mobileFocusSpacerHeight > 0 ? ( +