Template
feat: 完善课程模板通用能力
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
1e02c7835e
commit
a641d0e2ea
70 files changed
+2868
-205
No files matched your search
@@ -0,0 +1,14 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import type { CourseCanvasContextValue } from "./CourseCanvas.types";
|
||||
|
||||
export const CanvasContext = createContext<CourseCanvasContextValue | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
export function useCourseCanvas(): CourseCanvasContextValue {
|
||||
const context = useContext(CanvasContext);
|
||||
if (!context) {
|
||||
throw new Error("useCourseCanvas must be used within CourseCanvas");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ReactNode, RefObject } from "react";
|
||||
|
||||
export interface CourseCanvasViewport {
|
||||
width: number;
|
||||
height: number;
|
||||
scale: number;
|
||||
originLeft: number;
|
||||
originTop: number;
|
||||
}
|
||||
|
||||
export interface CourseCanvasContextValue {
|
||||
width: number;
|
||||
height: number;
|
||||
scale: number;
|
||||
portalContainer: RefObject<HTMLDivElement | null>;
|
||||
viewport: CourseCanvasViewport;
|
||||
}
|
||||
|
||||
export interface CourseCanvasProps {
|
||||
children: ReactNode;
|
||||
width?: number;
|
||||
height?: number;
|
||||
maxScale?: number;
|
||||
className?: string;
|
||||
canvasClassName?: string;
|
||||
portalClassName?: string;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { RefObject } from "react";
|
||||
|
||||
let portalRoot: RefObject<HTMLDivElement | null> | null = null;
|
||||
|
||||
export function registerAppPortalRoot(
|
||||
container: RefObject<HTMLDivElement | null>,
|
||||
): () => void {
|
||||
portalRoot = container;
|
||||
return () => {
|
||||
if (portalRoot === container) portalRoot = null;
|
||||
};
|
||||
}
|
||||
|
||||
export function getAppPortalRoot(): HTMLDivElement | null {
|
||||
return portalRoot?.current ?? null;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { CanvasContext, useCourseCanvas } from "./CanvasContext";
|
||||
export { CourseCanvas } from "./CourseCanvas";
|
||||
export type {
|
||||
CourseCanvasContextValue,
|
||||
CourseCanvasProps,
|
||||
CourseCanvasViewport,
|
||||
} from "./CourseCanvas.types";
|
||||
export { getAppPortalRoot, registerAppPortalRoot } from "./canvasPortal";
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface CanvasScaleOptions {
|
||||
width: number;
|
||||
height: number;
|
||||
maxScale: number;
|
||||
}
|
||||
|
||||
function getViewportSize() {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
width: window.visualViewport?.width ?? window.innerWidth,
|
||||
height: window.visualViewport?.height ?? window.innerHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function calculateScale({
|
||||
width,
|
||||
height,
|
||||
maxScale,
|
||||
}: CanvasScaleOptions): number {
|
||||
if (typeof window === "undefined") return maxScale;
|
||||
const viewport = getViewportSize();
|
||||
if (!viewport) return maxScale;
|
||||
return Math.min(viewport.width / width, viewport.height / height, maxScale);
|
||||
}
|
||||
|
||||
export function useCanvasScale({
|
||||
width,
|
||||
height,
|
||||
maxScale,
|
||||
}: CanvasScaleOptions): number {
|
||||
const [scale, setScale] = useState(() =>
|
||||
calculateScale({ width, height, maxScale }),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const updateScale = () =>
|
||||
setScale(calculateScale({ width, height, maxScale }));
|
||||
updateScale();
|
||||
window.addEventListener("resize", updateScale);
|
||||
window.addEventListener("orientationchange", updateScale);
|
||||
window.visualViewport?.addEventListener("resize", updateScale);
|
||||
window.visualViewport?.addEventListener("scroll", updateScale);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", updateScale);
|
||||
window.removeEventListener("orientationchange", updateScale);
|
||||
window.visualViewport?.removeEventListener("resize", updateScale);
|
||||
window.visualViewport?.removeEventListener("scroll", updateScale);
|
||||
};
|
||||
}, [height, maxScale, width]);
|
||||
|
||||
return scale;
|
||||
}
|
||||
Reference in new issue
Block a user