'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.
*/
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<HTMLDivElement>(null);
const [box, setBox] = useState({ width: 0, height: 0 });
useLayoutEffect(() => {
const el = hostRef.current;
if (!el) return;
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),
);
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
if (el.parentElement) ro.observe(el.parentElement);
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);
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 (
<div
ref={hostRef}
className="flex h-full min-h-0 w-full items-center justify-center overflow-hidden"
>
<div
className={cn('relative', className)}
style={
box.width > 0
? { width: box.width, height: box.height }
: { width: '100%', height: '100%' }
}
>
{children}
</div>
</div>
);
}