feat: full studio build -- light theme, canvas thumbnails, i18n (fa/en)

This commit is contained in:
Soroush.Asadi
2026-05-24 17:37:21 +03:30
parent d962483359
commit c61f587767
295 changed files with 29797 additions and 265 deletions
@@ -0,0 +1,74 @@
"use client";
import { useEffect, useState } from "react";
import { Image } from "react-konva";
import type Konva from "konva";
import useImage from "use-image";
import {
applyAdjustmentsToNode,
buildKonvaFilterList,
} from "@/lib/image-editor-konva";
import type { ImageAdjustments, ImageLayer } from "@/lib/image-editor-types";
interface ImageBaseLayerProps {
layer: ImageLayer;
adjustments: ImageAdjustments;
interactive?: boolean;
onSelect: () => void;
registerNode: (id: string, node: Konva.Node | null) => void;
}
export function ImageBaseLayer({
layer,
adjustments,
interactive = true,
onSelect,
registerNode,
}: ImageBaseLayerProps) {
const [konvaNode, setKonvaNode] = useState<Konva.Image | null>(null);
const src =
typeof layer.props.src === "string" ? layer.props.src : undefined;
const [image] = useImage(src ?? "", "anonymous");
const filters = buildKonvaFilterList(adjustments);
useEffect(() => {
if (!konvaNode || !image) return;
applyAdjustmentsToNode(konvaNode, adjustments, filters);
}, [konvaNode, image, adjustments, filters]);
if (!image) return null;
return (
<Image
ref={(node) => {
registerNode(layer.id, node);
setKonvaNode(node);
}}
image={image}
x={layer.x}
y={layer.y}
width={layer.width}
height={layer.height}
rotation={layer.rotation}
opacity={layer.opacity}
listening={interactive}
onMouseDown={
interactive
? (event) => {
event.cancelBubble = true;
onSelect();
}
: undefined
}
onTap={
interactive
? (event) => {
event.cancelBubble = true;
onSelect();
}
: undefined
}
/>
);
}
@@ -0,0 +1,53 @@
"use client";
import { Rnd } from "react-rnd";
import { getCropAspectRatioValue } from "@/lib/image-editor-crop";
import type { CropRect, ImageCropAspectRatio } from "@/lib/image-editor-types";
interface ImageCropOverlayProps {
cropRect: CropRect;
scale: number;
aspectRatio: ImageCropAspectRatio;
onCropChange: (rect: CropRect) => void;
}
export function ImageCropOverlay({
cropRect,
scale,
aspectRatio,
onCropChange,
}: ImageCropOverlayProps) {
const lockRatio = getCropAspectRatioValue(aspectRatio);
return (
<Rnd
size={{
width: cropRect.w * scale,
height: cropRect.h * scale,
}}
position={{
x: cropRect.x * scale,
y: cropRect.y * scale,
}}
bounds="parent"
lockAspectRatio={lockRatio}
onDragStop={(_e, data) =>
onCropChange({
...cropRect,
x: data.x / scale,
y: data.y / scale,
})
}
onResizeStop={(_e, _dir, ref, _delta, position) =>
onCropChange({
x: position.x / scale,
y: position.y / scale,
w: ref.offsetWidth / scale,
h: ref.offsetHeight / scale,
})
}
className="border-2 border-dashed border-violet-500 bg-violet-500/10"
/>
);
}
@@ -0,0 +1,246 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Layer, Rect, Stage, Transformer } from "react-konva";
import type Konva from "konva";
import { ImageCropOverlay } from "@/components/image-editor/canvas/ImageCropOverlay";
import { ImageEditorLayerNode } from "@/components/image-editor/canvas/ImageEditorLayerNode";
import { VignetteOverlay } from "@/components/image-editor/canvas/VignetteOverlay";
import { useContainerSize } from "@/hooks/useContainerSize";
import {
nodeToImageLayer,
resetNodeScale,
} from "@/lib/image-editor-transform";
import { registerImageEditorStage } from "@/lib/image-editor-stage-ref";
import {
getBaseImageLayer,
useImageEditorStore,
} from "@/lib/image-editor-store";
export function ImageEditorCanvas() {
const { ref: containerRef, width: cw, height: ch } = useContainerSize();
const transformerRef = useRef<Konva.Transformer>(null);
const nodeRefs = useRef<Map<string, Konva.Node>>(new Map());
const [drawPoints, setDrawPoints] = useState<number[]>([]);
const pendingShape = useImageEditorStore((s) => s.pendingShape);
const canvasWidth = useImageEditorStore((s) => s.canvasWidth);
const canvasHeight = useImageEditorStore((s) => s.canvasHeight);
const layers = useImageEditorStore((s) => s.layers);
const selectedLayerId = useImageEditorStore((s) => s.selectedLayerId);
const activeTool = useImageEditorStore((s) => s.activeTool);
const adjustments = useImageEditorStore((s) => s.adjustments);
const cropRect = useImageEditorStore((s) => s.cropRect);
const cropAspectRatio = useImageEditorStore((s) => s.cropAspectRatio);
const setSelectedLayer = useImageEditorStore((s) => s.setSelectedLayer);
const updateLayer = useImageEditorStore((s) => s.updateLayer);
const setCropRect = useImageEditorStore((s) => s.setCropRect);
const addLayer = useImageEditorStore((s) => s.addLayer);
const scale = cw > 0 ? Math.min(cw / canvasWidth, ch / canvasHeight) : 1;
const stageW = canvasWidth * scale;
const stageH = canvasHeight * scale;
const sorted = useMemo(
() => [...layers].sort((a, b) => a.zIndex - b.zIndex),
[layers]
);
const baseLayer = getBaseImageLayer({ layers });
useEffect(() => {
const tr = transformerRef.current;
if (!tr || activeTool !== "select") {
tr?.nodes([]);
return;
}
if (!selectedLayerId) {
tr.nodes([]);
return;
}
const node = nodeRefs.current.get(selectedLayerId);
if (node) {
tr.nodes([node]);
tr.getLayer()?.batchDraw();
}
}, [selectedLayerId, sorted, activeTool]);
const pointerToCanvas = useCallback(
(stage: Konva.Stage) => {
const pos = stage.getPointerPosition();
if (!pos) return null;
return { x: pos.x / scale, y: pos.y / scale };
},
[scale]
);
const handleStagePointerDown = (e: Konva.KonvaEventObject<MouseEvent>) => {
const stage = e.target.getStage();
if (!stage) return;
const pt = pointerToCanvas(stage);
if (!pt) return;
if (activeTool === "text") {
addLayer({
type: "text",
name: "Text",
x: pt.x,
y: pt.y,
width: 280,
height: 48,
props: { text: "New text", fontSize: 36, fill: "#ffffff" },
});
return;
}
if (activeTool === "shape") {
addLayer({
type: "shape",
name: pendingShape,
x: pt.x,
y: pt.y,
width: pendingShape === "line" ? 160 : 120,
height: pendingShape === "line" ? 8 : 120,
props: { shape: pendingShape, fill: "#2563EB" },
});
return;
}
if (activeTool === "draw") {
setDrawPoints([pt.x, pt.y]);
return;
}
if (e.target === stage) setSelectedLayer(null);
};
const handleStagePointerMove = (e: Konva.KonvaEventObject<MouseEvent>) => {
if (activeTool !== "draw" || drawPoints.length === 0) return;
const stage = e.target.getStage();
if (!stage) return;
const pt = pointerToCanvas(stage);
if (!pt) return;
setDrawPoints((prev) => [...prev, pt.x, pt.y]);
};
const handleStagePointerUp = () => {
if (activeTool !== "draw" || drawPoints.length < 4) {
setDrawPoints([]);
return;
}
addLayer({
type: "draw",
name: "Drawing",
x: 0,
y: 0,
width: canvasWidth,
height: canvasHeight,
props: { points: drawPoints, stroke: "#ffffff", strokeWidth: 4 },
});
setDrawPoints([]);
};
const isCropping = activeTool === "crop";
if (cw <= 0) {
return <div ref={containerRef} className="h-full w-full bg-gray-950" />;
}
return (
<div
ref={containerRef}
className="relative flex h-full w-full items-center justify-center overflow-hidden bg-gray-950"
>
<div
className="relative shadow-2xl"
style={{ width: stageW, height: stageH }}
>
<Stage
ref={(node) => registerImageEditorStage(node)}
width={stageW}
height={stageH}
scaleX={scale}
scaleY={scale}
onMouseDown={isCropping ? undefined : handleStagePointerDown}
onMousemove={isCropping ? undefined : handleStagePointerMove}
onMouseup={isCropping ? undefined : handleStagePointerUp}
className="bg-checkerboard"
>
<Layer>
<Rect
x={0}
y={0}
width={canvasWidth}
height={canvasHeight}
fill="#ffffff"
listening={false}
/>
{sorted.map((layer) => (
<ImageEditorLayerNode
key={layer.id}
layer={layer}
adjustments={adjustments}
isBaseImage={layer.id === baseLayer?.id}
interactive={!isCropping}
onSelect={() => setSelectedLayer(layer.id)}
onDragEnd={(x, y) => updateLayer(layer.id, { x, y })}
onTransformEnd={(node) => {
resetNodeScale(node);
updateLayer(layer.id, nodeToImageLayer(node));
}}
registerNode={(id, node) => {
if (node) nodeRefs.current.set(id, node);
else nodeRefs.current.delete(id);
}}
/>
))}
{drawPoints.length > 0 ? (
<ImageEditorLayerNode
layer={{
id: "preview-draw",
type: "draw",
name: "preview",
visible: true,
x: 0,
y: 0,
width: canvasWidth,
height: canvasHeight,
rotation: 0,
opacity: 1,
zIndex: 9999,
props: {
points: drawPoints,
stroke: "#ffffff",
strokeWidth: 4,
},
}}
adjustments={adjustments}
isBaseImage={false}
interactive={false}
onSelect={() => undefined}
onDragEnd={() => undefined}
onTransformEnd={() => undefined}
registerNode={() => undefined}
/>
) : null}
<VignetteOverlay
width={canvasWidth}
height={canvasHeight}
amount={adjustments.vignette}
/>
{activeTool === "select" ? (
<Transformer ref={transformerRef} rotateEnabled borderStroke="#7C3AED" />
) : null}
</Layer>
</Stage>
{isCropping && cropRect ? (
<ImageCropOverlay
cropRect={cropRect}
scale={scale}
aspectRatio={cropAspectRatio}
onCropChange={setCropRect}
/>
) : null}
</div>
</div>
);
}
@@ -0,0 +1,180 @@
"use client";
import { Arrow, Circle, Line, Rect, Text } from "react-konva";
import type Konva from "konva";
import { ImageBaseLayer } from "@/components/image-editor/canvas/ImageBaseLayer";
import type {
ImageAdjustments,
ImageLayer,
ImageShapeKind,
} from "@/lib/image-editor-types";
interface ImageEditorLayerNodeProps {
layer: ImageLayer;
adjustments: ImageAdjustments;
isBaseImage: boolean;
interactive?: boolean;
onSelect: () => void;
onDragEnd: (x: number, y: number) => void;
onTransformEnd: (node: Konva.Node) => void;
registerNode: (id: string, node: Konva.Node | null) => void;
}
export function ImageEditorLayerNode({
layer,
adjustments,
isBaseImage,
interactive = true,
onSelect,
onDragEnd,
onTransformEnd,
registerNode,
}: ImageEditorLayerNodeProps) {
if (!layer.visible) return null;
if (layer.type === "image") {
return (
<ImageBaseLayer
layer={layer}
adjustments={adjustments}
interactive={interactive}
onSelect={onSelect}
registerNode={registerNode}
/>
);
}
const common = {
rotation: layer.rotation,
opacity: layer.opacity,
listening: interactive,
draggable: interactive && !isBaseImage,
onMouseDown: interactive
? (e: Konva.KonvaEventObject<MouseEvent>) => {
e.cancelBubble = true;
onSelect();
}
: undefined,
onTap: interactive
? (e: Konva.KonvaEventObject<TouchEvent>) => {
e.cancelBubble = true;
onSelect();
}
: undefined,
onDragEnd: interactive
? (e: Konva.KonvaEventObject<DragEvent>) =>
onDragEnd(e.target.x(), e.target.y())
: undefined,
onTransformEnd: interactive
? (e: Konva.KonvaEventObject<Event>) => onTransformEnd(e.target)
: undefined,
};
if (layer.type === "text") {
return (
<Text
ref={(n) => registerNode(layer.id, n)}
x={layer.x}
y={layer.y}
width={layer.width}
text={typeof layer.props.text === "string" ? layer.props.text : "Text"}
fontSize={
typeof layer.props.fontSize === "number" ? layer.props.fontSize : 36
}
fill={
typeof layer.props.fill === "string" ? layer.props.fill : "#ffffff"
}
{...common}
/>
);
}
if (layer.type === "draw") {
const points = Array.isArray(layer.props.points)
? (layer.props.points as number[])
: [];
return (
<Line
ref={(n) => registerNode(layer.id, n)}
points={points}
x={layer.x}
y={layer.y}
stroke={
typeof layer.props.stroke === "string"
? layer.props.stroke
: "#ffffff"
}
strokeWidth={
typeof layer.props.strokeWidth === "number"
? layer.props.strokeWidth
: 4
}
tension={0.5}
lineCap="round"
lineJoin="round"
{...common}
/>
);
}
if (layer.type === "shape") {
const shape = (layer.props.shape as ImageShapeKind) ?? "rect";
const fill =
typeof layer.props.fill === "string" ? layer.props.fill : "#2563EB";
if (shape === "circle") {
const r = Math.min(layer.width, layer.height) / 2;
return (
<Circle
ref={(n) => registerNode(layer.id, n)}
x={layer.x + layer.width / 2}
y={layer.y + layer.height / 2}
radius={r}
fill={fill}
{...common}
/>
);
}
if (shape === "line") {
return (
<Line
ref={(n) => registerNode(layer.id, n)}
x={layer.x}
y={layer.y}
points={[0, 0, layer.width, layer.height]}
stroke={fill}
strokeWidth={4}
{...common}
/>
);
}
if (shape === "arrow") {
return (
<Arrow
ref={(n) => registerNode(layer.id, n)}
x={layer.x}
y={layer.y + layer.height / 2}
points={[0, 0, layer.width, 0]}
fill={fill}
stroke={fill}
pointerLength={10}
pointerWidth={10}
{...common}
/>
);
}
return (
<Rect
ref={(n) => registerNode(layer.id, n)}
x={layer.x}
y={layer.y}
width={layer.width}
height={layer.height}
fill={fill}
{...common}
/>
);
}
return null;
}
@@ -0,0 +1,39 @@
"use client";
import { Rect } from "react-konva";
interface VignetteOverlayProps {
width: number;
height: number;
amount: number;
}
export function VignetteOverlay({
width,
height,
amount,
}: VignetteOverlayProps) {
if (amount <= 0) return null;
const opacity = Math.min(0.85, amount / 100);
return (
<Rect
x={0}
y={0}
width={width}
height={height}
fillRadialGradientStartPoint={{ x: width / 2, y: height / 2 }}
fillRadialGradientStartRadius={0}
fillRadialGradientEndPoint={{ x: width / 2, y: height / 2 }}
fillRadialGradientEndRadius={Math.max(width, height) / 1.1}
fillRadialGradientColorStops={[
0,
"rgba(0,0,0,0)",
1,
`rgba(0,0,0,${opacity})`,
]}
listening={false}
/>
);
}