From 1a0c8691014d5c1c89eed35a28263c192f96dce7 Mon Sep 17 00:00:00 2001 From: hamid zarghami Date: Sun, 16 Nov 2025 16:33:54 +0330 Subject: [PATCH] add konva js to project + create some components with ai --- package-lock.json | 113 +++++++++++++++- package.json | 3 + src/pages/editor/Editor.tsx | 4 +- src/pages/editor/components/EditorCanvas.tsx | 101 +++++++++++++++ src/pages/editor/components/EditorSidebar.tsx | 93 +++++++------ .../components/canvas/ObjectRenderer.tsx | 67 ++++++++++ .../editor/components/canvas/drawingUtils.ts | 75 +++++++++++ .../components/canvas/useDrawingHandlers.ts | 97 ++++++++++++++ .../editor/components/canvas/useStageSize.ts | 34 +++++ .../components/sidebar/ObjectSettings.tsx | 69 ++++++++++ .../components/sidebar/ToolInstructions.tsx | 30 +++++ .../editor/components/sidebar/ToolsBar.tsx | 65 ++++++++++ src/pages/editor/components/sidebar/index.ts | 4 + .../sidebar/instructions/DocumentUpload.tsx | 51 ++++++++ .../sidebar/instructions/ImageUpload.tsx | 32 +++++ .../sidebar/instructions/LinkInput.tsx | 39 ++++++ .../instructions/SimpleInstruction.tsx | 31 +++++ .../sidebar/instructions/VideoInput.tsx | 38 ++++++ .../components/sidebar/instructions/index.ts | 6 + .../sidebar/settings/LineSettings.tsx | 33 +++++ .../sidebar/settings/LinkSettings.tsx | 44 +++++++ .../sidebar/settings/ShapeSettings.tsx | 59 +++++++++ .../sidebar/settings/SizeSettings.tsx | 39 ++++++ .../sidebar/settings/TextSettings.tsx | 38 ++++++ .../sidebar/settings/TransformSettings.tsx | 47 +++++++ .../sidebar/settings/VideoSettings.tsx | 25 ++++ .../components/sidebar/settings/index.ts | 8 ++ .../editor/components/tools/ArrowShape.tsx | 44 +++++++ .../editor/components/tools/CircleShape.tsx | 62 +++++++++ .../editor/components/tools/ImageObject.tsx | 74 +++++++++++ .../editor/components/tools/LineShape.tsx | 43 ++++++ .../editor/components/tools/LinkShape.tsx | 71 ++++++++++ .../components/tools/RectangleShape.tsx | 63 +++++++++ .../editor/components/tools/TextShape.tsx | 122 ++++++++++++++++++ .../editor/components/tools/VideoShape.tsx | 86 ++++++++++++ src/pages/editor/components/tools/index.ts | 9 ++ src/pages/editor/components/tools/types.ts | 16 +++ src/pages/editor/store/editorStore.ts | 62 +++++++++ 38 files changed, 1855 insertions(+), 42 deletions(-) create mode 100644 src/pages/editor/components/EditorCanvas.tsx create mode 100644 src/pages/editor/components/canvas/ObjectRenderer.tsx create mode 100644 src/pages/editor/components/canvas/drawingUtils.ts create mode 100644 src/pages/editor/components/canvas/useDrawingHandlers.ts create mode 100644 src/pages/editor/components/canvas/useStageSize.ts create mode 100644 src/pages/editor/components/sidebar/ObjectSettings.tsx create mode 100644 src/pages/editor/components/sidebar/ToolInstructions.tsx create mode 100644 src/pages/editor/components/sidebar/ToolsBar.tsx create mode 100644 src/pages/editor/components/sidebar/index.ts create mode 100644 src/pages/editor/components/sidebar/instructions/DocumentUpload.tsx create mode 100644 src/pages/editor/components/sidebar/instructions/ImageUpload.tsx create mode 100644 src/pages/editor/components/sidebar/instructions/LinkInput.tsx create mode 100644 src/pages/editor/components/sidebar/instructions/SimpleInstruction.tsx create mode 100644 src/pages/editor/components/sidebar/instructions/VideoInput.tsx create mode 100644 src/pages/editor/components/sidebar/instructions/index.ts create mode 100644 src/pages/editor/components/sidebar/settings/LineSettings.tsx create mode 100644 src/pages/editor/components/sidebar/settings/LinkSettings.tsx create mode 100644 src/pages/editor/components/sidebar/settings/ShapeSettings.tsx create mode 100644 src/pages/editor/components/sidebar/settings/SizeSettings.tsx create mode 100644 src/pages/editor/components/sidebar/settings/TextSettings.tsx create mode 100644 src/pages/editor/components/sidebar/settings/TransformSettings.tsx create mode 100644 src/pages/editor/components/sidebar/settings/VideoSettings.tsx create mode 100644 src/pages/editor/components/sidebar/settings/index.ts create mode 100644 src/pages/editor/components/tools/ArrowShape.tsx create mode 100644 src/pages/editor/components/tools/CircleShape.tsx create mode 100644 src/pages/editor/components/tools/ImageObject.tsx create mode 100644 src/pages/editor/components/tools/LineShape.tsx create mode 100644 src/pages/editor/components/tools/LinkShape.tsx create mode 100644 src/pages/editor/components/tools/RectangleShape.tsx create mode 100644 src/pages/editor/components/tools/TextShape.tsx create mode 100644 src/pages/editor/components/tools/VideoShape.tsx create mode 100644 src/pages/editor/components/tools/index.ts create mode 100644 src/pages/editor/components/tools/types.ts create mode 100644 src/pages/editor/store/editorStore.ts 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 ( +
+

{instructions[tool]}

+
+ ); +}; + +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 }), +})); +