Files
flatrender/src/components/studio/CanvasEditor.tsx
T
soroush.asadi 825f25be55 fix(studio): lock the canvas for scene-engine templates (no drag/resize)
For FLEXIBLE (Remotion / FlexStory) templates the render uses fixed positions —
dragging or resizing a layer on the Konva canvas does nothing to the output, which
is confusing. Make the canvas a read-only PREVIEW for those projects: the Konva
Layer is listening=false (no drag/select/transform), the Transformer is hidden, and
the auto-thumbnail capture is skipped so the flat Konva snapshot can't overwrite the
real rendered per-scene image. Editing happens only through the field form
(BlockFieldForm). AE/Konva templates are unchanged. Gated on isFlexStoryProject().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 09:33:20 +03:30

237 lines
7.8 KiB
TypeScript

"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<Map<string, Konva.Node>>(new Map());
const transformerRef = useRef<Konva.Transformer>(null);
const canvasWrapperRef = useRef<HTMLDivElement>(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<ReturnType<typeof setTimeout> | 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<MouseEvent>) => {
const target = event.target;
const clickedOnEmpty =
target === target.getStage() || target.name() === "background";
if (clickedOnEmpty) {
setSelectedLayer(null);
}
};
if (containerWidth <= 0) {
return <div ref={containerRef} className="h-full w-full" />;
}
return (
<div
ref={containerRef}
className="flex h-full w-full items-center justify-center"
>
<div className="overflow-hidden rounded-lg shadow-2xl ring-1 ring-gray-700/80">
<div ref={canvasWrapperRef} className="origin-center will-change-transform">
<Stage
ref={(node) => registerStudioStage(node)}
width={containerWidth}
height={stageHeight}
scaleX={scale}
scaleY={scale}
onMouseDown={isPlaying || lockedGeometry ? undefined : handleStagePointerDown}
>
<Layer listening={!lockedGeometry}>
<Rect
name="background"
x={0}
y={0}
width={STAGE_WIDTH}
height={STAGE_HEIGHT}
fill={sceneBackgroundColor}
/>
{sortedLayers.map((layer) => (
<CanvasLayerNode
key={layer.id}
layer={layer}
onSelect={() => setSelectedLayer(layer.id)}
onDragEnd={(x, y) => applyDrag(layer, x, y)}
onTransformEnd={(node) => applyTransform(layer, node)}
registerNode={registerNode}
/>
))}
{!isPlaying && !lockedGeometry ? (
<Transformer
ref={transformerRef}
rotateEnabled
borderStroke="#2563EB"
anchorStroke="#2563EB"
anchorFill="#ffffff"
anchorSize={8}
boundBoxFunc={(oldBox, newBox) => {
if (newBox.width < 8 || newBox.height < 8) {
return oldBox;
}
return newBox;
}}
/>
) : null}
</Layer>
</Stage>
</div>
</div>
</div>
);
}