"use client"; import { useCallback, useEffect, useMemo, useRef } from "react"; import { Layer, Rect, Stage, Transformer } from "react-konva"; import type Konva from "konva"; import { CanvasLayerNode } from "@/components/studio/canvas/CanvasLayerNode"; import { useCanvasKeyboard } from "@/hooks/useCanvasKeyboard"; import { useCanvasPreviewPlayback } from "@/hooks/useCanvasPreviewPlayback"; import { useContainerSize } from "@/hooks/useContainerSize"; import { registerStudioStage, getStudioStage } from "@/lib/studio-canvas-stage"; import { nodeTransformToLayer, resetNodeScale, } from "@/lib/canvas-transform"; import { getShapeProps } from "@/lib/studio-layer-props"; import { getActiveScene, isFlexStoryProject, useStudioStore } from "@/lib/studio-store"; import type { Layer as StudioLayer } from "@/lib/studio-types"; export const STAGE_WIDTH = 1280; export const STAGE_HEIGHT = 720; export function CanvasEditor() { const { ref: containerRef, width: containerWidth } = useContainerSize(); const nodeRefs = useRef>(new Map()); const transformerRef = useRef(null); const canvasWrapperRef = useRef(null); const scenes = useStudioStore((state) => state.scenes); const activeSceneId = useStudioStore((state) => state.activeSceneId); const selectedLayerId = useStudioStore((state) => state.selectedLayerId); const isPlaying = useStudioStore((state) => state.isPlaying); const sceneBackgroundColor = useStudioStore( (state) => state.sceneBackgroundColor ); const setSelectedLayer = useStudioStore((state) => state.setSelectedLayer); const updateLayer = useStudioStore((state) => state.updateLayer); const updateScene = useStudioStore((state) => state.updateScene); // Scene-engine (FlexStory) templates render in Remotion at fixed positions — // dragging/resizing layers on the canvas does nothing, so the canvas becomes a // read-only preview; editing happens only via the field form. const lockedGeometry = useStudioStore((state) => isFlexStoryProject(state.chooseMode) ); const thumbTimerRef = useRef | null>(null); useCanvasKeyboard(); useCanvasPreviewPlayback(canvasWrapperRef); const activeScene = useMemo( () => getActiveScene({ scenes, activeSceneId }), [scenes, activeSceneId] ); const sortedLayers = useMemo( () => [...(activeScene?.layers ?? [])].sort((a, b) => a.zIndex - b.zIndex), [activeScene?.layers] ); const scale = containerWidth > 0 ? containerWidth / STAGE_WIDTH : 1; const stageHeight = STAGE_HEIGHT * scale; const registerNode = useCallback((id: string, node: Konva.Node | null) => { if (node) { nodeRefs.current.set(id, node); } else { nodeRefs.current.delete(id); } }, []); useEffect(() => { const transformer = transformerRef.current; if (!transformer || isPlaying) return; if (!selectedLayerId) { transformer.nodes([]); transformer.getLayer()?.batchDraw(); return; } // Text layers are fixed — no transform handles const selectedLayer = sortedLayers.find((l) => l.id === selectedLayerId); if (selectedLayer?.type === "text") { transformer.nodes([]); transformer.getLayer()?.batchDraw(); return; } const node = nodeRefs.current.get(selectedLayerId); if (node) { transformer.nodes([node]); transformer.getLayer()?.batchDraw(); } }, [selectedLayerId, sortedLayers, isPlaying]); // Auto-capture scene thumbnail whenever layers or background change. Skipped for // scene-engine templates so the flat Konva snapshot doesn't overwrite the real // rendered per-scene image. useEffect(() => { if (isPlaying || !activeSceneId || lockedGeometry) return; if (thumbTimerRef.current) clearTimeout(thumbTimerRef.current); thumbTimerRef.current = setTimeout(() => { const stage = getStudioStage(); if (!stage) return; // Temporarily clear transformer so handles don't appear in thumbnail const transformer = transformerRef.current; const prevNodes = transformer?.nodes() ?? []; transformer?.nodes([]); transformer?.getLayer()?.batchDraw(); stage.toDataURL({ pixelRatio: 0.25, callback: (dataUrl: string) => { updateScene(activeSceneId, { thumbnailUrl: dataUrl }); // Restore transformer transformer?.nodes(prevNodes); transformer?.getLayer()?.batchDraw(); }, }); }, 700); return () => { if (thumbTimerRef.current) clearTimeout(thumbTimerRef.current); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [sortedLayers, sceneBackgroundColor, activeSceneId, isPlaying]); const applyTransform = useCallback( (layer: StudioLayer, node: Konva.Node) => { if (layer.type === "shape" && getShapeProps(layer.props).shape === "circle") { const circle = node as Konva.Circle; const scaledRadius = circle.radius() * circle.scaleX(); resetNodeScale(circle); updateLayer(layer.id, { x: circle.x() - scaledRadius, y: circle.y() - scaledRadius, width: scaledRadius * 2, height: scaledRadius * 2, rotation: circle.rotation(), }); return; } resetNodeScale(node); updateLayer(layer.id, nodeTransformToLayer(node)); }, [updateLayer] ); const applyDrag = useCallback( (layer: StudioLayer, x: number, y: number) => { if (layer.type === "shape" && getShapeProps(layer.props).shape === "circle") { const radius = Math.min(layer.width, layer.height) / 2; updateLayer(layer.id, { x: x - radius, y: y - radius }); return; } updateLayer(layer.id, { x, y }); }, [updateLayer] ); const handleStagePointerDown = (event: Konva.KonvaEventObject) => { const target = event.target; const clickedOnEmpty = target === target.getStage() || target.name() === "background"; if (clickedOnEmpty) { setSelectedLayer(null); } }; if (containerWidth <= 0) { return
; } return (
registerStudioStage(node)} width={containerWidth} height={stageHeight} scaleX={scale} scaleY={scale} onMouseDown={isPlaying || lockedGeometry ? undefined : handleStagePointerDown} > {sortedLayers.map((layer) => ( setSelectedLayer(layer.id)} onDragEnd={(x, y) => applyDrag(layer, x, y)} onTransformEnd={(node) => applyTransform(layer, node)} registerNode={registerNode} /> ))} {!isPlaying && !lockedGeometry ? ( { if (newBox.width < 8 || newBox.height < 8) { return oldBox; } return newBox; }} /> ) : null}
); }