summaryrefslogtreecommitdiff
path: root/apps/web/src/preview/components/index.tsx
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
commit3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch)
tree57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/preview/components/index.tsx
parent729d10592f4e802f91c555c309ff3ba4b119f843 (diff)
downloadopencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/preview/components/index.tsx')
-rw-r--r--apps/web/src/preview/components/index.tsx328
1 files changed, 328 insertions, 0 deletions
diff --git a/apps/web/src/preview/components/index.tsx b/apps/web/src/preview/components/index.tsx
new file mode 100644
index 00000000..d3cb2a49
--- /dev/null
+++ b/apps/web/src/preview/components/index.tsx
@@ -0,0 +1,328 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useRef } from "react";
+import useDeepCompareEffect from "use-deep-compare-effect";
+import { useEditor } from "@/editor/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 "@/wasm";
+import type { RootNode } from "@/services/renderer/nodes/root-node";
+import { buildScene } from "@/services/renderer/scene-builder";
+import { PreviewOverlayLayer } from "./overlay-layer";
+import { PreviewInteractionOverlay } from "./preview-interaction-overlay";
+import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
+import type {
+ PreviewOverlayControl,
+ PreviewOverlayInstance,
+} from "@/preview/overlays";
+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({
+ overlayControls,
+ overlayInstances,
+ onOverlayVisibilityChange,
+}: {
+ overlayControls: PreviewOverlayControl[];
+ overlayInstances: PreviewOverlayInstance[];
+ onOverlayVisibilityChange: (params: {
+ overlayId: string;
+ isVisible: boolean;
+ }) => void;
+}) {
+ 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}
+ overlayControls={overlayControls}
+ overlayInstances={overlayInstances}
+ onOverlayVisibilityChange={onOverlayVisibilityChange}
+ />
+ <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,
+ overlayControls,
+ overlayInstances,
+ onOverlayVisibilityChange,
+}: {
+ containerRef: React.RefObject<HTMLElement | null>;
+ onToggleFullscreen: () => void;
+ overlayControls: PreviewOverlayControl[];
+ overlayInstances: PreviewOverlayInstance[];
+ onOverlayVisibilityChange: (params: {
+ overlayId: string;
+ isVisible: boolean;
+ }) => 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 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 = 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, editor.timeline]);
+
+ 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,
+ }}
+ />
+ <PreviewOverlayLayer
+ instances={overlayInstances}
+ plane="under-interaction"
+ />
+ <PreviewInteractionOverlay />
+ <PreviewOverlayLayer
+ instances={overlayInstances}
+ plane="over-interaction"
+ />
+ </div>
+ </ContextMenuTrigger>
+ <PreviewContextMenu
+ onToggleFullscreen={onToggleFullscreen}
+ containerRef={containerRef}
+ overlayControls={overlayControls}
+ onOverlayVisibilityChange={onOverlayVisibilityChange}
+ />
+ </ContextMenu>
+ </div>
+ <PreviewToolbar onToggleFullscreen={onToggleFullscreen} />
+ </div>
+ </PreviewViewportProvider>
+ );
+}