"use client"; import { useCallback, useEffect, useMemo, useRef } from "react"; import useDeepCompareEffect from "use-deep-compare-effect"; import { useEditor } from "@/hooks/use-editor"; import { useRafLoop } from "@/hooks/use-raf-loop"; import { useContainerSize } from "@/hooks/use-container-size"; import { useFullscreen } from "@/hooks/use-fullscreen"; import { CanvasRenderer } from "@/services/renderer/canvas-renderer"; import type { RootNode } from "@/services/renderer/nodes/root-node"; import { buildScene } from "@/services/renderer/scene-builder"; import { PreviewInteractionOverlay } from "./preview-interaction-overlay"; import { BookmarkNoteOverlay } from "./bookmark-note-overlay"; import { GuideOverlay } from "./guide-overlay"; import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu"; import { usePreviewStore } from "@/stores/preview-store"; import { PreviewContextMenu } from "./context-menu"; import { PreviewToolbar } from "./toolbar"; import { PreviewViewportProvider, usePreviewViewportState, } from "./preview-viewport"; function usePreviewSize() { const canvasSize = useEditor( (e) => e.project.getActive()?.settings.canvasSize, ); return { width: canvasSize?.width, height: canvasSize?.height, }; } function normalizeWheelDelta({ delta, deltaMode, pageSize, }: { delta: number; deltaMode: number; pageSize: number; }): number { if (deltaMode === WheelEvent.DOM_DELTA_LINE) { return delta * 16; } if (deltaMode === WheelEvent.DOM_DELTA_PAGE) { return delta * pageSize; } return delta; } export function PreviewPanel() { const containerRef = useRef(null); const { toggleFullscreen } = useFullscreen({ containerRef }); return (
); } function RenderTreeController() { const editor = useEditor(); const tracks = useEditor((e) => e.timeline.getRenderTracks()); const mediaAssets = useEditor((e) => e.media.getAssets()); const activeProject = useEditor((e) => e.project.getActive()); const { width, height } = usePreviewSize(); useDeepCompareEffect(() => { if (!activeProject) return; const duration = editor.timeline.getTotalDuration(); const renderTree = buildScene({ tracks, mediaAssets, duration, canvasSize: { width, height }, background: activeProject.settings.background, isPreview: true, }); editor.renderer.setRenderTree({ renderTree }); }, [tracks, mediaAssets, activeProject?.settings.background, width, height]); return null; } function PreviewCanvas({ containerRef, onToggleFullscreen, }: { containerRef: React.RefObject; onToggleFullscreen: () => void; }) { const canvasRef = useRef(null); const viewportRef = useRef(null); const lastFrameRef = useRef(-1); const lastSceneRef = useRef(null); const renderingRef = useRef(false); const { width: nativeWidth, height: nativeHeight } = usePreviewSize(); const viewportSize = useContainerSize({ containerRef: viewportRef }); const editor = useEditor(); const activeProject = useEditor((e) => e.project.getActive()); const renderTree = useEditor((e) => e.renderer.getRenderTree()); const { overlays } = usePreviewStore(); const viewport = usePreviewViewportState({ canvasHeight: nativeHeight, canvasWidth: nativeWidth, viewportHeight: viewportSize.height, viewportRef, viewportWidth: viewportSize.width, }); const renderer = useMemo(() => { return new CanvasRenderer({ width: nativeWidth, height: nativeHeight, fps: activeProject.settings.fps, }); }, [nativeWidth, nativeHeight, activeProject.settings.fps]); const render = useCallback(() => { if (canvasRef.current && renderTree && !renderingRef.current) { const renderTime = editor.playback.getCurrentTime(); const frame = Math.floor(renderTime * renderer.fps); if ( frame !== lastFrameRef.current || renderTree !== lastSceneRef.current ) { renderingRef.current = true; lastSceneRef.current = renderTree; lastFrameRef.current = frame; renderer .renderToCanvas({ node: renderTree, time: renderTime, targetCanvas: canvasRef.current, }) .then(() => { renderingRef.current = false; }); } } }, [renderer, renderTree, editor.playback]); useRafLoop(render); useEffect(() => { const container = viewportRef.current; if (!container) return; let pendingZoomDelta = 0; let pendingPanDeltaX = 0; let pendingPanDeltaY = 0; let zoomRafId: ReturnType | null = null; let panRafId: ReturnType | null = null; const onWheel = (event: WheelEvent) => { const normalizedDeltaX = normalizeWheelDelta({ delta: event.deltaX, deltaMode: event.deltaMode, pageSize: container.clientWidth, }); const normalizedDeltaY = normalizeWheelDelta({ delta: event.deltaY, deltaMode: event.deltaMode, pageSize: container.clientHeight, }); const isZoomGesture = event.ctrlKey || event.metaKey; if (isZoomGesture) { event.preventDefault(); pendingZoomDelta += normalizedDeltaY; if (zoomRafId === null) { zoomRafId = requestAnimationFrame(() => { const cappedDelta = Math.sign(pendingZoomDelta) * Math.min(Math.abs(pendingZoomDelta), 30); const zoomFactor = Math.exp(-cappedDelta / 300); viewport.scaleZoom({ factor: zoomFactor }); pendingZoomDelta = 0; zoomRafId = null; }); } return; } if (!viewport.canPan) { return; } if (normalizedDeltaX === 0 && normalizedDeltaY === 0) { return; } event.preventDefault(); pendingPanDeltaX += normalizedDeltaX; pendingPanDeltaY += normalizedDeltaY; if (panRafId === null) { panRafId = requestAnimationFrame(() => { viewport.panByScreenDelta({ deltaX: pendingPanDeltaX, deltaY: pendingPanDeltaY, }); pendingPanDeltaX = 0; pendingPanDeltaY = 0; panRafId = null; }); } }; container.addEventListener("wheel", onWheel, { capture: true, passive: false, }); return () => { container.removeEventListener("wheel", onWheel, { capture: true, }); if (zoomRafId !== null) { cancelAnimationFrame(zoomRafId); } if (panRafId !== null) { cancelAnimationFrame(panRafId); } }; }, [viewport.canPan, viewport.panByScreenDelta, viewport.scaleZoom]); return (
{overlays.bookmarks && }
); }