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