feat: full studio build -- light theme, canvas thumbnails, i18n (fa/en)
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
"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, 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);
|
||||
|
||||
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
|
||||
useEffect(() => {
|
||||
if (isPlaying || !activeSceneId) 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 ? undefined : handleStagePointerDown}
|
||||
>
|
||||
<Layer>
|
||||
<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 ? (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user