295 lines
8.1 KiB
TypeScript
295 lines
8.1 KiB
TypeScript
"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 { TICKS_PER_SECOND } from "@/lib/wasm";
|
|
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<HTMLDivElement>(null);
|
|
const { toggleFullscreen } = useFullscreen({ containerRef });
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className="panel bg-background relative flex size-full min-h-0 min-w-0 flex-col rounded-sm border"
|
|
>
|
|
<PreviewCanvas
|
|
containerRef={containerRef}
|
|
onToggleFullscreen={toggleFullscreen}
|
|
/>
|
|
<RenderTreeController />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RenderTreeController() {
|
|
const editor = useEditor();
|
|
const tracks = useEditor(
|
|
(e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks,
|
|
);
|
|
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<HTMLElement | null>;
|
|
onToggleFullscreen: () => void;
|
|
}) {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const viewportRef = useRef<HTMLDivElement>(null);
|
|
const lastFrameRef = useRef(-1);
|
|
const lastSceneRef = useRef<RootNode | null>(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.numerator, activeProject.settings.fps.denominator]);
|
|
|
|
const render = useCallback(() => {
|
|
if (canvasRef.current && renderTree && !renderingRef.current) {
|
|
const renderTime = Math.min(
|
|
editor.playback.getCurrentTime(),
|
|
editor.timeline.getLastFrameTime(),
|
|
);
|
|
const ticksPerFrame = Math.round(TICKS_PER_SECOND * renderer.fps.denominator / renderer.fps.numerator);
|
|
const frame = Math.floor(renderTime / ticksPerFrame);
|
|
|
|
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<typeof requestAnimationFrame> | null = null;
|
|
let panRafId: ReturnType<typeof requestAnimationFrame> | 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 (
|
|
<PreviewViewportProvider value={viewport}>
|
|
<div className="flex size-full min-h-0 min-w-0 flex-col">
|
|
<div className="flex min-h-0 min-w-0 flex-1 p-2 pb-0">
|
|
<ContextMenu>
|
|
<ContextMenuTrigger asChild>
|
|
<div
|
|
ref={viewportRef}
|
|
className="relative flex size-full min-h-0 min-w-0 items-center justify-center overflow-hidden"
|
|
>
|
|
<canvas
|
|
ref={canvasRef}
|
|
width={nativeWidth}
|
|
height={nativeHeight}
|
|
className="absolute block border"
|
|
style={{
|
|
left: viewport.sceneLeft,
|
|
top: viewport.sceneTop,
|
|
width: viewport.sceneWidth,
|
|
height: viewport.sceneHeight,
|
|
background:
|
|
activeProject.settings.background.type === "blur"
|
|
? "transparent"
|
|
: activeProject?.settings.background.color,
|
|
}}
|
|
/>
|
|
<GuideOverlay />
|
|
<PreviewInteractionOverlay />
|
|
{overlays.bookmarks && <BookmarkNoteOverlay />}
|
|
</div>
|
|
</ContextMenuTrigger>
|
|
<PreviewContextMenu
|
|
onToggleFullscreen={onToggleFullscreen}
|
|
containerRef={containerRef}
|
|
/>
|
|
</ContextMenu>
|
|
</div>
|
|
<PreviewToolbar onToggleFullscreen={onToggleFullscreen} />
|
|
</div>
|
|
</PreviewViewportProvider>
|
|
);
|
|
}
|