'use client'; import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'; import { CLASSROOM_ASPECT_RATIO, containBox, fillWidthBox } from '@/lib/edit/contain-box'; import { cn } from '@/lib/utils'; /** * 16:9 stage box. * `contain` — largest box that fits (letterbox). * `fill-width` — as wide as the host; extra height is clipped. Dragging the * workbench bar then grows the canvas instead of a white strip. */ /** CSS properties whose transition end can settle the host's size. */ const LAYOUT_PROPERTIES = new Set([ 'width', 'height', 'min-width', 'max-width', 'min-height', 'max-height', 'flex', 'flex-basis', 'flex-grow', 'flex-shrink', 'gap', 'row-gap', 'column-gap', 'grid-template-columns', 'grid-template-rows', 'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', 'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'border-width', 'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width', 'inset', 'top', 'right', 'bottom', 'left', ]); export function ContainBox({ ratio = CLASSROOM_ASPECT_RATIO, fit = 'contain', className, children, }: { readonly ratio?: number; readonly fit?: 'contain' | 'fill-width'; readonly className?: string; readonly children: ReactNode; }) { const hostRef = useRef(null); const [box, setBox] = useState({ width: 0, height: 0 }); useLayoutEffect(() => { const el = hostRef.current; if (!el) return; // Publish only when the box actually changed. The rAF settle re-measures // and the transition/animation listeners can report a size that already // rendered, and an unconditional setBox on every ResizeObserver delivery // would re-render for identical boxes — with the extra re-measure passes // below that churn would also run for every settle, not just real changes. const apply = (next: { readonly width: number; readonly height: number }) => { setBox((current) => current.width === next.width && current.height === next.height ? current : next, ); }; const measure = () => { apply( fit === 'fill-width' ? fillWidthBox(el.clientWidth, ratio) : containBox(el.clientWidth, el.clientHeight, ratio), ); }; // First-paint measurement. When a course opens, the classroom pane mounts // while the workbench is still settling (the chat pane re-flows from fill // to fixed width, the pane's own column lands a frame or two later, the // scene rail can mount async once its list loads, fonts shift content) — // so the host box read here can be a stale full-width, or zero while the // pane is hidden. Any ONE of those can land later than a couple of rAFs, // which is what left first-open canvases mis-sized until a seam drag // re-measured. Instead of betting on the right instant, measure every // frame for a bounded settle window after mount; `apply` publishes only on // change, so identical reads are free and there is no render churn. measure(); const ro = new ResizeObserver(measure); ro.observe(el); // The frame is the host's sizing ancestor; watching it too catches a // squeeze that lands on the frame one frame before the host's own box // updates (rail width hydration, grid track redistribution). if (el.parentElement) ro.observe(el.parentElement); // Backstop: a layout settlement has been observed in the wild that left // the host at a stale size with no further RO delivery (first-open with // the slide rail expanded stayed mis-sized until a seam drag). Re-measure // on a slow interval for the lifetime of the mount; `apply` publishes only // on change, so a stable layout costs one cheap clientWidth read per tick. const backstop = window.setInterval(measure, 400); let settleRaf = 0; let settleUntil = 0; const SETTLE_WINDOW_MS = 1500; const loop = () => { measure(); settleRaf = performance.now() < settleUntil ? requestAnimationFrame(loop) : 0; }; const kick = (ms: number) => { settleUntil = performance.now() + ms; if (!settleRaf) settleRaf = requestAnimationFrame(loop); }; kick(SETTLE_WINDOW_MS); // The pane can also settle through a width transition or animation that // the ResizeObserver only tracks frame by frame (the scene rail's 0.3s // width transition, a pane column animating in). The transition/animation // END is the authoritative "settled" signal — restart a short settle // window on it. Listened on the document in capture phase because the // transitioning element is usually an ancestor or sibling of the host // (rail, chat pane, pane column) — a listener on the host subtree would // never see those. `transitionend` is filtered to layout-affecting // properties; `animationend` fires once by definition. const onSettled = (event: Event) => { if (event.type === 'transitionend') { const { propertyName } = event as TransitionEvent; if (propertyName && !LAYOUT_PROPERTIES.has(propertyName)) return; } kick(400); }; document.addEventListener('transitionend', onSettled, true); document.addEventListener('animationend', onSettled, true); return () => { window.clearInterval(backstop); if (settleRaf) cancelAnimationFrame(settleRaf); settleRaf = 0; document.removeEventListener('transitionend', onSettled, true); document.removeEventListener('animationend', onSettled, true); ro.disconnect(); }; }, [fit, ratio]); return (
0 ? { width: box.width, height: box.height } : { width: '100%', height: '100%' } } > {children}
); }