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