forked from zhongyidian/yx-react-template
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
135 lines
3.2 KiB
TypeScript
135 lines
3.2 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { CanvasContext } from "./CanvasContext";
|
|
import type {
|
|
CourseCanvasContextValue,
|
|
CourseCanvasProps,
|
|
CourseCanvasViewport,
|
|
} from "./CourseCanvas.types";
|
|
import { useCanvasScale } from "./useCanvasScale";
|
|
|
|
const DEFAULT_WIDTH = 1920;
|
|
const DEFAULT_HEIGHT = 1080;
|
|
|
|
export function CourseCanvas({
|
|
children,
|
|
width = DEFAULT_WIDTH,
|
|
height = DEFAULT_HEIGHT,
|
|
maxScale = 1,
|
|
className,
|
|
canvasClassName,
|
|
portalClassName,
|
|
}: CourseCanvasProps) {
|
|
const scale = useCanvasScale({ width, height, maxScale });
|
|
const frameRef = useRef<HTMLDivElement>(null);
|
|
const portalContainer = useRef<HTMLDivElement>(null);
|
|
const [viewport, setViewport] = useState<CourseCanvasViewport>({
|
|
width,
|
|
height,
|
|
scale,
|
|
originLeft: 0,
|
|
originTop: 0,
|
|
});
|
|
|
|
const measure = useCallback(() => {
|
|
const frame = frameRef.current;
|
|
if (!frame) return;
|
|
const rect = frame.getBoundingClientRect();
|
|
const committedScale = rect.width > 0 ? rect.width / width : scale;
|
|
const next = {
|
|
width,
|
|
height,
|
|
scale: committedScale,
|
|
originLeft: rect.left,
|
|
originTop: rect.top,
|
|
};
|
|
setViewport((current) =>
|
|
current.width === next.width &&
|
|
current.height === next.height &&
|
|
current.scale === next.scale &&
|
|
current.originLeft === next.originLeft &&
|
|
current.originTop === next.originTop
|
|
? current
|
|
: next,
|
|
);
|
|
}, [height, scale, width]);
|
|
|
|
useEffect(() => {
|
|
const frame = frameRef.current;
|
|
let animationFrame = requestAnimationFrame(measure);
|
|
const scheduleMeasure = () => {
|
|
cancelAnimationFrame(animationFrame);
|
|
animationFrame = requestAnimationFrame(measure);
|
|
};
|
|
const observer =
|
|
typeof ResizeObserver === "undefined"
|
|
? null
|
|
: new ResizeObserver(scheduleMeasure);
|
|
|
|
if (frame) observer?.observe(frame);
|
|
observer?.observe(document.documentElement);
|
|
window.addEventListener("resize", scheduleMeasure);
|
|
window.visualViewport?.addEventListener("resize", scheduleMeasure);
|
|
|
|
return () => {
|
|
cancelAnimationFrame(animationFrame);
|
|
observer?.disconnect();
|
|
window.removeEventListener("resize", scheduleMeasure);
|
|
window.visualViewport?.removeEventListener("resize", scheduleMeasure);
|
|
};
|
|
}, [measure]);
|
|
|
|
const value = useMemo<CourseCanvasContextValue>(
|
|
() => ({ width, height, scale, portalContainer, viewport }),
|
|
[height, scale, viewport, width],
|
|
);
|
|
|
|
return (
|
|
<CanvasContext.Provider value={value}>
|
|
<div
|
|
className={className}
|
|
style={{
|
|
display: "flex",
|
|
width: "100%",
|
|
height: "100%",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
<div
|
|
ref={frameRef}
|
|
style={{
|
|
position: "relative",
|
|
width: width * scale,
|
|
height: height * scale,
|
|
flex: "none",
|
|
}}
|
|
>
|
|
<div
|
|
className={canvasClassName}
|
|
style={{
|
|
position: "relative",
|
|
width,
|
|
height,
|
|
transform: `scale(${scale})`,
|
|
transformOrigin: "left top",
|
|
}}
|
|
>
|
|
{children}
|
|
<div
|
|
ref={portalContainer}
|
|
data-course-portal-root=""
|
|
className={portalClassName}
|
|
style={{
|
|
position: "absolute",
|
|
inset: 0,
|
|
pointerEvents: "none",
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CanvasContext.Provider>
|
|
);
|
|
}
|