/** * 인지검사 공용 러너 (PRD §3.1) * * 가로 전체화면 · 모바일 가로모드 고정 · 하단메뉴/광고 없음 * [X] 코드명 [진행률바(한줄)] [원형타이머] ← compact * 3-2-1 카운트다운 → 문제 → 타이머 → 다음 단계 */ import { useCallback, useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import styled from 'styled-components'; import { useOrientationLock } from '../hooks/useOrientationLock'; import { useWakeLock } from '../hooks/useWakeLock'; import { usePlayTime } from '../hooks/usePlayTime'; import { ALLOW_DESKTOP } from '../lib/env'; import { isIOS, supportsElementFullscreen } from '../lib/browser'; import type { IntroContent } from '../data/tests'; import CountdownOverlay from './CountdownOverlay'; import CircularTimer from './CircularTimer'; import ExitModal from './ExitModal'; import IntroScreen from './IntroScreen'; import TestProgress from './TestProgress'; export interface StepCtx { index: number; isLast: boolean; record: (result: unknown) => void; advance: () => void; playTime: number; } interface Props { code: string; introContent?: IntroContent; totalSteps: number; durations: number[]; renderStep: (ctx: StepCtx) => React.ReactNode; onTimeout: (index: number, record: (r: unknown) => void, advance: () => void) => void; onSubmit: (results: unknown[]) => Promise; } /* ─── 스타일 ────────────────────────────────────── */ const Shell = styled.div` position: fixed; top: 0; left: 0; right: 0; height: var(--test-vh, 100dvh); background: #f0f2f7; padding: 8px; display: flex; flex-direction: column; gap: 8px; overflow: hidden; `; const Header = styled.div` display: flex; align-items: center; justify-content: space-between; min-height: 42px; gap: 12px; `; const Left = styled.div` display: flex; align-items: center; gap: 10px; flex: 0 0 auto; `; const BackBtn = styled.button` font-size: 20px; padding: 8px 10px; margin-left: -6px; color: var(--pcnt-text); touch-action: manipulation; /* 모바일에서 탭 딜레이(더블탭 방지 대기) 제거 */ `; const Title = styled.h2` font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 0 0 auto; `; const ProgressWrap = styled.div` flex: 1 1 auto; min-width: 100px; max-width: none; /* 헤더에서 가능한 한 길게 */ `; const DesktopBlock = styled.div` display: flex; align-items: center; justify-content: center; height: var(--test-vh, 100vh); text-align: center; font-size: 17px; font-weight: 700; padding: 24px; `; const RotateOverlay = styled.div` position: fixed; top: 0; left: 0; right: 0; height: var(--test-vh, 100dvh); z-index: 500; background: #1f2430; color: #fff; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px; font-size: 18px; text-align: center; padding: 32px; `; const DoneBox = styled.div` display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 60vh; gap: 16px; font-size: 20px; font-weight: 700; `; /* ─── 컴포넌트 ─────────────────────────────────── */ export default function TestRunner({ code, introContent, totalSteps, durations, renderStep, onTimeout, onSubmit, }: Props) { const nav = useNavigate(); const orient = useOrientationLock(); useWakeLock(true); const [index, setIndex] = useState(0); const [results, setResults] = useState([]); const [intro, setIntro] = useState(!!introContent); const [countdown, setCountdown] = useState(true); const [exitModal, setExitModal] = useState(false); const [done, setDone] = useState(false); const [submitting, setSubmitting] = useState(false); const advancedRef = useRef(false); const exitingRef = useRef(false); const handleIntroStart = useCallback(() => setIntro(false), []); // 타이머 일시정지: 안내 / 카운트다운 / 회전 안내 / 종료 모달 중에는 멈춤 const timerPaused = intro || countdown || orient.isPortrait || done || exitModal; // playTime 도 동일 조건에서 정지 const playTime = usePlayTime( index, !timerPaused, ); /* ─── 효과: --test-vh CSS 변수를 실제 보이는 영역 높이로 설정 ─── 모바일에서 vh/dvh/visualViewport 가 브라우저 UI(주소창)나 시스템 하단 내비바 때문에 실제 화면보다 크게 잡히면 콘텐츠 하단이 잘린다. 특히 풀스크린 진입 시 visualViewport.height 가 화면 전체(=시스템바 미반영)를 반환해, 갤럭시 등에서 하단 내비바(|||ㅁ<)가 내용을 덮는다. → 풀스크린이 아닐 때 시스템바가 덮는 높이(overlap)를 측정해 두고, 풀스크린에서는 그만큼 빼서 하단이 가려지지 않게 한다. */ useEffect(() => { let overlap = 0; const setVh = () => { const vv = window.visualViewport; const visible = vv ? vv.height : window.innerHeight; const layout = window.innerHeight; if (!document.fullscreenElement) { // 비풀스크린: 레이아웃 뷰포트와 보이는 영역의 차 = 시스템바가 덮는 높이 overlap = Math.max(0, layout - visible); } let h = visible; if (document.fullscreenElement && visible >= layout) { // 풀스크린에서 visualViewport 가 화면 전체를 차지하면(=시스템바 미반영) 보정 h = visible - overlap; } document.documentElement.style.setProperty('--test-vh', `${Math.max(0, h)}px`); }; setVh(); const vv = window.visualViewport; vv?.addEventListener('resize', setVh); vv?.addEventListener('scroll', setVh); window.addEventListener('resize', setVh); document.addEventListener('fullscreenchange', setVh); return () => { vv?.removeEventListener('resize', setVh); vv?.removeEventListener('scroll', setVh); window.removeEventListener('resize', setVh); document.removeEventListener('fullscreenchange', setVh); }; }, []); /* ─── 효과: 풀스크린 유지 + 오리엔테이션잠금 + 뒤로가기 방지 ─── iOS Safari는 요소 전체화면·orientation.lock 미지원. 지원 여부와 무관하게 반복 시도하면 Safari가 프리즈되어 백색 화면이 되므로, iOS에서는 해당 시도/주소창 숨김을 하지 않고 브라우저 크롬이 보인 채로 동작한다. (수정사항-019) */ useEffect(() => { if (done) return; // 해제는 releaseControl()에서 처리 — skip setup const canFs = supportsElementFullscreen(); const iOS = isIOS(); document.documentElement.classList.add('test-landscape'); if (orient.canLockOrientation) orient.requestLock(); const el = document.documentElement; const tryFs = () => { if (!document.fullscreenElement && el.requestFullscreen) { el.requestFullscreen({ navigationUI: 'hide' }).catch?.(() => {}); } }; if (canFs) tryFs(); // 주소창 숨기기: 스크롤로 밀어내기 — iOS는 미지원이므로 스킵 let hideTimeout: number; const hideAddressBar = () => { window.scrollTo(0, 1); // 추가 지연 후 한 번 더 setTimeout(() => window.scrollTo(0, 1), 100); }; const onTouchStart = () => { clearTimeout(hideTimeout); hideTimeout = window.setTimeout(hideAddressBar, 300); }; if (!iOS) { hideAddressBar(); // 터치/스와이프 후에도 주소창이 다시 나타나면 자동으로 숨김 document.addEventListener('touchstart', onTouchStart, { passive: true }); } // 풀스크린이 풀렸을 때(터치/swipe) 자동 재진입 (주소창 재등장 방지) // 단, [종료]로 의도적으로 해제하는 중(exitingRef)에는 재진입하지 않는다. const onFsChange = () => { if (canFs && !document.fullscreenElement && !done && !exitingRef.current) { tryFs(); } }; document.addEventListener('fullscreenchange', onFsChange); /* 뒤로가기 가로채기 — 검사 중에는 브라우저 종료/뒤로 페이지로 이동 금지 */ history.pushState({ testLock: true }, '', location.href); const onPopState = () => { history.pushState({ testLock: true }, '', location.href); }; window.addEventListener('popstate', onPopState); function cleanup() { if (!iOS) { clearTimeout(hideTimeout); document.removeEventListener('touchstart', onTouchStart); } document.removeEventListener('fullscreenchange', onFsChange); window.removeEventListener('popstate', onPopState); document.documentElement.classList.remove('test-landscape'); // 풀스크린 해제는 onExit에서 명시적으로 호출 (cleanup 시점 X) } return cleanup; }, [orient, done]); /* ─── 가로 고정·풀스크린 해제 + 검사 리스트로 이동 ─── */ const releaseControl = useCallback(() => { exitingRef.current = true; try { const o: any = screen.orientation ?? (screen as any).msOrientation; o?.unlock?.(); } catch { /* */ } try { if (document.fullscreenElement) document.exitFullscreen?.().catch?.(() => {}); } catch { /* */ } document.documentElement.classList.remove('test-landscape'); }, []); const navigateToTestList = useCallback(() => { releaseControl(); nav('/test', { replace: true }); }, [releaseControl, nav]); /* ─── 로직 ─── */ const goNext = useCallback(() => { if (index >= totalSteps - 1) { setDone(true); } else { advancedRef.current = false; setIndex(index + 1); } }, [index, totalSteps]); const record = useCallback( (result: unknown) => { setResults((prev) => { const next = [...prev]; next[index] = result; return next; }); goNext(); }, [index, goNext], ); const advance = useCallback(() => { if (advancedRef.current) return; advancedRef.current = true; goNext(); }, [goNext]); // 타이머 만료: 모달/회전 중이면 무시 (단계 고정) const handleTimeout = useCallback(() => { if (advancedRef.current || exitModal) return; advancedRef.current = true; onTimeout(index, record, advance); }, [index, onTimeout, record, advance, exitModal]); useEffect(() => { if (done && !submitting) { setSubmitting(true); // 저장 성공/실패와 무관하게 가로잠금 해제 + 검사 리스트(/test)로 이동. onSubmit(results) .then(() => navigateToTestList()) .catch((e) => { console.error('검사 결과 저장 실패:', e); navigateToTestList(); }); } }, [done, submitting, results, onSubmit, navigateToTestList]); // 종료 동작 — releaseControl에서 해제 후 검사 리스트로 이동 const handleExit = useCallback(() => { navigateToTestList(); }, [navigateToTestList]); /* ─── 분기 렌더 ─── */ if (!orient.isMobile && !ALLOW_DESKTOP) { return 모바일 환경에서만 가능합니다.; } if (orient.isPortrait) { return (
📱
기기를 가로로 회전해 주세요. 가로로 돌리면{intro ? ' 안내 화면이' : countdown ? ' 검사가 자동 시작' : ' 잠시 후 이어집니다'} 됩니다.
); } if (intro && introContent) { return (
{ e.stopPropagation(); setExitModal(true); }} onClick={(e) => { e.stopPropagation(); setExitModal(true); }} > ✕ {code} 테스트
{exitModal && ( setExitModal(false)} onExit={handleExit} /> )}
); } if (countdown) { return setCountdown(false)} />; } if (done) { return 🎉 검사가 완료되었습니다!; } const ctx: StepCtx = { index, isLast: index === totalSteps - 1, record, advance, playTime }; return (
{ // 모바일에서 클릭 합성이 늦어지거나 무시되는 경우 대비 — 터치 즉시 팝업 e.stopPropagation(); setExitModal(true); }} onClick={(e) => { e.stopPropagation(); setExitModal(true); }} > ✕ {code} 테스트
{renderStep(ctx)} {exitModal && ( setExitModal(false)} onExit={handleExit} /> )}
); }