11'use client' ;
22
3- import { use , useEffect , useState } from 'react' ;
3+ import { use , useEffect , useRef , useState } from 'react' ;
44import { usePathname , useRouter } from 'next/navigation' ;
55import { useExerciseContext } from '@/state/ExerciseContext' ;
66import { 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 >
0 commit comments