image section

This commit is contained in:
hamid zarghami
2025-12-04 09:34:04 +03:30
parent 2c28218488
commit 26d3bd26ab
7 changed files with 292 additions and 27 deletions
+48 -3
View File
@@ -17,6 +17,7 @@
"konva": "^10.0.9", "konva": "^10.0.9",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-dropzone": "^14.3.8",
"react-i18next": "^16.3.0", "react-i18next": "^16.3.0",
"react-konva": "^19.2.0", "react-konva": "^19.2.0",
"react-router-dom": "^7.9.5", "react-router-dom": "^7.9.5",
@@ -2238,6 +2239,15 @@
"dev": true, "dev": true,
"license": "Python-2.0" "license": "Python-2.0"
}, },
"node_modules/attr-accept": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz",
"integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/babel-plugin-react-compiler": { "node_modules/babel-plugin-react-compiler": {
"version": "19.1.0-rc.3", "version": "19.1.0-rc.3",
"resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-19.1.0-rc.3.tgz", "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-19.1.0-rc.3.tgz",
@@ -2827,6 +2837,18 @@
"node": ">=16.0.0" "node": ">=16.0.0"
} }
}, },
"node_modules/file-selector": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz",
"integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==",
"license": "MIT",
"dependencies": {
"tslib": "^2.7.0"
},
"engines": {
"node": ">= 12"
}
},
"node_modules/fill-range": { "node_modules/fill-range": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -3118,9 +3140,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "4.1.0", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -3815,6 +3837,23 @@
"react": "^19.2.0" "react": "^19.2.0"
} }
}, },
"node_modules/react-dropzone": {
"version": "14.3.8",
"resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.8.tgz",
"integrity": "sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==",
"license": "MIT",
"dependencies": {
"attr-accept": "^2.2.4",
"file-selector": "^2.1.0",
"prop-types": "^15.8.1"
},
"engines": {
"node": ">= 10.13"
},
"peerDependencies": {
"react": ">= 16.8 || 18.0.0"
}
},
"node_modules/react-i18next": { "node_modules/react-i18next": {
"version": "16.3.0", "version": "16.3.0",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.3.0.tgz", "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.3.0.tgz",
@@ -4209,6 +4248,12 @@
"typescript": ">=4.8.4" "typescript": ">=4.8.4"
} }
}, },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/type-check": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+1
View File
@@ -19,6 +19,7 @@
"konva": "^10.0.9", "konva": "^10.0.9",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-dropzone": "^14.3.8",
"react-i18next": "^16.3.0", "react-i18next": "^16.3.0",
"react-konva": "^19.2.0", "react-konva": "^19.2.0",
"react-router-dom": "^7.9.5", "react-router-dom": "^7.9.5",
+151
View File
@@ -0,0 +1,151 @@
import { CloseCircle, DocumentUpload, Gallery } from 'iconsax-react'
import { type FC, useCallback, useEffect, useState } from 'react'
import { useDropzone } from 'react-dropzone';
import { clx } from '../helpers/utils';
type Props = {
label: string;
onChange: (file: File[]) => void;
isMultiple?: boolean;
isFile?: boolean,
preview?: string[],
onChangePreview?: (preview: string[]) => void,
getCover?: (url: string) => void,
coverUrl?: string
}
const UploadBoxDraggble: FC<Props> = (props: Props) => {
const [files, setFiles] = useState<File[]>([])
const [perviews, setPerviews] = useState<string[]>(props.preview ? props.preview : [])
const [isFill, setIsFill] = useState<boolean>(false)
const [cover, setCover] = useState<string>(props.coverUrl ? props.coverUrl : '')
const onDrop = useCallback((acceptedFiles: File[]) => {
if (props.isMultiple) {
const array = [...files, ...acceptedFiles]
setFiles(array)
props.onChange(array)
} else {
setFiles([acceptedFiles[0]])
props.onChange([acceptedFiles[0]])
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [files])
const { getRootProps, getInputProps } = useDropzone({ onDrop })
const handleDelete = (index: number) => {
const array = [...files]
array.splice(index, 1)
setFiles(array)
props.onChange(array)
}
const handleDeletePreview = (index: number) => {
const array = [...perviews];
array.splice(index, 1);
setPerviews(array);
if (props.onChangePreview) {
props.onChangePreview(array);
}
}
useEffect(() => {
if (props.preview && props.preview.length > 0 && !isFill) {
setPerviews(props.preview)
console.log('preview', props.preview);
setIsFill(true)
}
}, [props.preview, isFill])
useEffect(() => {
setCover(props.coverUrl ? props.coverUrl : '')
}, [props.coverUrl])
return (
<div>
<div {...getRootProps()} className='w-full py-8 border border-dashed border-description flex flex-col justify-center items-center gap-4 rounded-2xl'>
<input {...getInputProps()} />
<Gallery
className='size-8'
color='#8C90A3'
/>
<div className='text-description text-xs'>
{props.label}
</div>
<div className='flex items-center gap-2'>
<DocumentUpload
className='size-4'
color='black'
/>
<div className='text-xs'>آپلود</div>
</div>
</div>
{
files.length > 0 || perviews.length > 0 ? (
<div className='mt-4 flex gap-4 items-center'>
{
perviews && perviews.map((item, index) => {
return (
<div key={index} className='flex items-center gap-2'>
<div key={index} className='flex relative items-center gap-2'>
<img onClick={() => {
if (props.getCover) {
setCover(item)
props.getCover(item)
}
}} src={item}
className={clx(
'size-10 rounded-full object-cover',
cover === item ? 'border-2 border-red-400' : ''
)} />
<div onClick={() => handleDeletePreview(index)} className='absolute -left-2 -top-2 shadow-md bg-white size-5 rounded-full flex justify-center items-center'>
<CloseCircle className='size-4 ' color='red' />
</div>
</div>
</div>
)
})
}
{
files.map((file, index) => {
if (props.isFile) {
return (
<div key={index} className='flex border p-2 rounded-lg items-center gap-2'>
<div className='flex relative items-center gap-2'>
<div className='text-xs'>{file.name}</div>
<div className='absolute -left-4 -top-4 shadow-md bg-white size-5 rounded-full flex justify-center items-center'>
<CloseCircle onClick={() => handleDelete(index)} className='size-4 ' color='red' />
</div>
</div>
</div>
)
}
else
return (
<div key={index} className='flex items-center gap-2'>
<div key={index} className='flex relative items-center gap-2'>
<img src={URL.createObjectURL(file)} alt={file.name} className='size-10 rounded-full object-cover' />
<div className='absolute -left-2 -top-2 shadow-md bg-white size-5 rounded-full flex justify-center items-center'>
<CloseCircle onClick={() => handleDelete(index)} className='size-4 ' color='red' />
</div>
</div>
</div>
)
})
}
</div>
) : null
}
</div>
)
}
export default UploadBoxDraggble
@@ -85,6 +85,38 @@ const EditorCanvas = () => {
setEditingCell(null); setEditingCell(null);
}; };
// Attach transformer to selected object when it changes
useEffect(() => {
if (!transformerRef.current || !selectedObjectId || !layerRef.current) return;
const selectedObject = objects.find((obj) => obj.id === selectedObjectId);
if (!selectedObject) return;
// For grid objects, handle separately
if (selectedObject.type === "grid") {
const tableNode = layerRef.current.findOne(`#${selectedObjectId}`);
if (tableNode && transformerRef.current) {
transformerRef.current.nodes([tableNode]);
transformerRef.current.getLayer()?.batchDraw();
}
return;
}
// For other objects (like images), find the node by id and attach transformer
// Use a small delay to ensure the node is mounted (especially for images that need to load)
const timeoutId = setTimeout(() => {
if (!layerRef.current || !transformerRef.current) return;
const node = layerRef.current.findOne(`#${selectedObjectId}`);
if (node && transformerRef.current) {
transformerRef.current.nodes([node]);
transformerRef.current.getLayer()?.batchDraw();
}
}, 100);
return () => clearTimeout(timeoutId);
}, [selectedObjectId, objects, transformerRef, layerRef]);
useEffect(() => { useEffect(() => {
if (!transformerRef.current || !selectedObjectId) return; if (!transformerRef.current || !selectedObjectId) return;
@@ -18,14 +18,14 @@ const tools: Array<{ icon: (color: string, variant?: "Bold" | "Outline" | "Broke
{ icon: (color, variant) => <MouseSquare size={20} color={color} variant={variant} />, tool: "select", label: "انتخاب" }, { 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: "rectangle", label: "مستطیل" },
{ icon: (color, variant) => <Text size={20} color={color} variant={variant} />, tool: "text", 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) => <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) => <Gallery size={20} color={color} variant={variant} />, tool: "image", label: "تصویر" },
{ icon: (color, variant) => <Link size={20} color={color} variant={variant} />, tool: "link", label: "لینک" },
{ icon: (color, variant) => <DocumentUpload size={20} color={color} variant={variant} />, tool: "document", 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) => <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) => <Minus size={20} color={color} variant={variant} />, tool: "line", label: "خط" },
{ icon: (color, variant) => <VideoSquare size={20} color={color} variant={variant} />, tool: "video", 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: "لینک" }, { icon: (color, variant) => <Sticker size={20} color={color} variant={variant} />, tool: "sticker", label: "استیکر" },
]; ];
type ToolsBarProps = { type ToolsBarProps = {
@@ -1,29 +1,64 @@
import { Gallery } from "iconsax-react"; import { type FC, useState, useRef } from "react";
import Button from "@/components/Button"; import UploadBoxDraggble from "@/components/UploadBoxDraggble";
type ImageUploadProps = { type ImageUploadProps = {
onImageUpload: (e: React.ChangeEvent<HTMLInputElement>) => void; onImageUpload: (e: React.ChangeEvent<HTMLInputElement>) => void;
}; };
const ImageUpload = ({ onImageUpload }: ImageUploadProps) => { const ImageUpload: FC<ImageUploadProps> = ({ onImageUpload }) => {
const [uploadedImages, setUploadedImages] = useState<string[]>([]);
const previousFilesCountRef = useRef<number>(0);
const handleFileChange = (files: File[]) => {
// Update ref to track current files count
if (files.length > previousFilesCountRef.current) {
// Process only new files
const newFiles = files.slice(previousFilesCountRef.current);
newFiles.forEach((file) => {
const reader = new FileReader();
reader.onload = (event) => {
const imageUrl = event.target?.result as string;
setUploadedImages((prev) => [...prev, imageUrl]);
// Create a synthetic event for onImageUpload
const syntheticEvent = {
target: {
files: [file],
},
} as unknown as React.ChangeEvent<HTMLInputElement>;
onImageUpload(syntheticEvent);
};
reader.readAsDataURL(file);
});
}
// Always update ref to track current files count
previousFilesCountRef.current = files.length;
};
const handlePreviewChange = (previews: string[]) => {
setUploadedImages(previews);
// Update ref when previews change (e.g., when files are deleted)
previousFilesCountRef.current = previews.length;
};
return ( return (
<div> <div>
<label className="block text-sm mb-2">آپلود تصویر</label> <div className="text-base font-bold mb-4">تصویر</div>
<input <UploadBoxDraggble
type="file" label="تصویر مورد نظر را آپلود کنید"
accept="image/*" onChange={handleFileChange}
onChange={onImageUpload} isMultiple={true}
className="hidden" preview={uploadedImages}
id="image-upload" onChangePreview={handlePreviewChange}
/> />
<Button {uploadedImages.length > 0 && (
variant="outline" <div className="mt-4 text-description text-xs">
onClick={() => document.getElementById("image-upload")?.click()} تصویر های آپلود شده
className="w-full" </div>
> )}
<Gallery size={20} />
انتخاب تصویر
</Button>
</div> </div>
); );
}; };
@@ -16,17 +16,18 @@ const ImageObject = ({
const shapeRef = useRef<Konva.Image>(null); const shapeRef = useRef<Konva.Image>(null);
useEffect(() => { useEffect(() => {
if (isSelected && shapeRef.current && transformerRef.current) { if (isSelected && shapeRef.current && transformerRef.current && image) {
transformerRef.current.nodes([shapeRef.current]); transformerRef.current.nodes([shapeRef.current]);
transformerRef.current.getLayer()?.batchDraw(); transformerRef.current.getLayer()?.batchDraw();
} }
}, [isSelected, transformerRef]); }, [isSelected, transformerRef, image]);
if (!image) return null; if (!image) return null;
return ( return (
<KonvaImage <KonvaImage
ref={shapeRef} ref={shapeRef}
id={obj.id}
x={obj.x} x={obj.x}
y={obj.y} y={obj.y}
image={image} image={image}