모바일 화면에서 숫자 입력창 실행시 문제가 화면 위에 보여지도록 수정

This commit is contained in:
2026-05-26 17:30:19 +09:00
parent 4a08dded14
commit 7d4ab86da5
3 changed files with 336 additions and 74 deletions
+141 -12
View File
@@ -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<HTMLDivElement>(null);
const scrollTimeoutsRef = useRef<number[]>([]);
const viewportResizeCleanupRef = useRef<(() => void) | null>(null);
const answerFormRef = useRef<AnswerFormHandle>(null);
const [answerA, setAnswerA] = useState("");
const [answerB, setAnswerB] = useState("");
const [activeInput, setActiveInput] = useState<ActiveAnswerInput>(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<HTMLFormElement>) {
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<HTMLElement>("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 (
<main className="flex min-h-screen items-center bg-slate-50 px-6 py-12 text-slate-950">
@@ -144,7 +254,10 @@ export default function PlayClient({ problemCount }: PlayClientProps) {
/>
</div>
<div className="rounded-md bg-white p-6 shadow-sm ring-1 ring-slate-200 sm:p-8">
<div
ref={problemCardRef}
className="rounded-md bg-white px-6 pt-6 pb-2 shadow-sm ring-1 ring-slate-200 sm:p-8"
>
<p className="mb-4 text-sm font-semibold text-slate-500">
.
</p>
@@ -153,22 +266,38 @@ export default function PlayClient({ problemCount }: PlayClientProps) {
</div>
</div>
<AnswerForm
answerA={answerA}
answerB={answerB}
disabled={submitted}
error={inputError}
onAnswerAChange={setAnswerA}
onAnswerBChange={setAnswerB}
onSubmit={handleSubmit}
/>
{!submitted ? (
<AnswerForm
ref={answerFormRef}
answerA={answerA}
answerB={answerB}
activeInput={activeInput}
disabled={submitted}
error={inputError}
focusOnMount={focusInputOnFormMount}
onActiveInputChange={setActiveInput}
onAnswerAChange={setAnswerA}
onAnswerBChange={setAnswerB}
onInputBlur={() => setMobileFocusSpacerHeight(0)}
onInputFocus={scrollKatexToViewportTop}
onSubmit={handleSubmit}
/>
) : null}
{currentResult ? (
<FeedbackPanel
result={currentResult}
answerLatex={currentProblem.latexAfter}
isLastProblem={isLastProblem}
onNext={nextProblem}
onNext={handleNextProblem}
/>
) : null}
{!submitted && mobileFocusSpacerHeight > 0 ? (
<div
aria-hidden="true"
className="sm:hidden"
style={{ height: mobileFocusSpacerHeight }}
/>
) : null}
</section>