diff --git a/package-lock.json b/package-lock.json
index e5d66a2..ba7ac90 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 1e16dea..783d848 100644
--- a/package.json
+++ b/package.json
@@ -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": {
diff --git a/src/pages/editor/Editor.tsx b/src/pages/editor/Editor.tsx
index 6f248af..6246a5c 100644
--- a/src/pages/editor/Editor.tsx
+++ b/src/pages/editor/Editor.tsx
@@ -1,9 +1,11 @@
import { type FC } from 'react'
import EditorSidebar from './components/EditorSidebar'
+import EditorCanvas from './components/EditorCanvas'
const Editor: FC = () => {
return (
-
+
+
)
diff --git a/src/pages/editor/components/EditorCanvas.tsx b/src/pages/editor/components/EditorCanvas.tsx
new file mode 100644
index 0000000..8f83afa
--- /dev/null
+++ b/src/pages/editor/components/EditorCanvas.tsx
@@ -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
(null);
+ const layerRef = useRef(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 (
+
+
+
+ {objects.map((obj) => (
+
+ ))}
+ {selectedObjectId && (
+ {
+ 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",
+ ]}
+ />
+ )}
+
+
+
+ );
+};
+
+export default EditorCanvas;
diff --git a/src/pages/editor/components/EditorSidebar.tsx b/src/pages/editor/components/EditorSidebar.tsx
index 67b700f..c297fcb 100644
--- a/src/pages/editor/components/EditorSidebar.tsx
+++ b/src/pages/editor/components/EditorSidebar.tsx
@@ -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) => {
+ 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",
)}
>
-
+

+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- s
+
+
+
+ {selectedObject ? (
+
+ ) : (
+
+ )}
-
>
);
};
export default EditorSidebar;
-
diff --git a/src/pages/editor/components/canvas/ObjectRenderer.tsx b/src/pages/editor/components/canvas/ObjectRenderer.tsx
new file mode 100644
index 0000000..c86bc35
--- /dev/null
+++ b/src/pages/editor/components/canvas/ObjectRenderer.tsx
@@ -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
;
+ layerRef: React.RefObject;
+ onSelect: (id: string, node: Konva.Node) => void;
+ onUpdate: (id: string, updates: Partial) => 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 ;
+ case "circle":
+ return ;
+ case "text":
+ return ;
+ case "line":
+ return ;
+ case "arrow":
+ return ;
+ case "image":
+ return ;
+ case "link":
+ return ;
+ case "video":
+ return ;
+ case "document":
+ return ;
+ default:
+ return null;
+ }
+};
+
+export default ObjectRenderer;
+
diff --git a/src/pages/editor/components/canvas/drawingUtils.ts b/src/pages/editor/components/canvas/drawingUtils.ts
new file mode 100644
index 0000000..0a5ed2d
--- /dev/null
+++ b/src/pages/editor/components/canvas/drawingUtils.ts
@@ -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;
+};
diff --git a/src/pages/editor/components/canvas/useDrawingHandlers.ts b/src/pages/editor/components/canvas/useDrawingHandlers.ts
new file mode 100644
index 0000000..0cc9b6a
--- /dev/null
+++ b/src/pages/editor/components/canvas/useDrawingHandlers.ts
@@ -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(null);
+ const transformerRef = useRef(null);
+
+ const { tool, addObject, updateObject, setSelectedObjectId } = useEditorStore();
+
+ useEffect(() => {
+ setTempObject(null);
+ setIsDrawing(false);
+ }, [tool]);
+
+ const handleStageMouseDown = (e: Konva.KonvaEventObject) => {
+ 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) => {
+ 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 =
+ 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,
+ };
+};
+
diff --git a/src/pages/editor/components/canvas/useStageSize.ts b/src/pages/editor/components/canvas/useStageSize.ts
new file mode 100644
index 0000000..8403c2c
--- /dev/null
+++ b/src/pages/editor/components/canvas/useStageSize.ts
@@ -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;
+};
+
diff --git a/src/pages/editor/components/sidebar/ObjectSettings.tsx b/src/pages/editor/components/sidebar/ObjectSettings.tsx
new file mode 100644
index 0000000..8f2e6fe
--- /dev/null
+++ b/src/pages/editor/components/sidebar/ObjectSettings.tsx
@@ -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) => void;
+ onDelete: (id: string) => void;
+};
+
+const ObjectSettings = ({ selectedObject, onUpdate, onDelete }: ObjectSettingsProps) => {
+ return (
+
+
+
تنظیمات شیء
+
+
+
+ {selectedObject.type === "text" &&
}
+
+ {(selectedObject.type === "rectangle" || selectedObject.type === "circle") && (
+
+ )}
+
+ {(selectedObject.type === "line" || selectedObject.type === "arrow") && (
+
+ )}
+
+ {selectedObject.type === "image" && (
+
+ )}
+
+ {selectedObject.type === "link" && (
+
+ )}
+
+ {selectedObject.type === "video" && (
+
+ )}
+
+ {(selectedObject.type === "document" || selectedObject.type === "sticker") && (
+
+ )}
+
+
+
+ );
+};
+
+export default ObjectSettings;
+
diff --git a/src/pages/editor/components/sidebar/ToolInstructions.tsx b/src/pages/editor/components/sidebar/ToolInstructions.tsx
new file mode 100644
index 0000000..e189f94
--- /dev/null
+++ b/src/pages/editor/components/sidebar/ToolInstructions.tsx
@@ -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) => void;
+};
+
+const ToolInstructions = ({ tool, onImageUpload }: ToolInstructionsProps) => {
+ if (tool === "image") {
+ return ;
+ }
+
+ if (tool === "document") {
+ return ;
+ }
+
+ if (tool === "video") {
+ return ;
+ }
+
+ if (tool === "link") {
+ return ;
+ }
+
+ return ;
+};
+
+export default ToolInstructions;
+
diff --git a/src/pages/editor/components/sidebar/ToolsBar.tsx b/src/pages/editor/components/sidebar/ToolsBar.tsx
new file mode 100644
index 0000000..86b4531
--- /dev/null
+++ b/src/pages/editor/components/sidebar/ToolsBar.tsx
@@ -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) => , tool: "select", label: "انتخاب" },
+ { icon: (color, variant) => , tool: "rectangle", label: "مستطیل" },
+ { icon: (color, variant) => , tool: "circle", label: "دایره" },
+ { icon: (color, variant) => , tool: "text", label: "متن" },
+ { icon: (color, variant) => , tool: "line", label: "خط" },
+ { icon: (color, variant) => , tool: "grid", label: "گرید" },
+ { icon: (color, variant) => , tool: "sticker", label: "استیکر" },
+ { icon: (color, variant) => , tool: "document", label: "سند" },
+ { icon: (color, variant) => , tool: "arrow", label: "پیکان" },
+ { icon: (color, variant) => , tool: "image", label: "تصویر" },
+ { icon: (color, variant) => , tool: "video", label: "ویدیو" },
+ { icon: (color, variant) => , tool: "link", label: "لینک" },
+];
+
+type ToolsBarProps = {
+ tool: ToolType;
+ onToolClick: (tool: ToolType) => void;
+};
+
+const ToolsBar = ({ tool, onToolClick }: ToolsBarProps) => {
+ return (
+
+ {tools.map((item, index) => {
+ const isActive = tool === item.tool;
+ const iconColor = "black";
+ const iconVariant = isActive ? "Bold" : undefined;
+ return (
+
+ );
+ })}
+
+ );
+};
+
+export default ToolsBar;
+
diff --git a/src/pages/editor/components/sidebar/index.ts b/src/pages/editor/components/sidebar/index.ts
new file mode 100644
index 0000000..2b5a27d
--- /dev/null
+++ b/src/pages/editor/components/sidebar/index.ts
@@ -0,0 +1,4 @@
+export { default as ToolsBar } from "./ToolsBar";
+export { default as ObjectSettings } from "./ObjectSettings";
+export { default as ToolInstructions } from "./ToolInstructions";
+
diff --git a/src/pages/editor/components/sidebar/instructions/DocumentUpload.tsx b/src/pages/editor/components/sidebar/instructions/DocumentUpload.tsx
new file mode 100644
index 0000000..4b1188a
--- /dev/null
+++ b/src/pages/editor/components/sidebar/instructions/DocumentUpload.tsx
@@ -0,0 +1,51 @@
+import { DocumentUpload } from "iconsax-react";
+import { useEditorStore, type ToolType } from "../../../store/editorStore";
+import Button from "@/components/Button";
+
+const DocumentUploadComponent = () => {
+ return (
+
+
+
+ {
+ 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"
+ />
+
+
+
+ );
+};
+
+export default DocumentUploadComponent;
+
diff --git a/src/pages/editor/components/sidebar/instructions/ImageUpload.tsx b/src/pages/editor/components/sidebar/instructions/ImageUpload.tsx
new file mode 100644
index 0000000..b5d2d1e
--- /dev/null
+++ b/src/pages/editor/components/sidebar/instructions/ImageUpload.tsx
@@ -0,0 +1,32 @@
+import { Gallery } from "iconsax-react";
+import Button from "@/components/Button";
+
+type ImageUploadProps = {
+ onImageUpload: (e: React.ChangeEvent) => void;
+};
+
+const ImageUpload = ({ onImageUpload }: ImageUploadProps) => {
+ return (
+
+
+
+
+
+ );
+};
+
+export default ImageUpload;
+
diff --git a/src/pages/editor/components/sidebar/instructions/LinkInput.tsx b/src/pages/editor/components/sidebar/instructions/LinkInput.tsx
new file mode 100644
index 0000000..9e1ee06
--- /dev/null
+++ b/src/pages/editor/components/sidebar/instructions/LinkInput.tsx
@@ -0,0 +1,39 @@
+import { useEditorStore, type ToolType } from "../../../store/editorStore";
+import Input from "@/components/Input";
+
+const LinkInput = () => {
+ return (
+
+
+ {
+ 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 = "";
+ }
+ }}
+ />
+
+
+
آدرس لینک را وارد کنید و Enter بزنید
+
+
+ );
+};
+
+export default LinkInput;
+
diff --git a/src/pages/editor/components/sidebar/instructions/SimpleInstruction.tsx b/src/pages/editor/components/sidebar/instructions/SimpleInstruction.tsx
new file mode 100644
index 0000000..77f9412
--- /dev/null
+++ b/src/pages/editor/components/sidebar/instructions/SimpleInstruction.tsx
@@ -0,0 +1,31 @@
+import type { ToolType } from "../../../store/editorStore";
+
+type SimpleInstructionProps = {
+ tool: ToolType;
+};
+
+const SimpleInstruction = ({ tool }: SimpleInstructionProps) => {
+ const instructions: Record = {
+ select: "برای انتخاب و جابجایی اشیاء، روی آنها کلیک کنید",
+ rectangle: "برای رسم مستطیل، روی کانوس کلیک کرده و بکشید",
+ circle: "برای رسم دایره، روی کانوس کلیک کرده و بکشید",
+ text: "برای افزودن متن، روی کانوس کلیک کنید",
+ line: "برای رسم خط، روی کانوس کلیک کرده و بکشید",
+ arrow: "برای رسم پیکان، روی کانوس کلیک کرده و بکشید",
+ sticker: "برای افزودن استیکر، روی کانوس کلیک کنید",
+ grid: "برای افزودن گرید، روی کانوس کلیک کنید",
+ image: "",
+ document: "",
+ video: "",
+ link: "",
+ };
+
+ return (
+
+ );
+};
+
+export default SimpleInstruction;
+
diff --git a/src/pages/editor/components/sidebar/instructions/VideoInput.tsx b/src/pages/editor/components/sidebar/instructions/VideoInput.tsx
new file mode 100644
index 0000000..897f3a7
--- /dev/null
+++ b/src/pages/editor/components/sidebar/instructions/VideoInput.tsx
@@ -0,0 +1,38 @@
+import { useEditorStore, type ToolType } from "../../../store/editorStore";
+import Input from "@/components/Input";
+
+const VideoInput = () => {
+ return (
+
+
+ {
+ 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 = "";
+ }
+ }}
+ />
+
+
+
آدرس ویدیو را وارد کنید
+
+
+ );
+};
+
+export default VideoInput;
+
diff --git a/src/pages/editor/components/sidebar/instructions/index.ts b/src/pages/editor/components/sidebar/instructions/index.ts
new file mode 100644
index 0000000..1f8814d
--- /dev/null
+++ b/src/pages/editor/components/sidebar/instructions/index.ts
@@ -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";
+
diff --git a/src/pages/editor/components/sidebar/settings/LineSettings.tsx b/src/pages/editor/components/sidebar/settings/LineSettings.tsx
new file mode 100644
index 0000000..3727e56
--- /dev/null
+++ b/src/pages/editor/components/sidebar/settings/LineSettings.tsx
@@ -0,0 +1,33 @@
+import type { EditorObject } from "../../../store/editorStore";
+import Input from "@/components/Input";
+
+type LineSettingsProps = {
+ selectedObject: EditorObject;
+ onUpdate: (id: string, updates: Partial) => void;
+};
+
+const LineSettings = ({ selectedObject, onUpdate }: LineSettingsProps) => {
+ return (
+ <>
+ onUpdate(selectedObject.id, { stroke: e.target.value })}
+ />
+
+ onUpdate(selectedObject.id, {
+ strokeWidth: parseInt(e.target.value) || 2,
+ })
+ }
+ />
+ >
+ );
+};
+
+export default LineSettings;
+
diff --git a/src/pages/editor/components/sidebar/settings/LinkSettings.tsx b/src/pages/editor/components/sidebar/settings/LinkSettings.tsx
new file mode 100644
index 0000000..c0db372
--- /dev/null
+++ b/src/pages/editor/components/sidebar/settings/LinkSettings.tsx
@@ -0,0 +1,44 @@
+import type { EditorObject } from "../../../store/editorStore";
+import Input from "@/components/Input";
+
+type LinkSettingsProps = {
+ selectedObject: EditorObject;
+ onUpdate: (id: string, updates: Partial) => void;
+};
+
+const LinkSettings = ({ selectedObject, onUpdate }: LinkSettingsProps) => {
+ return (
+ <>
+ onUpdate(selectedObject.id, { linkUrl: e.target.value })}
+ placeholder="https://example.com"
+ />
+ onUpdate(selectedObject.id, { text: e.target.value })}
+ />
+
+ onUpdate(selectedObject.id, {
+ fontSize: parseInt(e.target.value) || 24,
+ })
+ }
+ />
+ onUpdate(selectedObject.id, { fill: e.target.value })}
+ />
+ >
+ );
+};
+
+export default LinkSettings;
+
diff --git a/src/pages/editor/components/sidebar/settings/ShapeSettings.tsx b/src/pages/editor/components/sidebar/settings/ShapeSettings.tsx
new file mode 100644
index 0000000..ba580b7
--- /dev/null
+++ b/src/pages/editor/components/sidebar/settings/ShapeSettings.tsx
@@ -0,0 +1,59 @@
+import type { EditorObject } from "../../../store/editorStore";
+import Input from "@/components/Input";
+
+type ShapeSettingsProps = {
+ selectedObject: EditorObject;
+ onUpdate: (id: string, updates: Partial) => void;
+};
+
+const ShapeSettings = ({ selectedObject, onUpdate }: ShapeSettingsProps) => {
+ return (
+ <>
+
+ onUpdate(selectedObject.id, {
+ width: parseInt(e.target.value) || 100,
+ })
+ }
+ />
+
+ onUpdate(selectedObject.id, {
+ height: parseInt(e.target.value) || 100,
+ })
+ }
+ />
+ onUpdate(selectedObject.id, { fill: e.target.value })}
+ />
+ onUpdate(selectedObject.id, { stroke: e.target.value })}
+ />
+
+ onUpdate(selectedObject.id, {
+ strokeWidth: parseInt(e.target.value) || 2,
+ })
+ }
+ />
+ >
+ );
+};
+
+export default ShapeSettings;
+
diff --git a/src/pages/editor/components/sidebar/settings/SizeSettings.tsx b/src/pages/editor/components/sidebar/settings/SizeSettings.tsx
new file mode 100644
index 0000000..adbb636
--- /dev/null
+++ b/src/pages/editor/components/sidebar/settings/SizeSettings.tsx
@@ -0,0 +1,39 @@
+import type { EditorObject } from "../../../store/editorStore";
+import Input from "@/components/Input";
+
+type SizeSettingsProps = {
+ selectedObject: EditorObject;
+ onUpdate: (id: string, updates: Partial) => void;
+ defaultWidth?: number;
+ defaultHeight?: number;
+};
+
+const SizeSettings = ({ selectedObject, onUpdate, defaultWidth = 100, defaultHeight = 100 }: SizeSettingsProps) => {
+ return (
+ <>
+
+ onUpdate(selectedObject.id, {
+ width: parseInt(e.target.value) || defaultWidth,
+ })
+ }
+ />
+
+ onUpdate(selectedObject.id, {
+ height: parseInt(e.target.value) || defaultHeight,
+ })
+ }
+ />
+ >
+ );
+};
+
+export default SizeSettings;
+
diff --git a/src/pages/editor/components/sidebar/settings/TextSettings.tsx b/src/pages/editor/components/sidebar/settings/TextSettings.tsx
new file mode 100644
index 0000000..0811495
--- /dev/null
+++ b/src/pages/editor/components/sidebar/settings/TextSettings.tsx
@@ -0,0 +1,38 @@
+import type { EditorObject } from "../../../store/editorStore";
+import Input from "@/components/Input";
+
+type TextSettingsProps = {
+ selectedObject: EditorObject;
+ onUpdate: (id: string, updates: Partial) => void;
+};
+
+const TextSettings = ({ selectedObject, onUpdate }: TextSettingsProps) => {
+ return (
+ <>
+ onUpdate(selectedObject.id, { text: e.target.value })}
+ />
+
+ onUpdate(selectedObject.id, {
+ fontSize: parseInt(e.target.value) || 24,
+ })
+ }
+ />
+ onUpdate(selectedObject.id, { fill: e.target.value })}
+ />
+ >
+ );
+};
+
+export default TextSettings;
+
diff --git a/src/pages/editor/components/sidebar/settings/TransformSettings.tsx b/src/pages/editor/components/sidebar/settings/TransformSettings.tsx
new file mode 100644
index 0000000..c85d6d2
--- /dev/null
+++ b/src/pages/editor/components/sidebar/settings/TransformSettings.tsx
@@ -0,0 +1,47 @@
+import type { EditorObject } from "../../../store/editorStore";
+import Input from "@/components/Input";
+
+type TransformSettingsProps = {
+ selectedObject: EditorObject;
+ onUpdate: (id: string, updates: Partial) => void;
+};
+
+const TransformSettings = ({ selectedObject, onUpdate }: TransformSettingsProps) => {
+ return (
+
+
+ onUpdate(selectedObject.id, {
+ x: parseInt(e.target.value) || 0,
+ })
+ }
+ />
+
+ onUpdate(selectedObject.id, {
+ y: parseInt(e.target.value) || 0,
+ })
+ }
+ />
+
+ onUpdate(selectedObject.id, {
+ rotation: parseInt(e.target.value) || 0,
+ })
+ }
+ />
+
+ );
+};
+
+export default TransformSettings;
+
diff --git a/src/pages/editor/components/sidebar/settings/VideoSettings.tsx b/src/pages/editor/components/sidebar/settings/VideoSettings.tsx
new file mode 100644
index 0000000..b625efa
--- /dev/null
+++ b/src/pages/editor/components/sidebar/settings/VideoSettings.tsx
@@ -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) => void;
+};
+
+const VideoSettings = ({ selectedObject, onUpdate }: VideoSettingsProps) => {
+ return (
+ <>
+ onUpdate(selectedObject.id, { videoUrl: e.target.value })}
+ placeholder="https://example.com/video.mp4"
+ />
+
+ >
+ );
+};
+
+export default VideoSettings;
+
diff --git a/src/pages/editor/components/sidebar/settings/index.ts b/src/pages/editor/components/sidebar/settings/index.ts
new file mode 100644
index 0000000..96f7f21
--- /dev/null
+++ b/src/pages/editor/components/sidebar/settings/index.ts
@@ -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";
+
diff --git a/src/pages/editor/components/tools/ArrowShape.tsx b/src/pages/editor/components/tools/ArrowShape.tsx
new file mode 100644
index 0000000..6285500
--- /dev/null
+++ b/src/pages/editor/components/tools/ArrowShape.tsx
@@ -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(null);
+
+ useEffect(() => {
+ if (isSelected && shapeRef.current && transformerRef.current) {
+ transformerRef.current.nodes([shapeRef.current]);
+ transformerRef.current.getLayer()?.batchDraw();
+ }
+ }, [isSelected, transformerRef]);
+
+ return (
+ {
+ 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;
+
diff --git a/src/pages/editor/components/tools/CircleShape.tsx b/src/pages/editor/components/tools/CircleShape.tsx
new file mode 100644
index 0000000..a21a593
--- /dev/null
+++ b/src/pages/editor/components/tools/CircleShape.tsx
@@ -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(null);
+
+ useEffect(() => {
+ if (isSelected && shapeRef.current && transformerRef.current) {
+ transformerRef.current.nodes([shapeRef.current]);
+ transformerRef.current.getLayer()?.batchDraw();
+ }
+ }, [isSelected, transformerRef]);
+
+ return (
+ {
+ 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;
+
diff --git a/src/pages/editor/components/tools/ImageObject.tsx b/src/pages/editor/components/tools/ImageObject.tsx
new file mode 100644
index 0000000..dd36b41
--- /dev/null
+++ b/src/pages/editor/components/tools/ImageObject.tsx
@@ -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(null);
+
+ useEffect(() => {
+ if (isSelected && shapeRef.current && transformerRef.current) {
+ transformerRef.current.nodes([shapeRef.current]);
+ transformerRef.current.getLayer()?.batchDraw();
+ }
+ }, [isSelected, transformerRef]);
+
+ if (!image) return null;
+
+ return (
+ {
+ 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;
+
diff --git a/src/pages/editor/components/tools/LineShape.tsx b/src/pages/editor/components/tools/LineShape.tsx
new file mode 100644
index 0000000..9881aa9
--- /dev/null
+++ b/src/pages/editor/components/tools/LineShape.tsx
@@ -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(null);
+
+ useEffect(() => {
+ if (isSelected && shapeRef.current && transformerRef.current) {
+ transformerRef.current.nodes([shapeRef.current]);
+ transformerRef.current.getLayer()?.batchDraw();
+ }
+ }, [isSelected, transformerRef]);
+
+ return (
+ {
+ 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;
+
diff --git a/src/pages/editor/components/tools/LinkShape.tsx b/src/pages/editor/components/tools/LinkShape.tsx
new file mode 100644
index 0000000..ce56c54
--- /dev/null
+++ b/src/pages/editor/components/tools/LinkShape.tsx
@@ -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(null);
+
+ useEffect(() => {
+ if (isSelected && shapeRef.current && transformerRef.current) {
+ transformerRef.current.nodes([shapeRef.current]);
+ transformerRef.current.getLayer()?.batchDraw();
+ }
+ }, [isSelected, transformerRef]);
+
+ return (
+ {
+ 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;
+
diff --git a/src/pages/editor/components/tools/RectangleShape.tsx b/src/pages/editor/components/tools/RectangleShape.tsx
new file mode 100644
index 0000000..ac5e00f
--- /dev/null
+++ b/src/pages/editor/components/tools/RectangleShape.tsx
@@ -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(null);
+
+ useEffect(() => {
+ if (isSelected && shapeRef.current && transformerRef.current) {
+ transformerRef.current.nodes([shapeRef.current]);
+ transformerRef.current.getLayer()?.batchDraw();
+ }
+ }, [isSelected, transformerRef]);
+
+ return (
+ {
+ 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;
+
diff --git a/src/pages/editor/components/tools/TextShape.tsx b/src/pages/editor/components/tools/TextShape.tsx
new file mode 100644
index 0000000..5a6fa94
--- /dev/null
+++ b/src/pages/editor/components/tools/TextShape.tsx
@@ -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(null);
+
+ useEffect(() => {
+ if (isSelected && shapeRef.current && transformerRef.current) {
+ transformerRef.current.nodes([shapeRef.current]);
+ transformerRef.current.getLayer()?.batchDraw();
+ }
+ }, [isSelected, transformerRef]);
+
+ return (
+ {
+ 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;
+
diff --git a/src/pages/editor/components/tools/VideoShape.tsx b/src/pages/editor/components/tools/VideoShape.tsx
new file mode 100644
index 0000000..f09fe19
--- /dev/null
+++ b/src/pages/editor/components/tools/VideoShape.tsx
@@ -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(null);
+
+ useEffect(() => {
+ if (isSelected && shapeRef.current && transformerRef.current) {
+ transformerRef.current.nodes([shapeRef.current]);
+ transformerRef.current.getLayer()?.batchDraw();
+ }
+ }, [isSelected, transformerRef]);
+
+ return (
+ <>
+ {
+ 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 && (
+
+ )}
+ >
+ );
+};
+
+export default VideoShape;
+
diff --git a/src/pages/editor/components/tools/index.ts b/src/pages/editor/components/tools/index.ts
new file mode 100644
index 0000000..6006fc7
--- /dev/null
+++ b/src/pages/editor/components/tools/index.ts
@@ -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";
diff --git a/src/pages/editor/components/tools/types.ts b/src/pages/editor/components/tools/types.ts
new file mode 100644
index 0000000..4701c09
--- /dev/null
+++ b/src/pages/editor/components/tools/types.ts
@@ -0,0 +1,16 @@
+import Konva from "konva";
+import { EditorObject } from "../../store/editorStore";
+
+export type ShapeProps = {
+ obj: EditorObject;
+ isSelected: boolean;
+ transformerRef: React.RefObject;
+ onSelect: (id: string, node: Konva.Node) => void;
+ onUpdate: (id: string, updates: Partial) => void;
+ draggable: boolean;
+};
+
+export type TextShapeProps = ShapeProps & {
+ layerRef: React.RefObject;
+};
+
diff --git a/src/pages/editor/store/editorStore.ts b/src/pages/editor/store/editorStore.ts
new file mode 100644
index 0000000..53c1853
--- /dev/null
+++ b/src/pages/editor/store/editorStore.ts
@@ -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) => void;
+ deleteObject: (id: string) => void;
+ selectedObjectId: string | null;
+ setSelectedObjectId: (id: string | null) => void;
+ stageRef: React.RefObject | null;
+ setStageRef: (ref: React.RefObject) => void;
+ layerRef: React.RefObject | null;
+ setLayerRef: (ref: React.RefObject) => void;
+};
+
+export const useEditorStore = create((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 }),
+}));
+