diff --git a/src/app/exercise/[id]/ExercisePageClient.tsx b/src/app/exercise/[id]/ExercisePageClient.tsx index 83de890..d80c3bf 100644 --- a/src/app/exercise/[id]/ExercisePageClient.tsx +++ b/src/app/exercise/[id]/ExercisePageClient.tsx @@ -1,6 +1,6 @@ 'use client'; -import { use, useEffect, useState } from 'react'; +import { use, useEffect, useRef, useState } from 'react'; import { usePathname, useRouter } from 'next/navigation'; import { useExerciseContext } from '@/state/ExerciseContext'; import { getExercise, getAllExercises } from '@/exercises/registry'; @@ -150,6 +150,10 @@ export default function ExercisePageClient({ params }: { params: Promise<{ id: s const pathname = usePathname(); const [isMobile, setIsMobile] = useState(false); const [activeMobileTab, setActiveMobileTab] = useState<'source' | 'viz' | 'log' | 'misc'>('source'); + const mobileShellRef = useRef(null); + const mobileWorkspacePanelRef = useRef(null); + const mobileDirectionsRef = useRef(null); + const mobileBottomDockRef = useRef(null); useEffect(() => { if (typeof window === 'undefined') return; @@ -205,6 +209,67 @@ export default function ExercisePageClient({ params }: { params: Promise<{ id: s setActiveMobileTab('source'); }, [id]); + useEffect(() => { + if (!isMobile) { + mobileShellRef.current?.style.removeProperty('--mobile-workspace-panel-height'); + return; + } + + const shellElement = mobileShellRef.current; + const panelElement = mobileWorkspacePanelRef.current; + const directionsElement = mobileDirectionsRef.current; + const bottomDockElement = mobileBottomDockRef.current; + + if (!shellElement || !panelElement || !directionsElement || !bottomDockElement) { + return; + } + + let frameId = 0; + + const syncPanelHeight = () => { + frameId = 0; + const shellRect = shellElement.getBoundingClientRect(); + const panelRect = panelElement.getBoundingClientRect(); + const bottomDockRect = bottomDockElement.getBoundingClientRect(); + const shellStyles = window.getComputedStyle(shellElement); + const shellGap = parseFloat(shellStyles.rowGap || shellStyles.gap || '0') || 0; + const availableHeight = Math.floor(bottomDockRect.top - panelRect.top - shellGap); + const maxAvailableHeight = Math.floor(shellRect.bottom - panelRect.top); + const nextHeight = Math.max(0, Math.min(availableHeight, maxAvailableHeight)); + shellElement.style.setProperty('--mobile-workspace-panel-height', `${nextHeight}px`); + }; + + const queueSyncPanelHeight = () => { + if (frameId !== 0) return; + frameId = window.requestAnimationFrame(syncPanelHeight); + }; + + queueSyncPanelHeight(); + + const observer = new ResizeObserver(() => { + queueSyncPanelHeight(); + }); + + observer.observe(shellElement); + observer.observe(directionsElement); + observer.observe(bottomDockElement); + + window.addEventListener('resize', queueSyncPanelHeight); + window.visualViewport?.addEventListener('resize', queueSyncPanelHeight); + window.visualViewport?.addEventListener('scroll', queueSyncPanelHeight); + + return () => { + observer.disconnect(); + window.removeEventListener('resize', queueSyncPanelHeight); + window.visualViewport?.removeEventListener('resize', queueSyncPanelHeight); + window.visualViewport?.removeEventListener('scroll', queueSyncPanelHeight); + if (frameId !== 0) { + window.cancelAnimationFrame(frameId); + } + shellElement.style.removeProperty('--mobile-workspace-panel-height'); + }; + }, [activeMobileTab, isMobile]); + useEffect(() => { const mainElement = document.querySelector('#app-body > main'); const appElement = document.getElementById('app'); @@ -447,8 +512,10 @@ export default function ExercisePageClient({ params }: { params: Promise<{ id: s if (isMobile) { return ( -
- +
+
+ +
@@ -490,7 +557,7 @@ export default function ExercisePageClient({ params }: { params: Promise<{ id: s
-
+
{activeMobileTab === 'source' && } {activeMobileTab === 'viz' && ( @@ -509,7 +576,7 @@ export default function ExercisePageClient({ params }: { params: Promise<{ id: s
-
+
diff --git a/src/app/globals.css b/src/app/globals.css index e527ae1..90d9d76 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -980,7 +980,6 @@ button.link-button:focus-visible, .mobile-workspace { min-height: 0; - flex: 1; display: flex; flex-direction: column; gap: 0.5rem; @@ -1022,15 +1021,18 @@ button.link-button:focus-visible, .mobile-workspace-panel { display: flex; - flex: 1 1 auto; + flex: 0 0 auto; flex-direction: column; + height: var(--mobile-workspace-panel-height, auto); + max-height: var(--mobile-workspace-panel-height, none); min-height: 0; overflow: hidden; } .mobile-workspace-panel > .panel { - flex: 1 1 auto; - height: auto; + flex: 1 1 100%; + height: 100%; + max-height: 100%; min-height: 0; } diff --git a/src/components/panels/InputPanel/InputPanel.tsx b/src/components/panels/InputPanel/InputPanel.tsx index ad2a10a..c456319 100644 --- a/src/components/panels/InputPanel/InputPanel.tsx +++ b/src/components/panels/InputPanel/InputPanel.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useExerciseContext } from '@/state/ExerciseContext'; import StepControls from './inputs/StepControls'; import TextHexInput from './inputs/TextHexInput'; @@ -15,10 +15,31 @@ import AsmStepInput from './inputs/AsmStepInput'; import AsmQuizInput from './inputs/AsmQuizInput'; import Toolkit from './Toolkit'; +const MOBILE_BREAKPOINT = '(max-width: 900px)'; + export default function InputPanel({ showToolkit = true }: { showToolkit?: boolean }) { const { currentExercise, asmEmulator, state } = useExerciseContext(); const ex = currentExercise; const [collapsed, setCollapsed] = useState(false); + const [isMobile, setIsMobile] = useState(false); + + useEffect(() => { + if (typeof window === 'undefined') return; + + const mediaQuery = window.matchMedia(MOBILE_BREAKPOINT); + const syncIsMobile = (event?: MediaQueryListEvent) => { + setIsMobile(event?.matches ?? mediaQuery.matches); + }; + + syncIsMobile(); + if (typeof mediaQuery.addEventListener === 'function') { + mediaQuery.addEventListener('change', syncIsMobile); + return () => mediaQuery.removeEventListener('change', syncIsMobile); + } + + mediaQuery.addListener(syncIsMobile); + return () => mediaQuery.removeListener(syncIsMobile); + }, []); let content: React.ReactNode; if (!ex) { @@ -86,14 +107,16 @@ export default function InputPanel({ showToolkit = true }: { showToolkit?: boole {state.inputProgress && ( {state.inputProgress} )} - + {isMobile && ( + + )}
{!collapsed && ( diff --git a/src/state/ExerciseContext.tsx b/src/state/ExerciseContext.tsx index 38ed7ad..91ea4da 100644 --- a/src/state/ExerciseContext.tsx +++ b/src/state/ExerciseContext.tsx @@ -3,7 +3,7 @@ import React, { createContext, useContext, useReducer, useRef, useEffect } from 'react'; import { AppState, Action } from './types'; import { reducer, createInitialState } from './reducer'; -import { saveProgress } from './persistence'; +import { loadProgress, saveProgress } from './persistence'; import { StackSim } from '@/engine/simulators/StackSim'; import { HeapSim } from '@/engine/simulators/HeapSim'; import { WinHeapSim } from '@/engine/simulators/WinHeapSim'; @@ -35,6 +35,10 @@ export function ExerciseContextProvider({ children }: { children: React.ReactNod ? getExercise(state.currentExerciseId) ?? null : null; + useEffect(() => { + dispatch({ type: 'HYDRATE_COMPLETED', completed: loadProgress() }); + }, []); + // Persist completed exercises whenever they change const completedRef = useRef(state.completed); useEffect(() => { diff --git a/src/state/reducer.ts b/src/state/reducer.ts index e192de3..b0e0060 100644 --- a/src/state/reducer.ts +++ b/src/state/reducer.ts @@ -1,11 +1,10 @@ import { AppState, Action } from './types'; import { BASE_SYMBOLS } from '@/exercises/shared/symbols'; -import { loadProgress } from './persistence'; export function createInitialState(): AppState { return { currentExerciseId: null, - completed: loadProgress(), + completed: new Set(), logMessages: [], inputMode: 'text', inputProgress: null, @@ -34,6 +33,9 @@ export function createInitialState(): AppState { export function reducer(state: AppState, action: Action): AppState { switch (action.type) { + case 'HYDRATE_COMPLETED': + return { ...state, completed: new Set(action.completed) }; + case 'LOAD_EXERCISE': return { ...state, diff --git a/src/state/types.ts b/src/state/types.ts index cef49a7..00246b3 100644 --- a/src/state/types.ts +++ b/src/state/types.ts @@ -27,6 +27,7 @@ export interface AppState { } export type Action = + | { type: 'HYDRATE_COMPLETED'; completed: Set } | { type: 'LOAD_EXERCISE'; exerciseId: string } | { type: 'LOG'; cls: string; msg: string } | { type: 'LOG_BATCH'; messages: Array<{ cls: string; msg: string }> }