Skip to content

Commit 6de75cf

Browse files
authored
Merge pull request #8 from Aeshus/main
please fix mobile?
2 parents 4ceaa40 + 671ab73 commit 6de75cf

6 files changed

Lines changed: 120 additions & 21 deletions

File tree

src/app/exercise/[id]/ExercisePageClient.tsx

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client';
22

3-
import { use, useEffect, useState } from 'react';
3+
import { use, useEffect, useRef, useState } from 'react';
44
import { usePathname, useRouter } from 'next/navigation';
55
import { useExerciseContext } from '@/state/ExerciseContext';
66
import { getExercise, getAllExercises } from '@/exercises/registry';
@@ -150,6 +150,10 @@ export default function ExercisePageClient({ params }: { params: Promise<{ id: s
150150
const pathname = usePathname();
151151
const [isMobile, setIsMobile] = useState(false);
152152
const [activeMobileTab, setActiveMobileTab] = useState<'source' | 'viz' | 'log' | 'misc'>('source');
153+
const mobileShellRef = useRef<HTMLDivElement | null>(null);
154+
const mobileWorkspacePanelRef = useRef<HTMLDivElement | null>(null);
155+
const mobileDirectionsRef = useRef<HTMLDivElement | null>(null);
156+
const mobileBottomDockRef = useRef<HTMLDivElement | null>(null);
153157

154158
useEffect(() => {
155159
if (typeof window === 'undefined') return;
@@ -205,6 +209,67 @@ export default function ExercisePageClient({ params }: { params: Promise<{ id: s
205209
setActiveMobileTab('source');
206210
}, [id]);
207211

212+
useEffect(() => {
213+
if (!isMobile) {
214+
mobileShellRef.current?.style.removeProperty('--mobile-workspace-panel-height');
215+
return;
216+
}
217+
218+
const shellElement = mobileShellRef.current;
219+
const panelElement = mobileWorkspacePanelRef.current;
220+
const directionsElement = mobileDirectionsRef.current;
221+
const bottomDockElement = mobileBottomDockRef.current;
222+
223+
if (!shellElement || !panelElement || !directionsElement || !bottomDockElement) {
224+
return;
225+
}
226+
227+
let frameId = 0;
228+
229+
const syncPanelHeight = () => {
230+
frameId = 0;
231+
const shellRect = shellElement.getBoundingClientRect();
232+
const panelRect = panelElement.getBoundingClientRect();
233+
const bottomDockRect = bottomDockElement.getBoundingClientRect();
234+
const shellStyles = window.getComputedStyle(shellElement);
235+
const shellGap = parseFloat(shellStyles.rowGap || shellStyles.gap || '0') || 0;
236+
const availableHeight = Math.floor(bottomDockRect.top - panelRect.top - shellGap);
237+
const maxAvailableHeight = Math.floor(shellRect.bottom - panelRect.top);
238+
const nextHeight = Math.max(0, Math.min(availableHeight, maxAvailableHeight));
239+
shellElement.style.setProperty('--mobile-workspace-panel-height', `${nextHeight}px`);
240+
};
241+
242+
const queueSyncPanelHeight = () => {
243+
if (frameId !== 0) return;
244+
frameId = window.requestAnimationFrame(syncPanelHeight);
245+
};
246+
247+
queueSyncPanelHeight();
248+
249+
const observer = new ResizeObserver(() => {
250+
queueSyncPanelHeight();
251+
});
252+
253+
observer.observe(shellElement);
254+
observer.observe(directionsElement);
255+
observer.observe(bottomDockElement);
256+
257+
window.addEventListener('resize', queueSyncPanelHeight);
258+
window.visualViewport?.addEventListener('resize', queueSyncPanelHeight);
259+
window.visualViewport?.addEventListener('scroll', queueSyncPanelHeight);
260+
261+
return () => {
262+
observer.disconnect();
263+
window.removeEventListener('resize', queueSyncPanelHeight);
264+
window.visualViewport?.removeEventListener('resize', queueSyncPanelHeight);
265+
window.visualViewport?.removeEventListener('scroll', queueSyncPanelHeight);
266+
if (frameId !== 0) {
267+
window.cancelAnimationFrame(frameId);
268+
}
269+
shellElement.style.removeProperty('--mobile-workspace-panel-height');
270+
};
271+
}, [activeMobileTab, isMobile]);
272+
208273
useEffect(() => {
209274
const mainElement = document.querySelector('#app-body > main');
210275
const appElement = document.getElementById('app');
@@ -447,8 +512,10 @@ export default function ExercisePageClient({ params }: { params: Promise<{ id: s
447512

448513
if (isMobile) {
449514
return (
450-
<div className="mobile-exercise-shell">
451-
<ExerciseDirectionsPanel />
515+
<div className="mobile-exercise-shell" ref={mobileShellRef}>
516+
<div ref={mobileDirectionsRef}>
517+
<ExerciseDirectionsPanel />
518+
</div>
452519

453520
<section className="mobile-workspace">
454521
<div className="mobile-workspace-tabs" role="tablist" aria-label="Exercise workspace">
@@ -490,7 +557,7 @@ export default function ExercisePageClient({ params }: { params: Promise<{ id: s
490557
</button>
491558
</div>
492559

493-
<div className="mobile-workspace-panel">
560+
<div className="mobile-workspace-panel" ref={mobileWorkspacePanelRef}>
494561
{activeMobileTab === 'source' && <SourcePanel showDescription={false} />}
495562
{activeMobileTab === 'viz' && (
496563
<ErrorBoundary>
@@ -509,7 +576,7 @@ export default function ExercisePageClient({ params }: { params: Promise<{ id: s
509576
</div>
510577
</section>
511578

512-
<div className="mobile-bottom-dock">
579+
<div className="mobile-bottom-dock" ref={mobileBottomDockRef}>
513580
<ErrorBoundary>
514581
<InputPanel showToolkit={false} />
515582
</ErrorBoundary>

src/app/globals.css

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -980,7 +980,6 @@ button.link-button:focus-visible,
980980

981981
.mobile-workspace {
982982
min-height: 0;
983-
flex: 1;
984983
display: flex;
985984
flex-direction: column;
986985
gap: 0.5rem;
@@ -1022,15 +1021,18 @@ button.link-button:focus-visible,
10221021

10231022
.mobile-workspace-panel {
10241023
display: flex;
1025-
flex: 1 1 auto;
1024+
flex: 0 0 auto;
10261025
flex-direction: column;
1026+
height: var(--mobile-workspace-panel-height, auto);
1027+
max-height: var(--mobile-workspace-panel-height, none);
10271028
min-height: 0;
10281029
overflow: hidden;
10291030
}
10301031

10311032
.mobile-workspace-panel > .panel {
1032-
flex: 1 1 auto;
1033-
height: auto;
1033+
flex: 1 1 100%;
1034+
height: 100%;
1035+
max-height: 100%;
10341036
min-height: 0;
10351037
}
10361038

src/components/panels/InputPanel/InputPanel.tsx

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client';
22

3-
import { useState } from 'react';
3+
import { useEffect, useState } from 'react';
44
import { useExerciseContext } from '@/state/ExerciseContext';
55
import StepControls from './inputs/StepControls';
66
import TextHexInput from './inputs/TextHexInput';
@@ -15,10 +15,31 @@ import AsmStepInput from './inputs/AsmStepInput';
1515
import AsmQuizInput from './inputs/AsmQuizInput';
1616
import Toolkit from './Toolkit';
1717

18+
const MOBILE_BREAKPOINT = '(max-width: 900px)';
19+
1820
export default function InputPanel({ showToolkit = true }: { showToolkit?: boolean }) {
1921
const { currentExercise, asmEmulator, state } = useExerciseContext();
2022
const ex = currentExercise;
2123
const [collapsed, setCollapsed] = useState(false);
24+
const [isMobile, setIsMobile] = useState(false);
25+
26+
useEffect(() => {
27+
if (typeof window === 'undefined') return;
28+
29+
const mediaQuery = window.matchMedia(MOBILE_BREAKPOINT);
30+
const syncIsMobile = (event?: MediaQueryListEvent) => {
31+
setIsMobile(event?.matches ?? mediaQuery.matches);
32+
};
33+
34+
syncIsMobile();
35+
if (typeof mediaQuery.addEventListener === 'function') {
36+
mediaQuery.addEventListener('change', syncIsMobile);
37+
return () => mediaQuery.removeEventListener('change', syncIsMobile);
38+
}
39+
40+
mediaQuery.addListener(syncIsMobile);
41+
return () => mediaQuery.removeListener(syncIsMobile);
42+
}, []);
2243

2344
let content: React.ReactNode;
2445
if (!ex) {
@@ -86,14 +107,16 @@ export default function InputPanel({ showToolkit = true }: { showToolkit?: boole
86107
{state.inputProgress && (
87108
<span className="input-panel-progress">{state.inputProgress}</span>
88109
)}
89-
<button
90-
type="button"
91-
className="input-panel-action"
92-
aria-expanded={!collapsed}
93-
onClick={() => setCollapsed((prev) => !prev)}
94-
>
95-
{collapsed ? 'Expand' : 'Minimize'}
96-
</button>
110+
{isMobile && (
111+
<button
112+
type="button"
113+
className="input-panel-action"
114+
aria-expanded={!collapsed}
115+
onClick={() => setCollapsed((prev) => !prev)}
116+
>
117+
{collapsed ? 'Expand' : 'Minimize'}
118+
</button>
119+
)}
97120
</div>
98121
</div>
99122
{!collapsed && (

src/state/ExerciseContext.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import React, { createContext, useContext, useReducer, useRef, useEffect } from 'react';
44
import { AppState, Action } from './types';
55
import { reducer, createInitialState } from './reducer';
6-
import { saveProgress } from './persistence';
6+
import { loadProgress, saveProgress } from './persistence';
77
import { StackSim } from '@/engine/simulators/StackSim';
88
import { HeapSim } from '@/engine/simulators/HeapSim';
99
import { WinHeapSim } from '@/engine/simulators/WinHeapSim';
@@ -35,6 +35,10 @@ export function ExerciseContextProvider({ children }: { children: React.ReactNod
3535
? getExercise(state.currentExerciseId) ?? null
3636
: null;
3737

38+
useEffect(() => {
39+
dispatch({ type: 'HYDRATE_COMPLETED', completed: loadProgress() });
40+
}, []);
41+
3842
// Persist completed exercises whenever they change
3943
const completedRef = useRef(state.completed);
4044
useEffect(() => {

src/state/reducer.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import { AppState, Action } from './types';
22
import { BASE_SYMBOLS } from '@/exercises/shared/symbols';
3-
import { loadProgress } from './persistence';
43

54
export function createInitialState(): AppState {
65
return {
76
currentExerciseId: null,
8-
completed: loadProgress(),
7+
completed: new Set(),
98
logMessages: [],
109
inputMode: 'text',
1110
inputProgress: null,
@@ -34,6 +33,9 @@ export function createInitialState(): AppState {
3433

3534
export function reducer(state: AppState, action: Action): AppState {
3635
switch (action.type) {
36+
case 'HYDRATE_COMPLETED':
37+
return { ...state, completed: new Set(action.completed) };
38+
3739
case 'LOAD_EXERCISE':
3840
return {
3941
...state,

src/state/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export interface AppState {
2727
}
2828

2929
export type Action =
30+
| { type: 'HYDRATE_COMPLETED'; completed: Set<string> }
3031
| { type: 'LOAD_EXERCISE'; exerciseId: string }
3132
| { type: 'LOG'; cls: string; msg: string }
3233
| { type: 'LOG_BATCH'; messages: Array<{ cls: string; msg: string }> }

0 commit comments

Comments
 (0)