feat: 完善课程模板通用能力

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zhongyidianandClaude Opus 5 committed 2026-09-08 14:07:42 +08:00
1 parent 1e02c7835e
commit a641d0e2ea
70 files changed
+2868 -205

No files matched your search

+134
View File
@@ -0,0 +1,134 @@
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>
);
}