feat: full studio build -- light theme, canvas thumbnails, i18n (fa/en)
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user