add konva js to project + create some components with ai

This commit is contained in:
hamid zarghami
2025-11-16 16:33:54 +03:30
parent c1c6b0ebf7
commit 1a0c869101
38 changed files with 1855 additions and 42 deletions
+111 -2
View File
@@ -11,12 +11,15 @@
"@tailwindcss/vite": "^4.1.17",
"i18next": "^25.6.2",
"iconsax-react": "^0.0.8",
"konva": "^10.0.9",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-i18next": "^16.3.0",
"react-konva": "^19.2.0",
"react-router-dom": "^7.9.5",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.17",
"use-image": "^1.1.4",
"zustand": "^5.0.8"
},
"devDependencies": {
@@ -1658,7 +1661,6 @@
"version": "19.2.2",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
@@ -1675,6 +1677,15 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/react-reconciler": {
"version": "0.32.3",
"resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.32.3.tgz",
"integrity": "sha512-cMi5ZrLG7UtbL7LTK6hq9w/EZIRk4Mf1Z5qHoI+qBh7/WkYkFXQ7gOto2yfUvPzF5ERMAhaXS5eTQ2SAnHjLzA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.46.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.4.tgz",
@@ -2228,7 +2239,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/debug": {
@@ -2855,6 +2865,27 @@
"dev": true,
"license": "ISC"
},
"node_modules/its-fine": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz",
"integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==",
"license": "MIT",
"dependencies": {
"@types/react-reconciler": "^0.28.9"
},
"peerDependencies": {
"react": "^19.0.0"
}
},
"node_modules/its-fine/node_modules/@types/react-reconciler": {
"version": "0.28.9",
"resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz",
"integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*"
}
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -2940,6 +2971,27 @@
"json-buffer": "3.0.1"
}
},
"node_modules/konva": {
"version": "10.0.9",
"resolved": "https://registry.npmjs.org/konva/-/konva-10.0.9.tgz",
"integrity": "sha512-AEqV0XqfE0ouHjcSo/IVAXZUK4jB/r18OlUoIzPFEx/59Vb95Yq1S4Ch5QuDDEXJT21SEpOMb89zSpEpsQWzWg==",
"funding": [
{
"type": "patreon",
"url": "https://www.patreon.com/lavrton"
},
{
"type": "opencollective",
"url": "https://opencollective.com/konva"
},
{
"type": "github",
"url": "https://github.com/sponsors/lavrton"
}
],
"license": "MIT",
"peer": true
},
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -3529,6 +3581,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -3579,6 +3632,52 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-konva": {
"version": "19.2.0",
"resolved": "https://registry.npmjs.org/react-konva/-/react-konva-19.2.0.tgz",
"integrity": "sha512-Ofifq/rdNvff50+Lj8x86WSfoeQDvdysOlsXMMrpD2uWmDxUPrEYSRLt27iCfdovQZL6xinKRpX9VaL9xDwXDQ==",
"funding": [
{
"type": "patreon",
"url": "https://www.patreon.com/lavrton"
},
{
"type": "opencollective",
"url": "https://opencollective.com/konva"
},
{
"type": "github",
"url": "https://github.com/sponsors/lavrton"
}
],
"license": "MIT",
"dependencies": {
"@types/react-reconciler": "^0.32.2",
"its-fine": "^2.0.0",
"react-reconciler": "0.33.0",
"scheduler": "0.27.0"
},
"peerDependencies": {
"konva": "^8.0.1 || ^7.2.5 || ^9.0.0 || ^10.0.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
}
},
"node_modules/react-reconciler": {
"version": "0.33.0",
"resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz",
"integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"engines": {
"node": ">=0.10.0"
},
"peerDependencies": {
"react": "^19.2.0"
}
},
"node_modules/react-refresh": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
@@ -3994,6 +4093,16 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-image": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/use-image/-/use-image-1.1.4.tgz",
"integrity": "sha512-P+swhszzHHgEb2X2yQ+vQNPCq/8Ks3hyfdXAVN133pvnvK7UK++bUaZUa5E+A3S02Mw8xOCBr9O6CLhk2fjrWA==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+3
View File
@@ -13,12 +13,15 @@
"@tailwindcss/vite": "^4.1.17",
"i18next": "^25.6.2",
"iconsax-react": "^0.0.8",
"konva": "^10.0.9",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-i18next": "^16.3.0",
"react-konva": "^19.2.0",
"react-router-dom": "^7.9.5",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.17",
"use-image": "^1.1.4",
"zustand": "^5.0.8"
},
"devDependencies": {
+3 -1
View File
@@ -1,9 +1,11 @@
import { type FC } from 'react'
import EditorSidebar from './components/EditorSidebar'
import EditorCanvas from './components/EditorCanvas'
const Editor: FC = () => {
return (
<div>
<div className="flex h-full w-full">
<EditorCanvas />
<EditorSidebar />
</div>
)
@@ -0,0 +1,101 @@
import { useEffect, useRef } from "react";
import { Stage, Layer, Transformer } from "react-konva";
import Konva from "konva";
import { useEditorStore } from "../store/editorStore";
import { clx } from "@/helpers/utils";
import { useDrawingHandlers } from "./canvas/useDrawingHandlers";
import { useStageSize } from "./canvas/useStageSize";
import ObjectRenderer from "./canvas/ObjectRenderer";
const EditorCanvas = () => {
const stageRef = useRef<Konva.Stage>(null);
const layerRef = useRef<Konva.Layer>(null);
const {
tool,
objects,
updateObject,
selectedObjectId,
setSelectedObjectId,
setStageRef,
setLayerRef,
} = useEditorStore();
const { transformerRef, handleStageMouseDown, handleStageMouseMove, handleStageMouseUp } = useDrawingHandlers();
const stageSize = useStageSize();
useEffect(() => {
if (stageRef.current) {
setStageRef(stageRef);
}
if (layerRef.current) {
setLayerRef(layerRef);
}
}, [setStageRef, setLayerRef]);
const handleSelect = (id: string, node: Konva.Node) => {
setSelectedObjectId(id);
if (transformerRef.current) {
transformerRef.current.nodes([node]);
transformerRef.current.getLayer()?.batchDraw();
}
};
return (
<div className="flex-1 flex items-center justify-center bg-gray-100 overflow-hidden">
<Stage
ref={stageRef}
width={stageSize.width}
height={stageSize.height}
onMouseDown={handleStageMouseDown}
onMouseMove={handleStageMouseMove}
onMouseUp={handleStageMouseUp}
className={clx("bg-white shadow-lg")}
>
<Layer ref={layerRef}>
{objects.map((obj) => (
<ObjectRenderer
key={obj.id}
obj={obj}
isSelected={selectedObjectId === obj.id}
transformerRef={transformerRef}
layerRef={layerRef}
onSelect={handleSelect}
onUpdate={updateObject}
draggable={tool === "select"}
/>
))}
{selectedObjectId && (
<Transformer
ref={transformerRef}
boundBoxFunc={(oldBox, newBox) => {
if (Math.abs(newBox.width) < 5 || Math.abs(newBox.height) < 5) {
return oldBox;
}
return newBox;
}}
anchorFill="#3b82f6"
anchorStroke="#ffffff"
borderStroke="#3b82f6"
borderStrokeWidth={2}
anchorSize={8}
rotateEnabled={true}
enabledAnchors={[
"top-left",
"top-right",
"bottom-left",
"bottom-right",
"top-center",
"bottom-center",
"middle-left",
"middle-right",
]}
/>
)}
</Layer>
</Stage>
</div>
);
};
export default EditorCanvas;
+54 -39
View File
@@ -1,11 +1,44 @@
import Logo from "@/assets/images/logo.svg";
import { clx } from "@/helpers/utils";
import { useSharedStore } from "@/shared/store/sharedStore";
import { DocumentUpload, Element, Gallery, Grid8, Link, MouseSquare, Shapes, Sticker, Text, VideoSquare } from "iconsax-react";
import { CloseSquare } from "iconsax-react";
import { useEditorStore, type ToolType } from "../store/editorStore";
import { ToolsBar, ObjectSettings, ToolInstructions } from "./sidebar";
const EditorSidebar = () => {
const { openSidebar, setOpenSidebar } = useSharedStore();
const { tool, setTool, selectedObjectId, objects, updateObject, deleteObject } = useEditorStore();
const selectedObject = objects.find((obj) => obj.id === selectedObjectId);
const handleToolClick = (selectedTool: ToolType) => {
setTool(selectedTool);
};
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const imageUrl = event.target?.result as string;
const img = new Image();
img.onload = () => {
const newImage = {
id: `image-${Date.now()}`,
type: "image" as ToolType,
x: 100,
y: 100,
width: img.width,
height: img.height,
imageUrl,
};
useEditorStore.getState().addObject(newImage);
};
img.src = imageUrl;
};
reader.readAsDataURL(file);
};
return (
<>
@@ -18,52 +51,34 @@ const EditorSidebar = () => {
openSidebar && "z-40 translate-x-0 opacity-100",
)}
>
<div className="mb-10 flex items-center justify-center">
<div className="mb-10 flex items-center justify-between">
<img src={Logo} alt="لوگو دانک" className="h-9" />
<button
onClick={() => setOpenSidebar(false)}
className="xl:hidden p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<CloseSquare color="black" size={24} />
</button>
</div>
<div className="flex gap-8 flex-1 min-h-0">
<div className="flex h-full max-w-[72px] flex-1 flex-col items-center gap-6 overflow-y-auto border-l border-border">
<div className="min-h-10">
<MouseSquare size={20} color="black" />
</div>
<div className="min-h-10">
<Shapes size={20} color="black" />
</div>
<div className="min-h-10">
<Text size={20} color="black" />
</div>
<div className="min-h-10">
<Grid8 size={20} color="black" />
</div>
<div className="min-h-10">
<Sticker size={20} color="black" />
</div>
<div className="min-h-10">
<DocumentUpload size={20} color="black" />
</div>
<div className="min-h-10">
<Element size={20} color="black" />
</div>
<div className="min-h-10">
<Gallery size={20} color="black" />
</div>
<div className="min-h-10">
<VideoSquare size={20} color="black" />
</div>
<div className="min-h-10">
<Link size={20} color="black" />
</div>
</div>
<div>
s
<ToolsBar tool={tool} onToolClick={handleToolClick} />
<div className="flex-1 overflow-y-auto">
{selectedObject ? (
<ObjectSettings
selectedObject={selectedObject}
onUpdate={updateObject}
onDelete={deleteObject}
/>
) : (
<ToolInstructions tool={tool} onImageUpload={handleImageUpload} />
)}
</div>
</div>
</aside>
</>
);
};
export default EditorSidebar;
@@ -0,0 +1,67 @@
import Konva from "konva";
import type { EditorObject } from "../../store/editorStore";
import {
RectangleShape,
CircleShape,
TextShape,
LineShape,
ArrowShape,
ImageObject,
LinkShape,
VideoShape,
} from "../tools";
type ObjectRendererProps = {
obj: EditorObject;
isSelected: boolean;
transformerRef: React.RefObject<Konva.Transformer | null>;
layerRef: React.RefObject<Konva.Layer | null>;
onSelect: (id: string, node: Konva.Node) => void;
onUpdate: (id: string, updates: Partial<EditorObject>) => void;
draggable: boolean;
};
const ObjectRenderer = ({
obj,
isSelected,
transformerRef,
layerRef,
onSelect,
onUpdate,
draggable,
}: ObjectRendererProps) => {
const commonProps = {
obj,
isSelected,
transformerRef,
onSelect,
onUpdate,
draggable,
};
switch (obj.type) {
case "rectangle":
return <RectangleShape key={obj.id} {...commonProps} />;
case "circle":
return <CircleShape key={obj.id} {...commonProps} />;
case "text":
return <TextShape key={obj.id} {...commonProps} layerRef={layerRef} />;
case "line":
return <LineShape key={obj.id} {...commonProps} />;
case "arrow":
return <ArrowShape key={obj.id} {...commonProps} />;
case "image":
return <ImageObject key={obj.id} {...commonProps} />;
case "link":
return <LinkShape key={obj.id} {...commonProps} />;
case "video":
return <VideoShape key={obj.id} {...commonProps} />;
case "document":
return <ImageObject key={obj.id} {...commonProps} />;
default:
return null;
}
};
export default ObjectRenderer;
@@ -0,0 +1,75 @@
import type { EditorObject, ToolType } from "../../store/editorStore";
type DrawingParams = {
tool: ToolType;
startPos: { x: number; y: number };
pointerPos: { x: number; y: number };
tempObject: EditorObject | null;
};
export const createDrawingObject = ({
tool,
startPos,
pointerPos,
tempObject,
}: DrawingParams): EditorObject | null => {
const width = pointerPos.x - startPos.x;
const height = pointerPos.y - startPos.y;
if (tool === "rectangle") {
return {
id: tempObject?.id || `rect-${Date.now()}`,
type: "rectangle",
x: Math.min(startPos.x, pointerPos.x),
y: Math.min(startPos.y, pointerPos.y),
width: Math.abs(width),
height: Math.abs(height),
fill: "#3b82f6",
stroke: "#1e40af",
strokeWidth: 2,
};
}
if (tool === "circle") {
const radius = Math.sqrt(width * width + height * height);
return {
id: tempObject?.id || `circle-${Date.now()}`,
type: "circle",
x: startPos.x,
y: startPos.y,
width: radius * 2,
height: radius * 2,
fill: "#10b981",
stroke: "#059669",
strokeWidth: 2,
};
}
if (tool === "line") {
return {
id: tempObject?.id || `line-${Date.now()}`,
type: "line",
x: startPos.x,
y: startPos.y,
width: pointerPos.x,
height: pointerPos.y,
stroke: "#000000",
strokeWidth: 2,
};
}
if (tool === "arrow") {
return {
id: tempObject?.id || `arrow-${Date.now()}`,
type: "arrow",
x: startPos.x,
y: startPos.y,
width: pointerPos.x,
height: pointerPos.y,
stroke: "#000000",
strokeWidth: 2,
};
}
return null;
};
@@ -0,0 +1,97 @@
import { useState, useRef, useEffect } from "react";
import Konva from "konva";
import { useEditorStore, type EditorObject } from "../../store/editorStore";
import { createDrawingObject } from "./drawingUtils";
export const useDrawingHandlers = () => {
const [isDrawing, setIsDrawing] = useState(false);
const [startPos, setStartPos] = useState({ x: 0, y: 0 });
const [tempObject, setTempObject] = useState<EditorObject | null>(null);
const transformerRef = useRef<Konva.Transformer>(null);
const { tool, addObject, updateObject, setSelectedObjectId } = useEditorStore();
useEffect(() => {
setTempObject(null);
setIsDrawing(false);
}, [tool]);
const handleStageMouseDown = (e: Konva.KonvaEventObject<MouseEvent>) => {
const stage = e.target.getStage();
if (!stage) return;
const pointerPos = stage.getPointerPosition();
if (!pointerPos) return;
if (tool === "select") {
const clickedOnEmpty = e.target === stage;
if (clickedOnEmpty) {
setSelectedObjectId(null);
if (transformerRef.current) {
transformerRef.current.nodes([]);
}
}
return;
}
setIsDrawing(true);
setStartPos(pointerPos);
if (tool === "text") {
const newText: EditorObject = {
id: `text-${Date.now()}`,
type: "text",
x: pointerPos.x,
y: pointerPos.y,
text: "متن جدید",
fontSize: 24,
fill: "#000000",
};
addObject(newText);
setSelectedObjectId(newText.id);
}
};
const handleStageMouseMove = (e: Konva.KonvaEventObject<MouseEvent>) => {
if (!isDrawing || tool === "select" || tool === "text") return;
const stage = e.target.getStage();
if (!stage) return;
const pointerPos = stage.getPointerPosition();
if (!pointerPos) return;
const newObject = createDrawingObject({ tool, startPos, pointerPos, tempObject });
if (!newObject) return;
if (!tempObject) {
addObject(newObject);
setTempObject(newObject);
} else {
const updates: Partial<EditorObject> =
tool === "circle"
? { width: newObject.width, height: newObject.height }
: {
x: newObject.x,
y: newObject.y,
width: newObject.width,
height: newObject.height,
};
updateObject(tempObject.id, updates);
}
};
const handleStageMouseUp = () => {
setIsDrawing(false);
setTempObject(null);
};
return {
isDrawing,
transformerRef,
handleStageMouseDown,
handleStageMouseMove,
handleStageMouseUp,
};
};
@@ -0,0 +1,34 @@
import { useState, useEffect } from "react";
const A4_WIDTH = 794;
const A4_HEIGHT = 1123;
const SCALE = 0.8;
export const useStageSize = () => {
const [stageSize, setStageSize] = useState({
width: A4_WIDTH * SCALE,
height: A4_HEIGHT * SCALE,
});
useEffect(() => {
const handleResize = () => {
const maxWidth = window.innerWidth - 400;
const maxHeight = window.innerHeight - 100;
const scaleX = maxWidth / A4_WIDTH;
const scaleY = maxHeight / A4_HEIGHT;
const newScale = Math.min(scaleX, scaleY, SCALE);
setStageSize({
width: A4_WIDTH * newScale,
height: A4_HEIGHT * newScale,
});
};
handleResize();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
return stageSize;
};
@@ -0,0 +1,69 @@
import { Trash } from "iconsax-react";
import type { EditorObject } from "../../store/editorStore";
import {
TextSettings,
ShapeSettings,
LineSettings,
SizeSettings,
LinkSettings,
VideoSettings,
TransformSettings,
} from "./settings";
type ObjectSettingsProps = {
selectedObject: EditorObject;
onUpdate: (id: string, updates: Partial<EditorObject>) => void;
onDelete: (id: string) => void;
};
const ObjectSettings = ({ selectedObject, onUpdate, onDelete }: ObjectSettingsProps) => {
return (
<div className="space-y-4">
<div className="flex items-center justify-between pb-4 border-b border-border">
<h3 className="text-sm font-medium">تنظیمات شیء</h3>
<button
onClick={() => onDelete(selectedObject.id)}
className="p-2 hover:bg-red-50 rounded-lg transition-colors text-red-500"
>
<Trash size={20} />
</button>
</div>
{selectedObject.type === "text" && <TextSettings selectedObject={selectedObject} onUpdate={onUpdate} />}
{(selectedObject.type === "rectangle" || selectedObject.type === "circle") && (
<ShapeSettings selectedObject={selectedObject} onUpdate={onUpdate} />
)}
{(selectedObject.type === "line" || selectedObject.type === "arrow") && (
<LineSettings selectedObject={selectedObject} onUpdate={onUpdate} />
)}
{selectedObject.type === "image" && (
<SizeSettings selectedObject={selectedObject} onUpdate={onUpdate} />
)}
{selectedObject.type === "link" && (
<LinkSettings selectedObject={selectedObject} onUpdate={onUpdate} />
)}
{selectedObject.type === "video" && (
<VideoSettings selectedObject={selectedObject} onUpdate={onUpdate} />
)}
{(selectedObject.type === "document" || selectedObject.type === "sticker") && (
<SizeSettings
selectedObject={selectedObject}
onUpdate={onUpdate}
defaultWidth={selectedObject.type === "document" ? 200 : 100}
defaultHeight={selectedObject.type === "document" ? 250 : 100}
/>
)}
<TransformSettings selectedObject={selectedObject} onUpdate={onUpdate} />
</div>
);
};
export default ObjectSettings;
@@ -0,0 +1,30 @@
import type { ToolType } from "../../store/editorStore";
import { ImageUpload, DocumentUpload, VideoInput, LinkInput, SimpleInstruction } from "./instructions";
type ToolInstructionsProps = {
tool: ToolType;
onImageUpload: (e: React.ChangeEvent<HTMLInputElement>) => void;
};
const ToolInstructions = ({ tool, onImageUpload }: ToolInstructionsProps) => {
if (tool === "image") {
return <ImageUpload onImageUpload={onImageUpload} />;
}
if (tool === "document") {
return <DocumentUpload />;
}
if (tool === "video") {
return <VideoInput />;
}
if (tool === "link") {
return <LinkInput />;
}
return <SimpleInstruction tool={tool} />;
};
export default ToolInstructions;
@@ -0,0 +1,65 @@
import { clx } from "@/helpers/utils";
import {
DocumentUpload,
Element,
Gallery,
Grid8,
Link,
MouseSquare,
Shapes,
Sticker,
Text,
VideoSquare,
Minus,
} from "iconsax-react";
import type { ToolType } from "../../store/editorStore";
const tools: Array<{ icon: (color: string, variant?: "Bold" | "Outline" | "Broken" | "Bulk" | "Linear" | "TwoTone") => React.ReactNode; tool: ToolType; label: string }> = [
{ icon: (color, variant) => <MouseSquare size={20} color={color} variant={variant} />, tool: "select", label: "انتخاب" },
{ icon: (color, variant) => <Shapes size={20} color={color} variant={variant} />, tool: "rectangle", label: "مستطیل" },
{ icon: (color, variant) => <Shapes size={20} color={color} variant={variant} />, tool: "circle", label: "دایره" },
{ icon: (color, variant) => <Text size={20} color={color} variant={variant} />, tool: "text", label: "متن" },
{ icon: (color, variant) => <Minus size={20} color={color} variant={variant} />, tool: "line", label: "خط" },
{ icon: (color, variant) => <Grid8 size={20} color={color} variant={variant} />, tool: "grid", label: "گرید" },
{ icon: (color, variant) => <Sticker size={20} color={color} variant={variant} />, tool: "sticker", label: "استیکر" },
{ icon: (color, variant) => <DocumentUpload size={20} color={color} variant={variant} />, tool: "document", label: "سند" },
{ icon: (color, variant) => <Element size={20} color={color} variant={variant} />, tool: "arrow", label: "پیکان" },
{ icon: (color, variant) => <Gallery size={20} color={color} variant={variant} />, tool: "image", label: "تصویر" },
{ icon: (color, variant) => <VideoSquare size={20} color={color} variant={variant} />, tool: "video", label: "ویدیو" },
{ icon: (color, variant) => <Link size={20} color={color} variant={variant} />, tool: "link", label: "لینک" },
];
type ToolsBarProps = {
tool: ToolType;
onToolClick: (tool: ToolType) => void;
};
const ToolsBar = ({ tool, onToolClick }: ToolsBarProps) => {
return (
<div className="flex h-full max-w-[72px] flex-1 flex-col items-center gap-4 overflow-y-auto border-l border-border py-2">
{tools.map((item, index) => {
const isActive = tool === item.tool;
const iconColor = "black";
const iconVariant = isActive ? "Bold" : undefined;
return (
<button
key={index}
onClick={() => onToolClick(item.tool)}
className={clx(
"min-h-10 w-10 flex items-center justify-center rounded-lg transition-colors text-black",
// isActive
// ? "bg-black text-white"
// : "hover:bg-gray-100 text-black",
)}
title={item.label}
>
{item.icon(iconColor, iconVariant)}
</button>
);
})}
</div>
);
};
export default ToolsBar;
@@ -0,0 +1,4 @@
export { default as ToolsBar } from "./ToolsBar";
export { default as ObjectSettings } from "./ObjectSettings";
export { default as ToolInstructions } from "./ToolInstructions";
@@ -0,0 +1,51 @@
import { DocumentUpload } from "iconsax-react";
import { useEditorStore, type ToolType } from "../../../store/editorStore";
import Button from "@/components/Button";
const DocumentUploadComponent = () => {
return (
<div className="space-y-4">
<div>
<label className="block text-sm mb-2">آپلود سند</label>
<input
type="file"
accept=".pdf,.doc,.docx"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
const fileUrl = event.target?.result as string;
const newDocument = {
id: `document-${Date.now()}`,
type: "document" as ToolType,
x: 100,
y: 100,
width: 200,
height: 250,
imageUrl: fileUrl,
};
const { addObject } = useEditorStore.getState();
addObject(newDocument);
};
reader.readAsDataURL(file);
}
}}
className="hidden"
id="document-upload"
/>
<Button
variant="outline"
onClick={() => document.getElementById("document-upload")?.click()}
className="w-full"
>
<DocumentUpload size={20} />
انتخاب سند
</Button>
</div>
</div>
);
};
export default DocumentUploadComponent;
@@ -0,0 +1,32 @@
import { Gallery } from "iconsax-react";
import Button from "@/components/Button";
type ImageUploadProps = {
onImageUpload: (e: React.ChangeEvent<HTMLInputElement>) => void;
};
const ImageUpload = ({ onImageUpload }: ImageUploadProps) => {
return (
<div>
<label className="block text-sm mb-2">آپلود تصویر</label>
<input
type="file"
accept="image/*"
onChange={onImageUpload}
className="hidden"
id="image-upload"
/>
<Button
variant="outline"
onClick={() => document.getElementById("image-upload")?.click()}
className="w-full"
>
<Gallery size={20} />
انتخاب تصویر
</Button>
</div>
);
};
export default ImageUpload;
@@ -0,0 +1,39 @@
import { useEditorStore, type ToolType } from "../../../store/editorStore";
import Input from "@/components/Input";
const LinkInput = () => {
return (
<div className="space-y-4">
<div>
<Input
label="آدرس لینک"
placeholder="https://example.com"
onEnter={() => {
const input = document.querySelector('input[placeholder="https://example.com"]') as HTMLInputElement;
if (input?.value) {
const newLink = {
id: `link-${Date.now()}`,
type: "link" as ToolType,
x: 100,
y: 100,
text: input.value,
linkUrl: input.value,
fontSize: 24,
fill: "#0000ff",
};
const { addObject } = useEditorStore.getState();
addObject(newLink);
input.value = "";
}
}}
/>
</div>
<div className="text-sm text-gray-600">
<p>آدرس لینک را وارد کنید و Enter بزنید</p>
</div>
</div>
);
};
export default LinkInput;
@@ -0,0 +1,31 @@
import type { ToolType } from "../../../store/editorStore";
type SimpleInstructionProps = {
tool: ToolType;
};
const SimpleInstruction = ({ tool }: SimpleInstructionProps) => {
const instructions: Record<ToolType, string> = {
select: "برای انتخاب و جابجایی اشیاء، روی آن‌ها کلیک کنید",
rectangle: "برای رسم مستطیل، روی کانوس کلیک کرده و بکشید",
circle: "برای رسم دایره، روی کانوس کلیک کرده و بکشید",
text: "برای افزودن متن، روی کانوس کلیک کنید",
line: "برای رسم خط، روی کانوس کلیک کرده و بکشید",
arrow: "برای رسم پیکان، روی کانوس کلیک کرده و بکشید",
sticker: "برای افزودن استیکر، روی کانوس کلیک کنید",
grid: "برای افزودن گرید، روی کانوس کلیک کنید",
image: "",
document: "",
video: "",
link: "",
};
return (
<div className="text-sm text-gray-600">
<p>{instructions[tool]}</p>
</div>
);
};
export default SimpleInstruction;
@@ -0,0 +1,38 @@
import { useEditorStore, type ToolType } from "../../../store/editorStore";
import Input from "@/components/Input";
const VideoInput = () => {
return (
<div className="space-y-4">
<div>
<Input
label="آدرس ویدیو"
placeholder="https://example.com/video.mp4"
onEnter={() => {
const input = document.querySelector('input[placeholder="https://example.com/video.mp4"]') as HTMLInputElement;
if (input?.value) {
const newVideo = {
id: `video-${Date.now()}`,
type: "video" as ToolType,
x: 100,
y: 100,
width: 400,
height: 300,
videoUrl: input.value,
};
const { addObject } = useEditorStore.getState();
addObject(newVideo);
input.value = "";
}
}}
/>
</div>
<div className="text-sm text-gray-600">
<p>آدرس ویدیو را وارد کنید</p>
</div>
</div>
);
};
export default VideoInput;
@@ -0,0 +1,6 @@
export { default as ImageUpload } from "./ImageUpload";
export { default as DocumentUpload } from "./DocumentUpload";
export { default as VideoInput } from "./VideoInput";
export { default as LinkInput } from "./LinkInput";
export { default as SimpleInstruction } from "./SimpleInstruction";
@@ -0,0 +1,33 @@
import type { EditorObject } from "../../../store/editorStore";
import Input from "@/components/Input";
type LineSettingsProps = {
selectedObject: EditorObject;
onUpdate: (id: string, updates: Partial<EditorObject>) => void;
};
const LineSettings = ({ selectedObject, onUpdate }: LineSettingsProps) => {
return (
<>
<Input
label="رنگ خط"
type="color"
value={selectedObject.stroke || "#000000"}
onChange={(e) => onUpdate(selectedObject.id, { stroke: e.target.value })}
/>
<Input
label="ضخامت خط"
type="number"
value={selectedObject.strokeWidth || 2}
onChange={(e) =>
onUpdate(selectedObject.id, {
strokeWidth: parseInt(e.target.value) || 2,
})
}
/>
</>
);
};
export default LineSettings;
@@ -0,0 +1,44 @@
import type { EditorObject } from "../../../store/editorStore";
import Input from "@/components/Input";
type LinkSettingsProps = {
selectedObject: EditorObject;
onUpdate: (id: string, updates: Partial<EditorObject>) => void;
};
const LinkSettings = ({ selectedObject, onUpdate }: LinkSettingsProps) => {
return (
<>
<Input
label="آدرس لینک"
value={selectedObject.linkUrl || ""}
onChange={(e) => onUpdate(selectedObject.id, { linkUrl: e.target.value })}
placeholder="https://example.com"
/>
<Input
label="متن لینک"
value={selectedObject.text || ""}
onChange={(e) => onUpdate(selectedObject.id, { text: e.target.value })}
/>
<Input
label="اندازه فونت"
type="number"
value={selectedObject.fontSize || 24}
onChange={(e) =>
onUpdate(selectedObject.id, {
fontSize: parseInt(e.target.value) || 24,
})
}
/>
<Input
label="رنگ متن"
type="color"
value={selectedObject.fill || "#0000ff"}
onChange={(e) => onUpdate(selectedObject.id, { fill: e.target.value })}
/>
</>
);
};
export default LinkSettings;
@@ -0,0 +1,59 @@
import type { EditorObject } from "../../../store/editorStore";
import Input from "@/components/Input";
type ShapeSettingsProps = {
selectedObject: EditorObject;
onUpdate: (id: string, updates: Partial<EditorObject>) => void;
};
const ShapeSettings = ({ selectedObject, onUpdate }: ShapeSettingsProps) => {
return (
<>
<Input
label="عرض"
type="number"
value={selectedObject.width || 100}
onChange={(e) =>
onUpdate(selectedObject.id, {
width: parseInt(e.target.value) || 100,
})
}
/>
<Input
label="ارتفاع"
type="number"
value={selectedObject.height || 100}
onChange={(e) =>
onUpdate(selectedObject.id, {
height: parseInt(e.target.value) || 100,
})
}
/>
<Input
label="رنگ پس‌زمینه"
type="color"
value={selectedObject.fill || "#3b82f6"}
onChange={(e) => onUpdate(selectedObject.id, { fill: e.target.value })}
/>
<Input
label="رنگ خط"
type="color"
value={selectedObject.stroke || "#1e40af"}
onChange={(e) => onUpdate(selectedObject.id, { stroke: e.target.value })}
/>
<Input
label="ضخامت خط"
type="number"
value={selectedObject.strokeWidth || 2}
onChange={(e) =>
onUpdate(selectedObject.id, {
strokeWidth: parseInt(e.target.value) || 2,
})
}
/>
</>
);
};
export default ShapeSettings;
@@ -0,0 +1,39 @@
import type { EditorObject } from "../../../store/editorStore";
import Input from "@/components/Input";
type SizeSettingsProps = {
selectedObject: EditorObject;
onUpdate: (id: string, updates: Partial<EditorObject>) => void;
defaultWidth?: number;
defaultHeight?: number;
};
const SizeSettings = ({ selectedObject, onUpdate, defaultWidth = 100, defaultHeight = 100 }: SizeSettingsProps) => {
return (
<>
<Input
label="عرض"
type="number"
value={selectedObject.width || defaultWidth}
onChange={(e) =>
onUpdate(selectedObject.id, {
width: parseInt(e.target.value) || defaultWidth,
})
}
/>
<Input
label="ارتفاع"
type="number"
value={selectedObject.height || defaultHeight}
onChange={(e) =>
onUpdate(selectedObject.id, {
height: parseInt(e.target.value) || defaultHeight,
})
}
/>
</>
);
};
export default SizeSettings;
@@ -0,0 +1,38 @@
import type { EditorObject } from "../../../store/editorStore";
import Input from "@/components/Input";
type TextSettingsProps = {
selectedObject: EditorObject;
onUpdate: (id: string, updates: Partial<EditorObject>) => void;
};
const TextSettings = ({ selectedObject, onUpdate }: TextSettingsProps) => {
return (
<>
<Input
label="متن"
value={selectedObject.text || ""}
onChange={(e) => onUpdate(selectedObject.id, { text: e.target.value })}
/>
<Input
label="اندازه فونت"
type="number"
value={selectedObject.fontSize || 24}
onChange={(e) =>
onUpdate(selectedObject.id, {
fontSize: parseInt(e.target.value) || 24,
})
}
/>
<Input
label="رنگ متن"
type="color"
value={selectedObject.fill || "#000000"}
onChange={(e) => onUpdate(selectedObject.id, { fill: e.target.value })}
/>
</>
);
};
export default TextSettings;
@@ -0,0 +1,47 @@
import type { EditorObject } from "../../../store/editorStore";
import Input from "@/components/Input";
type TransformSettingsProps = {
selectedObject: EditorObject;
onUpdate: (id: string, updates: Partial<EditorObject>) => void;
};
const TransformSettings = ({ selectedObject, onUpdate }: TransformSettingsProps) => {
return (
<div className="pt-4 border-t border-border">
<Input
label="موقعیت X"
type="number"
value={Math.round(selectedObject.x)}
onChange={(e) =>
onUpdate(selectedObject.id, {
x: parseInt(e.target.value) || 0,
})
}
/>
<Input
label="موقعیت Y"
type="number"
value={Math.round(selectedObject.y)}
onChange={(e) =>
onUpdate(selectedObject.id, {
y: parseInt(e.target.value) || 0,
})
}
/>
<Input
label="چرخش (درجه)"
type="number"
value={selectedObject.rotation || 0}
onChange={(e) =>
onUpdate(selectedObject.id, {
rotation: parseInt(e.target.value) || 0,
})
}
/>
</div>
);
};
export default TransformSettings;
@@ -0,0 +1,25 @@
import type { EditorObject } from "../../../store/editorStore";
import Input from "@/components/Input";
import SizeSettings from "./SizeSettings";
type VideoSettingsProps = {
selectedObject: EditorObject;
onUpdate: (id: string, updates: Partial<EditorObject>) => void;
};
const VideoSettings = ({ selectedObject, onUpdate }: VideoSettingsProps) => {
return (
<>
<Input
label="آدرس ویدیو"
value={selectedObject.videoUrl || ""}
onChange={(e) => onUpdate(selectedObject.id, { videoUrl: e.target.value })}
placeholder="https://example.com/video.mp4"
/>
<SizeSettings selectedObject={selectedObject} onUpdate={onUpdate} defaultWidth={400} defaultHeight={300} />
</>
);
};
export default VideoSettings;
@@ -0,0 +1,8 @@
export { default as TextSettings } from "./TextSettings";
export { default as ShapeSettings } from "./ShapeSettings";
export { default as LineSettings } from "./LineSettings";
export { default as SizeSettings } from "./SizeSettings";
export { default as LinkSettings } from "./LinkSettings";
export { default as VideoSettings } from "./VideoSettings";
export { default as TransformSettings } from "./TransformSettings";
@@ -0,0 +1,44 @@
import { useEffect, useRef } from "react";
import { Arrow } from "react-konva";
import Konva from "konva";
import type { ShapeProps } from "./types";
const ArrowShape = ({ obj, isSelected, transformerRef, onSelect, onUpdate, draggable }: ShapeProps) => {
const shapeRef = useRef<Konva.Arrow>(null);
useEffect(() => {
if (isSelected && shapeRef.current && transformerRef.current) {
transformerRef.current.nodes([shapeRef.current]);
transformerRef.current.getLayer()?.batchDraw();
}
}, [isSelected, transformerRef]);
return (
<Arrow
ref={shapeRef}
x={obj.x}
y={obj.y}
points={[0, 0, (obj.width || 0) - obj.x, (obj.height || 0) - obj.y]}
stroke={isSelected ? "#3b82f6" : obj.stroke || "#000000"}
strokeWidth={isSelected ? 3 : obj.strokeWidth || 2}
fill={isSelected ? "#3b82f6" : obj.stroke || "#000000"}
rotation={obj.rotation || 0}
draggable={draggable}
onClick={() => {
if (shapeRef.current) {
onSelect(obj.id, shapeRef.current);
}
}}
onDragEnd={(e) => {
const node = e.target;
onUpdate(obj.id, {
x: node.x(),
y: node.y(),
});
}}
/>
);
};
export default ArrowShape;
@@ -0,0 +1,62 @@
import { useEffect, useRef } from "react";
import { Circle } from "react-konva";
import Konva from "konva";
import type { ShapeProps } from "./types";
const CircleShape = ({ obj, isSelected, transformerRef, onSelect, onUpdate, draggable }: ShapeProps) => {
const shapeRef = useRef<Konva.Circle>(null);
useEffect(() => {
if (isSelected && shapeRef.current && transformerRef.current) {
transformerRef.current.nodes([shapeRef.current]);
transformerRef.current.getLayer()?.batchDraw();
}
}, [isSelected, transformerRef]);
return (
<Circle
ref={shapeRef}
x={obj.x}
y={obj.y}
radius={(obj.width || 50) / 2}
fill={obj.fill}
stroke={isSelected ? "#3b82f6" : obj.stroke}
strokeWidth={isSelected ? 3 : obj.strokeWidth || 2}
rotation={obj.rotation || 0}
draggable={draggable}
onClick={() => {
if (shapeRef.current) {
onSelect(obj.id, shapeRef.current);
}
}}
onDragEnd={(e) => {
const node = e.target;
onUpdate(obj.id, {
x: node.x(),
y: node.y(),
});
}}
onTransformEnd={() => {
const node = shapeRef.current;
if (!node) return;
const scaleX = node.scaleX();
node.scaleX(1);
node.scaleY(1);
const newRadius = Math.max(5, ((obj.width || 50) / 2) * scaleX);
onUpdate(obj.id, {
x: node.x(),
y: node.y(),
rotation: node.rotation(),
width: newRadius * 2,
height: newRadius * 2,
});
}}
/>
);
};
export default CircleShape;
@@ -0,0 +1,74 @@
import { useEffect, useRef } from "react";
import { Image as KonvaImage } from "react-konva";
import Konva from "konva";
import useImage from "use-image";
import type { ShapeProps } from "./types";
const ImageObject = ({
obj,
isSelected,
transformerRef,
onSelect,
onUpdate,
draggable,
}: ShapeProps) => {
const [image] = useImage(obj.imageUrl || "");
const shapeRef = useRef<Konva.Image>(null);
useEffect(() => {
if (isSelected && shapeRef.current && transformerRef.current) {
transformerRef.current.nodes([shapeRef.current]);
transformerRef.current.getLayer()?.batchDraw();
}
}, [isSelected, transformerRef]);
if (!image) return null;
return (
<KonvaImage
ref={shapeRef}
x={obj.x}
y={obj.y}
image={image}
width={obj.width || image.width}
height={obj.height || image.height}
stroke={isSelected ? "#3b82f6" : undefined}
strokeWidth={isSelected ? 3 : 0}
rotation={obj.rotation || 0}
draggable={draggable}
onClick={() => {
if (shapeRef.current) {
onSelect(obj.id, shapeRef.current);
}
}}
onDragEnd={(e) => {
const node = e.target;
onUpdate(obj.id, {
x: node.x(),
y: node.y(),
});
}}
onTransformEnd={() => {
const node = shapeRef.current;
if (!node) return;
const scaleX = node.scaleX();
const scaleY = node.scaleY();
node.scaleX(1);
node.scaleY(1);
onUpdate(obj.id, {
x: node.x(),
y: node.y(),
rotation: node.rotation(),
width: Math.max(5, (obj.width || image.width) * scaleX),
height: Math.max(5, (obj.height || image.height) * scaleY),
});
}}
/>
);
};
export default ImageObject;
@@ -0,0 +1,43 @@
import { useEffect, useRef } from "react";
import { Line } from "react-konva";
import Konva from "konva";
import type { ShapeProps } from "./types";
const LineShape = ({ obj, isSelected, transformerRef, onSelect, onUpdate, draggable }: ShapeProps) => {
const shapeRef = useRef<Konva.Line>(null);
useEffect(() => {
if (isSelected && shapeRef.current && transformerRef.current) {
transformerRef.current.nodes([shapeRef.current]);
transformerRef.current.getLayer()?.batchDraw();
}
}, [isSelected, transformerRef]);
return (
<Line
ref={shapeRef}
x={obj.x}
y={obj.y}
points={[0, 0, (obj.width || 0) - obj.x, (obj.height || 0) - obj.y]}
stroke={isSelected ? "#3b82f6" : obj.stroke || "#000000"}
strokeWidth={isSelected ? 3 : obj.strokeWidth || 2}
rotation={obj.rotation || 0}
draggable={draggable}
onClick={() => {
if (shapeRef.current) {
onSelect(obj.id, shapeRef.current);
}
}}
onDragEnd={(e) => {
const node = e.target;
onUpdate(obj.id, {
x: node.x(),
y: node.y(),
});
}}
/>
);
};
export default LineShape;
@@ -0,0 +1,71 @@
import { useEffect, useRef } from "react";
import { Text } from "react-konva";
import Konva from "konva";
import type { ShapeProps } from "./types";
const LinkShape = ({
obj,
isSelected,
transformerRef,
onSelect,
onUpdate,
draggable,
}: ShapeProps) => {
const shapeRef = useRef<Konva.Text>(null);
useEffect(() => {
if (isSelected && shapeRef.current && transformerRef.current) {
transformerRef.current.nodes([shapeRef.current]);
transformerRef.current.getLayer()?.batchDraw();
}
}, [isSelected, transformerRef]);
return (
<Text
ref={shapeRef}
x={obj.x}
y={obj.y}
text={obj.text || obj.linkUrl || ""}
fontSize={obj.fontSize || 24}
fill={obj.fill || "#0000ff"}
fontFamily="irancell"
underline={true}
rotation={obj.rotation || 0}
draggable={draggable}
onClick={() => {
if (shapeRef.current && obj.linkUrl) {
window.open(obj.linkUrl, "_blank");
}
if (shapeRef.current) {
onSelect(obj.id, shapeRef.current);
}
}}
onDragEnd={(e) => {
const node = e.target;
onUpdate(obj.id, {
x: node.x(),
y: node.y(),
});
}}
onTransformEnd={() => {
const node = shapeRef.current;
if (!node) return;
const scaleX = node.scaleX();
node.scaleX(1);
node.scaleY(1);
onUpdate(obj.id, {
x: node.x(),
y: node.y(),
rotation: node.rotation(),
fontSize: obj.fontSize ? Math.max(5, obj.fontSize * scaleX) : undefined,
});
}}
/>
);
};
export default LinkShape;
@@ -0,0 +1,63 @@
import { useEffect, useRef } from "react";
import { Rect } from "react-konva";
import Konva from "konva";
import type { ShapeProps } from "./types";
const RectangleShape = ({ obj, isSelected, transformerRef, onSelect, onUpdate, draggable }: ShapeProps) => {
const shapeRef = useRef<Konva.Rect>(null);
useEffect(() => {
if (isSelected && shapeRef.current && transformerRef.current) {
transformerRef.current.nodes([shapeRef.current]);
transformerRef.current.getLayer()?.batchDraw();
}
}, [isSelected, transformerRef]);
return (
<Rect
ref={shapeRef}
x={obj.x}
y={obj.y}
width={obj.width || 100}
height={obj.height || 100}
fill={obj.fill}
stroke={isSelected ? "#3b82f6" : obj.stroke}
strokeWidth={isSelected ? 3 : obj.strokeWidth || 2}
rotation={obj.rotation || 0}
draggable={draggable}
onClick={() => {
if (shapeRef.current) {
onSelect(obj.id, shapeRef.current);
}
}}
onDragEnd={(e) => {
const node = e.target;
onUpdate(obj.id, {
x: node.x(),
y: node.y(),
});
}}
onTransformEnd={() => {
const node = shapeRef.current;
if (!node) return;
const scaleX = node.scaleX();
const scaleY = node.scaleY();
node.scaleX(1);
node.scaleY(1);
onUpdate(obj.id, {
x: node.x(),
y: node.y(),
rotation: node.rotation(),
width: Math.max(5, (obj.width || 100) * scaleX),
height: Math.max(5, (obj.height || 100) * scaleY),
});
}}
/>
);
};
export default RectangleShape;
@@ -0,0 +1,122 @@
import { useEffect, useRef } from "react";
import { Text } from "react-konva";
import Konva from "konva";
import type { TextShapeProps } from "./types";
const TextShape = ({
obj,
isSelected,
transformerRef,
onSelect,
onUpdate,
draggable,
layerRef,
}: TextShapeProps) => {
const shapeRef = useRef<Konva.Text>(null);
useEffect(() => {
if (isSelected && shapeRef.current && transformerRef.current) {
transformerRef.current.nodes([shapeRef.current]);
transformerRef.current.getLayer()?.batchDraw();
}
}, [isSelected, transformerRef]);
return (
<Text
ref={shapeRef}
x={obj.x}
y={obj.y}
text={obj.text || ""}
fontSize={obj.fontSize || 24}
fill={obj.fill || "#000000"}
fontFamily="irancell"
width={obj.width}
height={obj.height}
rotation={obj.rotation || 0}
draggable={draggable}
onClick={() => {
if (shapeRef.current) {
onSelect(obj.id, shapeRef.current);
}
}}
onDragEnd={(e) => {
const node = e.target;
onUpdate(obj.id, {
x: node.x(),
y: node.y(),
});
}}
onTransformEnd={() => {
const node = shapeRef.current;
if (!node) return;
const scaleX = node.scaleX();
const scaleY = node.scaleY();
node.scaleX(1);
node.scaleY(1);
onUpdate(obj.id, {
x: node.x(),
y: node.y(),
rotation: node.rotation(),
width: obj.width ? Math.max(5, obj.width * scaleX) : undefined,
height: obj.height ? Math.max(5, obj.height * scaleY) : undefined,
});
}}
onDblClick={(e) => {
const textNode = e.target as Konva.Text;
const stage = textNode.getStage();
if (!stage) return;
const textPosition = textNode.absolutePosition();
const areaPosition = {
x: stage.container().offsetLeft + textPosition.x,
y: stage.container().offsetTop + textPosition.y,
};
const textarea = document.createElement("textarea");
document.body.appendChild(textarea);
textarea.value = textNode.text();
textarea.style.position = "absolute";
textarea.style.top = `${areaPosition.y}px`;
textarea.style.left = `${areaPosition.x}px`;
textarea.style.width = `${textNode.width()}px`;
textarea.style.fontSize = `${textNode.fontSize()}px`;
textarea.style.border = "none";
textarea.style.padding = "0px";
textarea.style.margin = "0px";
textarea.style.overflow = "hidden";
textarea.style.background = "transparent";
textarea.style.outline = "none";
textarea.style.resize = "none";
textarea.style.fontFamily = "irancell";
textarea.style.direction = "rtl";
textarea.focus();
const removeTextarea = () => {
document.body.removeChild(textarea);
textNode.text(textarea.value);
onUpdate(obj.id, { text: textarea.value });
layerRef.current?.draw();
};
textarea.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
removeTextarea();
}
if (e.key === "Escape") {
removeTextarea();
}
});
textarea.addEventListener("blur", () => {
removeTextarea();
});
}}
/>
);
};
export default TextShape;
@@ -0,0 +1,86 @@
import { useEffect, useRef } from "react";
import { Rect, Text } from "react-konva";
import Konva from "konva";
import type { ShapeProps } from "./types";
const VideoShape = ({
obj,
isSelected,
transformerRef,
onSelect,
onUpdate,
draggable,
}: ShapeProps) => {
const shapeRef = useRef<Konva.Rect>(null);
useEffect(() => {
if (isSelected && shapeRef.current && transformerRef.current) {
transformerRef.current.nodes([shapeRef.current]);
transformerRef.current.getLayer()?.batchDraw();
}
}, [isSelected, transformerRef]);
return (
<>
<Rect
ref={shapeRef}
x={obj.x}
y={obj.y}
width={obj.width || 400}
height={obj.height || 300}
fill="#000000"
stroke={isSelected ? "#3b82f6" : "#666666"}
strokeWidth={isSelected ? 3 : 2}
rotation={obj.rotation || 0}
draggable={draggable}
onClick={() => {
if (shapeRef.current) {
onSelect(obj.id, shapeRef.current);
}
}}
onDragEnd={(e) => {
const node = e.target;
onUpdate(obj.id, {
x: node.x(),
y: node.y(),
});
}}
onTransformEnd={() => {
const node = shapeRef.current;
if (!node) return;
const scaleX = node.scaleX();
const scaleY = node.scaleY();
node.scaleX(1);
node.scaleY(1);
onUpdate(obj.id, {
x: node.x(),
y: node.y(),
rotation: node.rotation(),
width: Math.max(5, (obj.width || 400) * scaleX),
height: Math.max(5, (obj.height || 300) * scaleY),
});
}}
/>
{obj.videoUrl && (
<Text
x={obj.x + (obj.width || 400) / 2}
y={obj.y + (obj.height || 300) / 2}
text="▶ ویدیو"
fontSize={16}
fill="#ffffff"
fontFamily="irancell"
align="center"
verticalAlign="middle"
offsetX={40}
offsetY={8}
/>
)}
</>
);
};
export default VideoShape;
@@ -0,0 +1,9 @@
export { default as RectangleShape } from "./RectangleShape";
export { default as CircleShape } from "./CircleShape";
export { default as TextShape } from "./TextShape";
export { default as LineShape } from "./LineShape";
export { default as ArrowShape } from "./ArrowShape";
export { default as ImageObject } from "./ImageObject";
export { default as LinkShape } from "./LinkShape";
export { default as VideoShape } from "./VideoShape";
export type { ShapeProps, TextShapeProps } from "./types";
@@ -0,0 +1,16 @@
import Konva from "konva";
import { EditorObject } from "../../store/editorStore";
export type ShapeProps = {
obj: EditorObject;
isSelected: boolean;
transformerRef: React.RefObject<Konva.Transformer | null>;
onSelect: (id: string, node: Konva.Node) => void;
onUpdate: (id: string, updates: Partial<EditorObject>) => void;
draggable: boolean;
};
export type TextShapeProps = ShapeProps & {
layerRef: React.RefObject<Konva.Layer | null>;
};
+62
View File
@@ -0,0 +1,62 @@
import { create } from "zustand";
import Konva from "konva";
export type ToolType = "select" | "rectangle" | "circle" | "text" | "image" | "line" | "arrow" | "sticker" | "document" | "video" | "link" | "grid";
export type EditorObject = {
id: string;
type: ToolType;
x: number;
y: number;
width?: number;
height?: number;
fill?: string;
stroke?: string;
strokeWidth?: number;
text?: string;
fontSize?: number;
imageUrl?: string;
videoUrl?: string;
linkUrl?: string;
rotation?: number;
scaleX?: number;
scaleY?: number;
};
type EditorStoreType = {
tool: ToolType;
setTool: (tool: ToolType) => void;
objects: EditorObject[];
addObject: (object: EditorObject) => void;
updateObject: (id: string, updates: Partial<EditorObject>) => void;
deleteObject: (id: string) => void;
selectedObjectId: string | null;
setSelectedObjectId: (id: string | null) => void;
stageRef: React.RefObject<Konva.Stage | null> | null;
setStageRef: (ref: React.RefObject<Konva.Stage | null>) => void;
layerRef: React.RefObject<Konva.Layer | null> | null;
setLayerRef: (ref: React.RefObject<Konva.Layer | null>) => void;
};
export const useEditorStore = create<EditorStoreType>((set) => ({
tool: "select",
setTool: (tool) => set({ tool }),
objects: [],
addObject: (object) => set((state) => ({ objects: [...state.objects, object] })),
updateObject: (id, updates) =>
set((state) => ({
objects: state.objects.map((obj) => (obj.id === id ? { ...obj, ...updates } : obj)),
})),
deleteObject: (id) =>
set((state) => ({
objects: state.objects.filter((obj) => obj.id !== id),
selectedObjectId: state.selectedObjectId === id ? null : state.selectedObjectId,
})),
selectedObjectId: null,
setSelectedObjectId: (id) => set({ selectedObjectId: id }),
stageRef: null,
setStageRef: (ref) => set({ stageRef: ref }),
layerRef: null,
setLayerRef: (ref) => set({ layerRef: ref }),
}));