From 61d8e5a81429e3264fabe639399e280bba2a9ea7 Mon Sep 17 00:00:00 2001 From: hamid zarghami Date: Mon, 5 Jan 2026 12:27:58 +0330 Subject: [PATCH] base group --- src/components/Table.tsx | 4 +- src/pages/editor/components/EditorCanvas.tsx | 429 ++++++++++++++---- .../components/canvas/ObjectRenderer.tsx | 73 ++- .../components/canvas/useDrawingHandlers.ts | 8 +- .../components/canvas/useKeyboardMovement.ts | 77 +++- .../editor/components/tools/AbstractShape.tsx | 11 +- .../editor/components/tools/ArrowShape.tsx | 11 +- .../editor/components/tools/CircleShape.tsx | 11 +- .../editor/components/tools/ImageObject.tsx | 15 +- .../editor/components/tools/LineShape.tsx | 11 +- .../editor/components/tools/LinkShape.tsx | 11 +- .../components/tools/RectangleShape.tsx | 11 +- .../editor/components/tools/TextShape.tsx | 27 +- .../editor/components/tools/TriangleShape.tsx | 11 +- .../editor/components/tools/VideoShape.tsx | 9 +- src/pages/editor/components/tools/types.ts | 2 +- src/pages/editor/store/editorStore.ts | 117 ++++- 17 files changed, 607 insertions(+), 231 deletions(-) diff --git a/src/components/Table.tsx b/src/components/Table.tsx index e3a0d98..949f586 100644 --- a/src/components/Table.tsx +++ b/src/components/Table.tsx @@ -6,7 +6,7 @@ import type { EditorObject } from "@/pages/editor/store/editorStore"; type TableProps = { obj: EditorObject; selectedCellId: string | null; - onCellClick: (tableId: string, cellId: string, node: Konva.Node) => void; + onCellClick: (tableId: string, cellId: string, node: Konva.Node, e?: Konva.KonvaEventObject) => void; onCellDblClick: (tableId: string, cellId: string, x: number, y: number, width: number, height: number, text: string) => void; onDragEnd: (tableId: string, x: number, y: number) => void; draggable: boolean; @@ -36,7 +36,7 @@ const Table = ({ const handleCellClick = (cellId: string, e: Konva.KonvaEventObject) => { e.cancelBubble = true; if (groupRef.current) { - onCellClick(obj.id, cellId, groupRef.current); + onCellClick(obj.id, cellId, groupRef.current, e); } }; diff --git a/src/pages/editor/components/EditorCanvas.tsx b/src/pages/editor/components/EditorCanvas.tsx index 4160c07..84e22c6 100644 --- a/src/pages/editor/components/EditorCanvas.tsx +++ b/src/pages/editor/components/EditorCanvas.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { Stage, Layer, Transformer, Rect, Group, Text } from "react-konva"; import Konva from "konva"; import { useEditorStore, type EditorObject } from "../store/editorStore"; @@ -18,7 +18,11 @@ const EditorCanvas = () => { objects, updateObject, selectedObjectId, + selectedObjectIds, setSelectedObjectId, + setSelectedObjectIds, + addToSelection, + removeFromSelection, setSelectedTableId, selectedCellId, setSelectedCellId, @@ -46,37 +50,68 @@ const EditorCanvas = () => { } }, [setStageRef, setLayerRef]); - const handleSelect = (id: string, node: Konva.Node) => { + const handleSelect = (id: string, node: Konva.Node, e?: Konva.KonvaEventObject) => { const obj = objects.find((o) => o.id === id); + const isMultiSelect = e?.evt?.metaKey || e?.evt?.ctrlKey || e?.evt?.shiftKey; + + // اگر object در یک group است، group را انتخاب کن نه object + if (obj?.groupId && obj.type !== "group") { + const groupObject = objects.find((o) => o.id === obj.groupId); + if (groupObject) { + if (isMultiSelect) { + if (selectedObjectIds.includes(groupObject.id)) { + removeFromSelection(groupObject.id); + } else { + addToSelection(groupObject.id); + } + } else { + setSelectedObjectId(groupObject.id); + } + return; + } + } + + if (isMultiSelect) { + // انتخاب چندتایی + if (selectedObjectIds.includes(id)) { + // اگر قبلاً انتخاب شده بود، از انتخاب خارج کن + removeFromSelection(id); + } else { + // اگر انتخاب نشده بود، به انتخاب اضافه کن + addToSelection(id); + } + } else { + // انتخاب تک‌تایی + setSelectedObjectId(id); + } - setSelectedObjectId(id); if (obj?.type === "grid") { setSelectedTableId(id); - } else { + } else if (!isMultiSelect) { setSelectedTableId(null); } - setSelectedCellId(null); - if (transformerRef.current && layerRef.current) { - // اگر object با mask گروه شده، transformer را به Group متصل کن - const maskedGroup = layerRef.current.findOne(`#masked-${id}`); - const targetNode = maskedGroup || node; - transformerRef.current.nodes([targetNode]); - transformerRef.current.getLayer()?.batchDraw(); + + if (!isMultiSelect) { + setSelectedCellId(null); } }; - const handleCellClick = (tableId: string, cellId: string) => { - setSelectedTableId(tableId); - setSelectedObjectId(tableId); - setSelectedCellId(cellId); - const obj = objects.find((o) => o.id === tableId); - if (obj && transformerRef.current && layerRef.current) { - const tableNode = layerRef.current.findOne(`#${tableId}`); - if (tableNode) { - transformerRef.current.nodes([tableNode]); - transformerRef.current.getLayer()?.batchDraw(); + const handleCellClick = (tableId: string, cellId: string, node?: Konva.Node, e?: Konva.KonvaEventObject) => { + const isMultiSelect = e?.evt?.metaKey || e?.evt?.ctrlKey || e?.evt?.shiftKey; + + if (isMultiSelect) { + // انتخاب چندتایی + if (selectedObjectIds.includes(tableId)) { + removeFromSelection(tableId); + } else { + addToSelection(tableId); } + } else { + setSelectedTableId(tableId); + setSelectedObjectId(tableId); } + + setSelectedCellId(cellId); }; const handleCellDblClick = (tableId: string, cellId: string, x: number, y: number, width: number, height: number, text: string) => { @@ -98,13 +133,13 @@ const EditorCanvas = () => { const obj = objects.find((o) => o.id === id); if (!obj) return; - // اگر object در یک گروه است و position تغییر کرد، همه اعضای گروه را جابجا کن - if (obj.groupId && (updates.x !== undefined || updates.y !== undefined)) { - const groupMembers = objects.filter((o) => o.groupId === obj.groupId && o.id !== id); + // اگر group object است و position تغییر کرد، همه objectهای داخل group را جابجا کن + if (obj.type === "group" && (updates.x !== undefined || updates.y !== undefined)) { + const groupMembers = objects.filter((o) => o.groupId === id); const deltaX = updates.x !== undefined ? updates.x - (obj.x || 0) : 0; const deltaY = updates.y !== undefined ? updates.y - (obj.y || 0) : 0; - // به‌روزرسانی object اصلی + // به‌روزرسانی group object updateObject(id, updates); // به‌روزرسانی همه اعضای گروه @@ -114,6 +149,10 @@ const EditorCanvas = () => { y: (member.y || 0) + deltaY, }); }); + } else if (obj.groupId && (updates.x !== undefined || updates.y !== undefined)) { + // اگر object در یک گروه است و position تغییر کرد، فقط object را به‌روزرسانی کن + // bounds group فقط موقع transform به‌روزرسانی می‌شود + updateObject(id, updates); } else { updateObject(id, updates); } @@ -127,78 +166,248 @@ const EditorCanvas = () => { ungroupObjects(groupId); }; - // Attach transformer to selected object when it changes + // Attach transformer to selected objects when they change 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]); + if (!transformerRef.current || selectedObjectIds.length === 0 || !layerRef.current) { + if (transformerRef.current) { + transformerRef.current.nodes([]); transformerRef.current.getLayer()?.batchDraw(); } return; } - // For other objects, 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) + // Use a small delay to ensure the nodes are mounted (especially for images that need to load) const timeoutId = setTimeout(() => { if (!layerRef.current || !transformerRef.current) return; - // اگر object با mask گروه شده، transformer را به Group متصل کن - const maskedGroup = layerRef.current.findOne(`#masked-${selectedObjectId}`); - const node = maskedGroup || layerRef.current.findOne(`#${selectedObjectId}`); + const nodesToTransform: Konva.Node[] = []; + + selectedObjectIds.forEach((objId) => { + const selectedObject = objects.find((obj) => obj.id === objId); + if (!selectedObject) return; - if (node && transformerRef.current) { - transformerRef.current.nodes([node]); + // For all objects, find by id (group, grid, or regular) + if (selectedObject.type === "group" || selectedObject.type === "grid") { + const node = layerRef.current?.findOne(`#${objId}`); + if (node) { + nodesToTransform.push(node); + } + } else { + // اگر object با mask گروه شده، transformer را به Group متصل کن + const maskedGroup = layerRef.current?.findOne(`#masked-${objId}`); + const regularNode = layerRef.current?.findOne(`#${objId}`); + const targetNode = maskedGroup || regularNode; + if (targetNode) { + nodesToTransform.push(targetNode); + } + } + }); + + if (nodesToTransform.length > 0) { + transformerRef.current.nodes(nodesToTransform); transformerRef.current.getLayer()?.batchDraw(); } }, 100); return () => clearTimeout(timeoutId); - }, [selectedObjectId, objects, transformerRef, layerRef]); + }, [selectedObjectIds, objects, transformerRef, layerRef]); useEffect(() => { - if (!transformerRef.current || !selectedObjectId) return; - - const selectedObject = objects.find((obj) => obj.id === selectedObjectId); - if (!selectedObject) return; + if (!transformerRef.current || selectedObjectIds.length === 0) return; const transformer = transformerRef.current; - // Handle grid objects separately - if (selectedObject.type === "grid") { - const handleTransformEnd = () => { - const node = transformer.nodes()[0]; - if (!node || !selectedObject) return; + const handleTransform = () => { + const nodes = transformer.nodes(); + if (nodes.length === 0 || !layerRef.current) return; + + nodes.forEach((node) => { + const nodeId = node.id(); + const selectedObject = objects.find((obj) => obj.id === nodeId); + if (!selectedObject || selectedObject.type !== "group") return; const scaleX = node.scaleX(); const scaleY = node.scaleY(); + const newX = node.x(); + const newY = node.y(); - node.scaleX(1); - node.scaleY(1); + const groupMembers = objects.filter((o) => o.groupId === selectedObject.id); + const oldGroupX = selectedObject.x || 0; + const oldGroupY = selectedObject.y || 0; - const newWidth = (selectedObject.width || 300) * scaleX; - const newHeight = (selectedObject.height || 200) * scaleY; + // Update Konva nodes directly for live preview + groupMembers.forEach((member) => { + const relativeX = (member.x || 0) - oldGroupX; + const relativeY = (member.y || 0) - oldGroupY; + const scaledX = relativeX * scaleX; + const scaledY = relativeY * scaleY; - applyTableResize(selectedObject.id, newWidth, newHeight); - updateObject(selectedObject.id, { - x: node.x(), - y: node.y(), + // Find the Konva node for this member + const memberNode = layerRef.current?.findOne(`#${member.id}`) || + layerRef.current?.findOne(`#masked-${member.id}`); + if (memberNode) { + memberNode.x(newX + scaledX); + memberNode.y(newY + scaledY); + memberNode.width((member.width || 100) * scaleX); + memberNode.height((member.height || 100) * scaleY); + memberNode.scaleX(1); + memberNode.scaleY(1); + } }); - }; - transformer.on("transformend", handleTransformEnd); + layerRef.current?.batchDraw(); + }); + }; - return () => { - transformer.off("transformend", handleTransformEnd); - }; - } - }, [selectedObjectId, objects, transformerRef, applyTableResize, updateObject]); + const handleTransformEnd = () => { + const nodes = transformer.nodes(); + if (nodes.length === 0) return; + + nodes.forEach((node) => { + const nodeId = node.id(); + const selectedObject = objects.find((obj) => obj.id === nodeId); + if (!selectedObject) return; + + // Handle group objects + if (selectedObject.type === "group") { + const scaleX = node.scaleX(); + const scaleY = node.scaleY(); + const rotation = node.rotation(); + const newX = node.x(); + const newY = node.y(); + + node.scaleX(1); + node.scaleY(1); + node.rotation(0); + + const groupMembers = objects.filter((o) => o.groupId === selectedObject.id); + const oldGroupX = selectedObject.x || 0; + const oldGroupY = selectedObject.y || 0; + const oldGroupWidth = selectedObject.width || 100; + const oldGroupHeight = selectedObject.height || 100; + + // محاسبه bounds جدید بر اساا member های update شده + const updatedMembers = groupMembers.map((member) => { + const relativeX = (member.x || 0) - oldGroupX; + const relativeY = (member.y || 0) - oldGroupY; + + // Apply rotation + const angle = (rotation * Math.PI) / 180; + const rotatedX = relativeX * Math.cos(angle) - relativeY * Math.sin(angle); + const rotatedY = relativeX * Math.sin(angle) + relativeY * Math.cos(angle); + + // Apply scale + const scaledX = rotatedX * scaleX; + const scaledY = rotatedY * scaleY; + + return { + id: member.id, + x: newX + scaledX, + y: newY + scaledY, + rotation: (member.rotation || 0) + rotation, + width: (member.width || 100) * scaleX, + height: (member.height || 100) * scaleY, + }; + }); + + // Update all members + updatedMembers.forEach((member) => { + updateObject(member.id, { + x: member.x, + y: member.y, + rotation: member.rotation, + width: member.width, + height: member.height, + }); + }); + + // محاسبه bounds گروه بر اساس member های update شده با در نظر گرفتن rotation + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + updatedMembers.forEach((m) => { + const cx = (m.x || 0) + (m.width || 0) / 2; + const cy = (m.y || 0) + (m.height || 0) / 2; + const w = m.width || 0; + const h = m.height || 0; + const rot = ((m.rotation || 0) * Math.PI) / 180; + + // محاسبه چهار گوشه rectangle با rotation + const corners = [ + { x: -w / 2, y: -h / 2 }, + { x: w / 2, y: -h / 2 }, + { x: w / 2, y: h / 2 }, + { x: -w / 2, y: h / 2 }, + ]; + + corners.forEach((corner) => { + const rotatedX = corner.x * Math.cos(rot) - corner.y * Math.sin(rot); + const rotatedY = corner.x * Math.sin(rot) + corner.y * Math.cos(rot); + const finalX = cx + rotatedX; + const finalY = cy + rotatedY; + minX = Math.min(minX, finalX); + minY = Math.min(minY, finalY); + maxX = Math.max(maxX, finalX); + maxY = Math.max(maxY, finalY); + }); + }); + + const groupWidth = maxX - minX; + const groupHeight = maxY - minY; + + // Update group object with new bounds (rotation should remain 0) + updateObject(selectedObject.id, { + x: minX, + y: minY, + width: groupWidth, + height: groupHeight, + rotation: 0, + }); + } else if (selectedObject.type === "grid") { + // Handle grid objects separately + const scaleX = node.scaleX(); + const scaleY = node.scaleY(); + + node.scaleX(1); + node.scaleY(1); + + const newWidth = (selectedObject.width || 300) * scaleX; + const newHeight = (selectedObject.height || 200) * scaleY; + + applyTableResize(selectedObject.id, newWidth, newHeight); + updateObject(selectedObject.id, { + x: node.x(), + y: node.y(), + }); + } else { + // Handle other objects + const scaleX = node.scaleX(); + const scaleY = node.scaleY(); + + node.scaleX(1); + node.scaleY(1); + + updateObject(selectedObject.id, { + x: node.x(), + y: node.y(), + rotation: node.rotation(), + width: Math.max(5, (selectedObject.width || 100) * scaleX), + height: Math.max(5, (selectedObject.height || 100) * scaleY), + }); + } + }); + }; + + transformer.on("transform", handleTransform); + transformer.on("transformend", handleTransformEnd); + + return () => { + transformer.off("transform", handleTransform); + transformer.off("transformend", handleTransformEnd); + }; + }, [selectedObjectIds, objects, transformerRef, applyTableResize, updateObject]); const finalScale = stageSize.scale * zoom; @@ -227,26 +436,76 @@ const EditorCanvas = () => { - {objects.map((obj) => ( - - ))} + {/* Render group objects first */} + {objects + .filter((obj) => obj.type === "group") + .map((obj) => ( + + ))} + {/* Render non-grouped objects */} + {objects + .filter((obj) => !obj.groupId && obj.type !== "group") + .map((obj) => ( + + ))} + {/* Render objects inside groups - they should not be interactive */} + {objects + .filter((obj) => obj.groupId && obj.type !== "group") + .map((obj) => { + const groupObject = objects.find((o) => o.id === obj.groupId); + if (!groupObject) return null; + + // Render with absolute position but wrapped in non-listening group + return ( + + + + ); + })} {selectedObjectId && (() => { const selectedObject = objects.find(obj => obj.id === selectedObjectId); const isText = selectedObject?.type === "text"; + const isGroup = selectedObject?.type === "group"; // بررسی امکان group/ungroup با mask const hasMask = selectedObject?.maskId; @@ -272,7 +531,7 @@ const EditorCanvas = () => { borderStroke="#3b82f6" borderStrokeWidth={2} anchorSize={8} - rotateEnabled={true} + rotateEnabled={!isGroup} enabledAnchors={[ "top-left", "top-right", diff --git a/src/pages/editor/components/canvas/ObjectRenderer.tsx b/src/pages/editor/components/canvas/ObjectRenderer.tsx index 110925f..7004701 100644 --- a/src/pages/editor/components/canvas/ObjectRenderer.tsx +++ b/src/pages/editor/components/canvas/ObjectRenderer.tsx @@ -21,7 +21,7 @@ type ObjectRendererProps = { isSelected: boolean; transformerRef: React.RefObject; layerRef: React.RefObject; - onSelect: (id: string, node: Konva.Node) => void; + onSelect: (id: string, node: Konva.Node, e?: Konva.KonvaEventObject) => void; onUpdate: (id: string, updates: Partial) => void; draggable: boolean; onCellClick?: (tableId: string, cellId: string) => void; @@ -51,17 +51,18 @@ const ObjectRenderer = ({ // Apply caching for masked objects - REQUIRED for destination-in to work useEffect(() => { - if (!groupRef.current || !shouldApplyMask) return; + const groupNode = groupRef.current; + if (!groupNode || !shouldApplyMask) return; // Use double requestAnimationFrame to ensure all changes (including stroke) are rendered before caching let rafId1: number; const rafId2 = requestAnimationFrame(() => { rafId1 = requestAnimationFrame(() => { if (!groupRef.current) return; - + // Clear cache first groupRef.current.clearCache(); - + // Apply cache - Konva will automatically calculate bounds groupRef.current.cache(); groupRef.current.getLayer()?.batchDraw(); @@ -71,7 +72,7 @@ const ObjectRenderer = ({ return () => { cancelAnimationFrame(rafId2); if (rafId1) cancelAnimationFrame(rafId1); - groupRef.current?.clearCache(); + groupNode?.clearCache(); }; }, [shouldApplyMask, isSelected, maskShape?.x, maskShape?.y, maskShape?.width, maskShape?.height, maskShape?.rotation, obj.x, obj.y, obj.width, obj.height, obj.rotation, obj.maskInvert]); @@ -97,7 +98,7 @@ const ObjectRenderer = ({ } // برای objectهای با mask، یک obj جدید با موقعیت (0, 0) ایجاد کن - const shapeObj = shouldApplyMask + const shapeObj = shouldApplyMask ? { ...obj, x: 0, y: 0 } : obj; @@ -222,8 +223,8 @@ const ObjectRenderer = ({ key={obj.id} obj={obj} selectedCellId={selectedCellId || null} - onCellClick={(tableId, cellId, node) => { - onSelect(tableId, node); + onCellClick={(tableId, cellId, node, e) => { + onSelect(tableId, node, e); onCellClick?.(tableId, cellId); }} onCellDblClick={(tableId, cellId, x, y, width, height, text) => { @@ -236,6 +237,52 @@ const ObjectRenderer = ({ /> ); break; + case "group": + // Render group as a simple selection box + // Objects inside group are rendered separately in EditorCanvas + shapeElement = ( + { + const groupNode = e.currentTarget; + if (groupNode) { + onSelect(obj.id, groupNode, e); + } + }} + onDragMove={(e) => { + const node = e.target; + onUpdate(obj.id, { + x: node.x(), + y: node.y(), + }); + }} + onDragEnd={(e) => { + const node = e.target; + onUpdate(obj.id, { + x: node.x(), + y: node.y(), + }); + }} + > + + + ); + break; default: return null; } @@ -250,7 +297,7 @@ const ObjectRenderer = ({ // The mask layer has listening={false} to not interfere with selection // Default: destination-out (overlap مخفی می‌شود) const compositeOp = obj.maskInvert === false ? "destination-in" : "destination-out"; - + const handleGroupDragEnd = (e: Konva.KonvaEventObject) => { const node = e.target; onUpdate(obj.id, { @@ -323,18 +370,18 @@ const ObjectRenderer = ({ }); } }; - + return ( - { + onClick={(e) => { if (groupRef.current) { - onSelect(obj.id, groupRef.current); + onSelect(obj.id, groupRef.current, e); } }} onDragEnd={handleGroupDragEnd} diff --git a/src/pages/editor/components/canvas/useDrawingHandlers.ts b/src/pages/editor/components/canvas/useDrawingHandlers.ts index 25e8186..8f2d454 100644 --- a/src/pages/editor/components/canvas/useDrawingHandlers.ts +++ b/src/pages/editor/components/canvas/useDrawingHandlers.ts @@ -10,7 +10,7 @@ export const useDrawingHandlers = () => { const [tempObject, setTempObject] = useState(null); const transformerRef = useRef(null); - const { tool, addObject, updateObject, setSelectedObjectId, selectedObjectId, setTool, addTable, setSelectedTableId } = useEditorStore(); + const { tool, addObject, updateObject, setSelectedObjectId, setSelectedObjectIds, selectedObjectId, selectedObjectIds, setTool, addTable, setSelectedTableId } = useEditorStore(); const { defaults } = useTextDefaultsStore(); useEffect(() => { @@ -19,7 +19,7 @@ export const useDrawingHandlers = () => { }, [tool]); const clearSelection = () => { - setSelectedObjectId(null); + setSelectedObjectIds([]); if (transformerRef.current) { transformerRef.current.nodes([]); transformerRef.current.getLayer()?.batchDraw(); @@ -32,7 +32,7 @@ export const useDrawingHandlers = () => { const clickedOnEmpty = e.target === stage; - if (clickedOnEmpty && selectedObjectId) { + if (clickedOnEmpty && selectedObjectIds.length > 0) { clearSelection(); return; } @@ -44,7 +44,7 @@ export const useDrawingHandlers = () => { return; } - if (selectedObjectId) { + if (selectedObjectIds.length > 0) { return; } diff --git a/src/pages/editor/components/canvas/useKeyboardMovement.ts b/src/pages/editor/components/canvas/useKeyboardMovement.ts index 061851d..3424aee 100644 --- a/src/pages/editor/components/canvas/useKeyboardMovement.ts +++ b/src/pages/editor/components/canvas/useKeyboardMovement.ts @@ -4,11 +4,14 @@ import { useEditorStore } from "../../store/editorStore"; export const useKeyboardMovement = () => { const { selectedObjectId, + selectedObjectIds, objects, updateObject, deleteObject, tool, layerRef, + groupObjects, + ungroupObjects, } = useEditorStore(); useEffect(() => { @@ -28,58 +31,84 @@ export const useKeyboardMovement = () => { // Handle Delete and Backspace keys if (e.key === "Delete" || e.key === "Backspace") { - if (selectedObjectId) { + if (selectedObjectIds.length > 0) { e.preventDefault(); - deleteObject(selectedObjectId); + selectedObjectIds.forEach(id => deleteObject(id)); return; } } - if (!selectedObjectId) { + // Handle Command/Ctrl + G for grouping/ungrouping + if ((e.metaKey || e.ctrlKey) && e.key === "g") { + e.preventDefault(); + if (selectedObjectIds.length === 1) { + const selectedObject = objects.find((obj) => obj.id === selectedObjectIds[0]); + // اگر group انتخاب شده، ungroup کن + if (selectedObject?.type === "group") { + ungroupObjects(selectedObject.id); + return; + } + } + // اگر چند object انتخاب شده، group کن + if (selectedObjectIds.length >= 2) { + groupObjects(selectedObjectIds); + return; + } + } + + if (selectedObjectIds.length === 0) { return; } - const selectedObject = objects.find((obj) => obj.id === selectedObjectId); - if (!selectedObject) return; - const step = e.shiftKey ? 10 : 1; - let newX = selectedObject.x || 0; - let newY = selectedObject.y || 0; + let deltaX = 0; + let deltaY = 0; switch (e.key) { case "ArrowUp": e.preventDefault(); - newY -= step; + deltaY = -step; break; case "ArrowDown": e.preventDefault(); - newY += step; + deltaY = step; break; case "ArrowLeft": e.preventDefault(); - newX -= step; + deltaX = -step; break; case "ArrowRight": e.preventDefault(); - newX += step; + deltaX = step; break; default: return; } - updateObject(selectedObjectId, { - x: newX, - y: newY, - }); + // Move all selected objects + selectedObjectIds.forEach((id) => { + const selectedObject = objects.find((obj) => obj.id === id); + if (!selectedObject) return; - if (layerRef?.current) { - const node = layerRef.current.findOne(`#${selectedObjectId}`); - if (node) { - node.x(newX); - node.y(newY); - layerRef.current.batchDraw(); + const newX = (selectedObject.x || 0) + deltaX; + const newY = (selectedObject.y || 0) + deltaY; + + updateObject(id, { + x: newX, + y: newY, + }); + + if (layerRef?.current) { + const maskedGroup = layerRef.current.findOne(`#masked-${id}`); + const regularNode = layerRef.current.findOne(`#${id}`); + const node = maskedGroup || regularNode; + if (node) { + node.x(newX); + node.y(newY); + layerRef.current.batchDraw(); + } } - } + }); }; window.addEventListener("keydown", handleKeyDown); @@ -87,5 +116,5 @@ export const useKeyboardMovement = () => { return () => { window.removeEventListener("keydown", handleKeyDown); }; - }, [selectedObjectId, objects, updateObject, deleteObject, tool, layerRef]); + }, [selectedObjectIds, objects, updateObject, deleteObject, tool, layerRef, groupObjects, ungroupObjects]); }; diff --git a/src/pages/editor/components/tools/AbstractShape.tsx b/src/pages/editor/components/tools/AbstractShape.tsx index 8c2a48c..8a8c604 100644 --- a/src/pages/editor/components/tools/AbstractShape.tsx +++ b/src/pages/editor/components/tools/AbstractShape.tsx @@ -6,12 +6,7 @@ import type { ShapeProps } from "./types"; const AbstractShape = ({ obj, isSelected, transformerRef, onSelect, onUpdate, draggable }: ShapeProps) => { const shapeRef = useRef(null); - useEffect(() => { - if (isSelected && shapeRef.current && transformerRef.current) { - transformerRef.current.nodes([shapeRef.current]); - transformerRef.current.getLayer()?.batchDraw(); - } - }, [isSelected, transformerRef]); + // Transformer is managed in EditorCanvas for multi-select support const isMask = obj.isMask || false; const showGuide = obj.showMaskGuide !== false; @@ -44,9 +39,9 @@ const AbstractShape = ({ obj, isSelected, transformerRef, onSelect, onUpdate, dr dash={isMask && showGuide ? [10, 5] : undefined} rotation={obj.rotation || 0} draggable={draggable} - onClick={() => { + onClick={(e) => { if (shapeRef.current) { - onSelect(obj.id, shapeRef.current); + onSelect(obj.id, shapeRef.current, e); } }} onDragMove={(e) => { diff --git a/src/pages/editor/components/tools/ArrowShape.tsx b/src/pages/editor/components/tools/ArrowShape.tsx index 6362e9b..7add286 100644 --- a/src/pages/editor/components/tools/ArrowShape.tsx +++ b/src/pages/editor/components/tools/ArrowShape.tsx @@ -6,12 +6,7 @@ import type { ShapeProps } from "./types"; const ArrowShape = ({ obj, isSelected, transformerRef, onSelect, onUpdate, draggable }: ShapeProps) => { const shapeRef = useRef(null); - useEffect(() => { - if (isSelected && shapeRef.current && transformerRef.current) { - transformerRef.current.nodes([shapeRef.current]); - transformerRef.current.getLayer()?.batchDraw(); - } - }, [isSelected, transformerRef]); + // Transformer is managed in EditorCanvas for multi-select support const startX = obj.x; const startY = obj.y; @@ -32,9 +27,9 @@ const ArrowShape = ({ obj, isSelected, transformerRef, onSelect, onUpdate, dragg perfectDrawEnabled={false} rotation={obj.rotation || 0} draggable={draggable} - onClick={() => { + onClick={(e) => { if (shapeRef.current) { - onSelect(obj.id, shapeRef.current); + onSelect(obj.id, shapeRef.current, e); } }} onDragEnd={(e) => { diff --git a/src/pages/editor/components/tools/CircleShape.tsx b/src/pages/editor/components/tools/CircleShape.tsx index 9a46e59..b296e28 100644 --- a/src/pages/editor/components/tools/CircleShape.tsx +++ b/src/pages/editor/components/tools/CircleShape.tsx @@ -6,12 +6,7 @@ import type { ShapeProps } from "./types"; const CircleShape = ({ obj, isSelected, transformerRef, onSelect, onUpdate, draggable }: ShapeProps) => { const shapeRef = useRef(null); - useEffect(() => { - if (isSelected && shapeRef.current && transformerRef.current) { - transformerRef.current.nodes([shapeRef.current]); - transformerRef.current.getLayer()?.batchDraw(); - } - }, [isSelected, transformerRef]); + // Transformer is managed in EditorCanvas for multi-select support const isMask = obj.isMask || false; const showGuide = obj.showMaskGuide !== false; @@ -38,9 +33,9 @@ const CircleShape = ({ obj, isSelected, transformerRef, onSelect, onUpdate, drag dash={isMask && showGuide ? [10, 5] : undefined} rotation={obj.rotation || 0} draggable={draggable} - onClick={() => { + onClick={(e) => { if (shapeRef.current) { - onSelect(obj.id, shapeRef.current); + onSelect(obj.id, shapeRef.current, e); } }} onDragMove={(e) => { diff --git a/src/pages/editor/components/tools/ImageObject.tsx b/src/pages/editor/components/tools/ImageObject.tsx index a84780d..5f2e35a 100644 --- a/src/pages/editor/components/tools/ImageObject.tsx +++ b/src/pages/editor/components/tools/ImageObject.tsx @@ -15,16 +15,7 @@ const ImageObject = ({ const [image] = useImage(obj.imageUrl || ""); const shapeRef = useRef(null); - useEffect(() => { - // اگر draggable=false باشد، یعنی mask اعمال شده و transformer باید به Group متصل شود (نه Image) - // پس این useEffect را اجرا نکن - if (!draggable) return; - - if (isSelected && shapeRef.current && transformerRef.current && image) { - transformerRef.current.nodes([shapeRef.current]); - transformerRef.current.getLayer()?.batchDraw(); - } - }, [isSelected, transformerRef, image, draggable]); + // Transformer is managed in EditorCanvas for multi-select support if (!image) return null; @@ -42,10 +33,10 @@ const ImageObject = ({ strokeWidth={isSelected && draggable ? 3 : 0} rotation={obj.rotation || 0} draggable={draggable} - onClick={draggable ? () => { + onClick={draggable ? (e) => { // فقط وقتی mask اعمال نشده onClick handler را فعال کن if (shapeRef.current) { - onSelect(obj.id, shapeRef.current); + onSelect(obj.id, shapeRef.current, e); } } : undefined} onDragEnd={(e) => { diff --git a/src/pages/editor/components/tools/LineShape.tsx b/src/pages/editor/components/tools/LineShape.tsx index edb5caf..c1d0d78 100644 --- a/src/pages/editor/components/tools/LineShape.tsx +++ b/src/pages/editor/components/tools/LineShape.tsx @@ -6,12 +6,7 @@ import type { ShapeProps } from "./types"; const LineShape = ({ obj, isSelected, transformerRef, onSelect, onUpdate, draggable }: ShapeProps) => { const shapeRef = useRef(null); - useEffect(() => { - if (isSelected && shapeRef.current && transformerRef.current) { - transformerRef.current.nodes([shapeRef.current]); - transformerRef.current.getLayer()?.batchDraw(); - } - }, [isSelected, transformerRef]); + // Transformer is managed in EditorCanvas for multi-select support const startX = obj.x; const startY = obj.y; @@ -30,9 +25,9 @@ const LineShape = ({ obj, isSelected, transformerRef, onSelect, onUpdate, dragga perfectDrawEnabled={false} rotation={obj.rotation || 0} draggable={draggable} - onClick={() => { + onClick={(e) => { if (shapeRef.current) { - onSelect(obj.id, shapeRef.current); + onSelect(obj.id, shapeRef.current, e); } }} onDragEnd={(e) => { diff --git a/src/pages/editor/components/tools/LinkShape.tsx b/src/pages/editor/components/tools/LinkShape.tsx index 2bea9fe..13ad1a4 100644 --- a/src/pages/editor/components/tools/LinkShape.tsx +++ b/src/pages/editor/components/tools/LinkShape.tsx @@ -23,12 +23,7 @@ const LinkShape = ({ const shapeRef = useRef(null); const fontWeight = useMemo(() => getFontWeight(obj.fontWeight), [obj.fontWeight]); - useEffect(() => { - if (isSelected && shapeRef.current && transformerRef.current) { - transformerRef.current.nodes([shapeRef.current]); - transformerRef.current.getLayer()?.batchDraw(); - } - }, [isSelected, transformerRef]); + // Transformer is managed in EditorCanvas for multi-select support return ( { + onClick={(e) => { // در حالت editor، لینک‌ها قابل کلیک نیستند // این قابلیت در viewer اضافه خواهد شد if (shapeRef.current) { - onSelect(obj.id, shapeRef.current); + onSelect(obj.id, shapeRef.current, e); } }} onDragEnd={(e) => { diff --git a/src/pages/editor/components/tools/RectangleShape.tsx b/src/pages/editor/components/tools/RectangleShape.tsx index d56b769..d074ea4 100644 --- a/src/pages/editor/components/tools/RectangleShape.tsx +++ b/src/pages/editor/components/tools/RectangleShape.tsx @@ -6,12 +6,7 @@ import type { ShapeProps } from "./types"; const RectangleShape = ({ obj, isSelected, transformerRef, onSelect, onUpdate, draggable }: ShapeProps) => { const shapeRef = useRef(null); - useEffect(() => { - if (isSelected && shapeRef.current && transformerRef.current) { - transformerRef.current.nodes([shapeRef.current]); - transformerRef.current.getLayer()?.batchDraw(); - } - }, [isSelected, transformerRef]); + // Transformer is managed in EditorCanvas for multi-select support const isMask = obj.isMask || false; const showGuide = obj.showMaskGuide !== false; @@ -39,9 +34,9 @@ const RectangleShape = ({ obj, isSelected, transformerRef, onSelect, onUpdate, d dash={isMask && showGuide ? [10, 5] : undefined} rotation={obj.rotation || 0} draggable={draggable} - onClick={() => { + onClick={(e) => { if (shapeRef.current) { - onSelect(obj.id, shapeRef.current); + onSelect(obj.id, shapeRef.current, e); } }} onDragMove={(e) => { diff --git a/src/pages/editor/components/tools/TextShape.tsx b/src/pages/editor/components/tools/TextShape.tsx index 5127199..9361095 100644 --- a/src/pages/editor/components/tools/TextShape.tsx +++ b/src/pages/editor/components/tools/TextShape.tsx @@ -44,25 +44,14 @@ const TextShape = ({ }: TextShapeProps) => { const shapeRef = useRef(null); + // Transformer is managed in EditorCanvas for multi-select support + // Set offset for text node useEffect(() => { - if (isSelected && shapeRef.current && transformerRef.current) { - const textNode = shapeRef.current; - - // تنظیم offset برای حذف فضای خالی - textNode.offsetX(0); - textNode.offsetY(0); - - transformerRef.current.nodes([textNode]); - - // به‌روزرسانی transformer بعد از یک تیک برای محاسبه دقیق bounds - setTimeout(() => { - if (transformerRef.current && shapeRef.current) { - transformerRef.current.forceUpdate(); - transformerRef.current.getLayer()?.batchDraw(); - } - }, 0); + if (shapeRef.current) { + shapeRef.current.offsetX(0); + shapeRef.current.offsetY(0); } - }, [isSelected, transformerRef, obj.text, obj.width, obj.height, obj.fontSize, obj.fontFamily, obj.fontWeight]); + }, []); const fontFamily = useMemo(() => getFontFamily(obj.fontFamily), [obj.fontFamily]); const fontWeight = useMemo(() => getFontWeight(obj.fontWeight), [obj.fontWeight]); @@ -84,9 +73,9 @@ const TextShape = ({ align="right" rotation={obj.rotation || 0} draggable={draggable} - onClick={() => { + onClick={(e) => { if (shapeRef.current) { - onSelect(obj.id, shapeRef.current); + onSelect(obj.id, shapeRef.current, e); } }} onDragEnd={(e) => { diff --git a/src/pages/editor/components/tools/TriangleShape.tsx b/src/pages/editor/components/tools/TriangleShape.tsx index 084cd72..327346a 100644 --- a/src/pages/editor/components/tools/TriangleShape.tsx +++ b/src/pages/editor/components/tools/TriangleShape.tsx @@ -6,12 +6,7 @@ import type { ShapeProps } from "./types"; const TriangleShape = ({ obj, isSelected, transformerRef, onSelect, onUpdate, draggable }: ShapeProps) => { const shapeRef = useRef(null); - useEffect(() => { - if (isSelected && shapeRef.current && transformerRef.current) { - transformerRef.current.nodes([shapeRef.current]); - transformerRef.current.getLayer()?.batchDraw(); - } - }, [isSelected, transformerRef]); + // Transformer is managed in EditorCanvas for multi-select support const isMask = obj.isMask || false; const showGuide = obj.showMaskGuide !== false; @@ -40,9 +35,9 @@ const TriangleShape = ({ obj, isSelected, transformerRef, onSelect, onUpdate, dr dash={isMask && showGuide ? [10, 5] : undefined} rotation={obj.rotation || 0} draggable={draggable} - onClick={() => { + onClick={(e) => { if (shapeRef.current) { - onSelect(obj.id, shapeRef.current); + onSelect(obj.id, shapeRef.current, e); } }} onDragMove={(e) => { diff --git a/src/pages/editor/components/tools/VideoShape.tsx b/src/pages/editor/components/tools/VideoShape.tsx index 6157a76..359efc2 100644 --- a/src/pages/editor/components/tools/VideoShape.tsx +++ b/src/pages/editor/components/tools/VideoShape.tsx @@ -54,12 +54,7 @@ const VideoShape = ({ }; }, [obj.videoUrl]); - useEffect(() => { - if (isSelected && groupRef.current && transformerRef.current && image) { - transformerRef.current.nodes([groupRef.current]); - transformerRef.current.getLayer()?.batchDraw(); - } - }, [isSelected, transformerRef, image]); + // Transformer is managed in EditorCanvas for multi-select support useEffect(() => { if (!showVideoModal) return; @@ -101,7 +96,7 @@ const VideoShape = ({ } } if (groupRef.current) { - onSelect(obj.id, groupRef.current); + onSelect(obj.id, groupRef.current, e); } }; diff --git a/src/pages/editor/components/tools/types.ts b/src/pages/editor/components/tools/types.ts index 05d1379..b24496a 100644 --- a/src/pages/editor/components/tools/types.ts +++ b/src/pages/editor/components/tools/types.ts @@ -5,7 +5,7 @@ export type ShapeProps = { obj: EditorObject; isSelected: boolean; transformerRef: React.RefObject; - onSelect: (id: string, node: Konva.Node) => void; + onSelect: (id: string, node: Konva.Node, e?: Konva.KonvaEventObject) => void; onUpdate: (id: string, updates: Partial) => void; draggable: boolean; }; diff --git a/src/pages/editor/store/editorStore.ts b/src/pages/editor/store/editorStore.ts index dd84c69..1c41b4d 100644 --- a/src/pages/editor/store/editorStore.ts +++ b/src/pages/editor/store/editorStore.ts @@ -13,7 +13,8 @@ export type ToolType = | "document" | "video" | "link" - | "grid"; + | "grid" + | "group"; export type TableCell = { id: string; @@ -83,6 +84,10 @@ type EditorStoreType = { duplicateObject: (id: string) => void; selectedObjectId: string | null; setSelectedObjectId: (id: string | null) => void; + selectedObjectIds: string[]; + setSelectedObjectIds: (ids: string[]) => void; + addToSelection: (id: string) => void; + removeFromSelection: (id: string) => void; selectedTableId: string | null; setSelectedTableId: (id: string | null) => void; selectedCellId: string | null; @@ -301,7 +306,37 @@ export const useEditorStore = create((set, get) => { })); }, selectedObjectId: null, - setSelectedObjectId: (id) => set({ selectedObjectId: id }), + setSelectedObjectId: (id) => { + set({ + selectedObjectId: id, + selectedObjectIds: id ? [id] : [] + }); + }, + selectedObjectIds: [], + setSelectedObjectIds: (ids) => { + set({ + selectedObjectIds: ids, + selectedObjectId: ids.length > 0 ? ids[0] : null + }); + }, + addToSelection: (id) => { + const state = get(); + if (!state.selectedObjectIds.includes(id)) { + const newIds = [...state.selectedObjectIds, id]; + set({ + selectedObjectIds: newIds, + selectedObjectId: newIds[0] + }); + } + }, + removeFromSelection: (id) => { + const state = get(); + const newIds = state.selectedObjectIds.filter(selectedId => selectedId !== id); + set({ + selectedObjectIds: newIds, + selectedObjectId: newIds.length > 0 ? newIds[0] : null + }); + }, selectedTableId: null, setSelectedTableId: (id) => set({ selectedTableId: id }), selectedCellId: null, @@ -650,6 +685,57 @@ export const useEditorStore = create((set, get) => { if (objectIds.length < 2) return; const groupId = `group-${crypto.randomUUID()}`; + const objectsToGroup = state.objects.filter((obj) => objectIds.includes(obj.id)); + + if (objectsToGroup.length === 0) return; + + // محاسبه bounds گروه با در نظر گرفتن rotation + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + objectsToGroup.forEach((obj) => { + const cx = (obj.x || 0) + (obj.width || 0) / 2; + const cy = (obj.y || 0) + (obj.height || 0) / 2; + const w = obj.width || 0; + const h = obj.height || 0; + const rot = ((obj.rotation || 0) * Math.PI) / 180; + + // محاسبه چهار گوشه rectangle با rotation + const corners = [ + { x: -w / 2, y: -h / 2 }, + { x: w / 2, y: -h / 2 }, + { x: w / 2, y: h / 2 }, + { x: -w / 2, y: h / 2 }, + ]; + + corners.forEach((corner) => { + const rotatedX = corner.x * Math.cos(rot) - corner.y * Math.sin(rot); + const rotatedY = corner.x * Math.sin(rot) + corner.y * Math.cos(rot); + const finalX = cx + rotatedX; + const finalY = cy + rotatedY; + minX = Math.min(minX, finalX); + minY = Math.min(minY, finalY); + maxX = Math.max(maxX, finalX); + maxY = Math.max(maxY, finalY); + }); + }); + + const groupWidth = maxX - minX; + const groupHeight = maxY - minY; + + // ایجاد group object + const groupObject: EditorObject = { + id: groupId, + type: "group", + x: minX, + y: minY, + width: groupWidth, + height: groupHeight, + }; + + // اضافه کردن groupId به objectهای داخل گروه const updateObjects = (objects: EditorObject[]) => objects.map((obj) => objectIds.includes(obj.id) ? { ...obj, groupId } : obj @@ -657,25 +743,32 @@ export const useEditorStore = create((set, get) => { if (state.currentPageId) { set((state) => ({ - objects: updateObjects(state.objects), + objects: [...updateObjects(state.objects), groupObject], pages: state.pages.map((p) => p.id === state.currentPageId - ? { ...p, objects: updateObjects(p.objects) } + ? { ...p, objects: [...updateObjects(p.objects), groupObject] } : p ), + selectedObjectId: groupId, + selectedObjectIds: [groupId], })); return; } set((state) => ({ - objects: updateObjects(state.objects), + objects: [...updateObjects(state.objects), groupObject], + selectedObjectId: groupId, + selectedObjectIds: [groupId], })); }, ungroupObjects: (groupId) => { const state = get(); + // حذف groupId از objectهای داخل گروه و حذف group object const updateObjects = (objects: EditorObject[]) => - objects.map((obj) => - obj.groupId === groupId ? { ...obj, groupId: undefined } : obj - ); + objects + .map((obj) => + obj.groupId === groupId ? { ...obj, groupId: undefined } : obj + ) + .filter((obj) => obj.id !== groupId); // حذف group object if (state.currentPageId) { set((state) => ({ @@ -685,12 +778,20 @@ export const useEditorStore = create((set, get) => { ? { ...p, objects: updateObjects(p.objects) } : p ), + selectedObjectId: null, + selectedObjectIds: [], })); return; } set((state) => ({ objects: updateObjects(state.objects), + selectedObjectId: null, + selectedObjectIds: [], })); }, + getGroupObjects: (groupId) => { + const state = get(); + return state.objects.filter((obj) => obj.groupId === groupId); + }, }; });