825f25be55
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>
237 lines
7.8 KiB
TypeScript
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>
|
|
);
|
|
}
|