diff --git a/src/pages/editor/components/EditorCanvas.tsx b/src/pages/editor/components/EditorCanvas.tsx index 208f580..093e16d 100644 --- a/src/pages/editor/components/EditorCanvas.tsx +++ b/src/pages/editor/components/EditorCanvas.tsx @@ -1,11 +1,15 @@ import { useEffect, useRef } from "react"; -import { Stage, Layer, Transformer, Rect, Group, Text } from "react-konva"; +import { Stage, Layer, Rect } from "react-konva"; import Konva from "konva"; -import { useEditorStore, type EditorObject } from "../store/editorStore"; +import { useEditorStore } from "../store/editorStore"; import { useDrawingHandlers } from "./canvas/useDrawingHandlers"; import { useStageSize } from "./canvas/useStageSize"; import { useKeyboardMovement } from "./canvas/useKeyboardMovement"; -import ObjectRenderer from "./canvas/ObjectRenderer"; +import { useSelectionHandlers } from "./canvas/useSelectionHandlers"; +import { useTransformHandlers } from "./canvas/useTransformHandlers"; +import { useObjectHandlers } from "./canvas/useObjectHandlers"; +import { useMaskGroupState } from "./canvas/useMaskGroupState"; +import ObjectsLayer from "./canvas/ObjectsLayer"; import CellEditor from "@/components/CellEditor"; import ZoomControls from "./ZoomControls"; @@ -16,30 +20,27 @@ const EditorCanvas = () => { const { tool, objects, - updateObject, selectedObjectId, selectedObjectIds, - setSelectedObjectId, - addToSelection, - removeFromSelection, - setSelectedTableId, selectedCellId, - setSelectedCellId, editingCell, - setEditingCell, - updateCellValue, - applyTableResize, setStageRef, setLayerRef, zoom, - groupObjects, - ungroupObjects, } = useEditorStore(); const { transformerRef, handleStageMouseDown, handleStageMouseMove, handleStageMouseUp } = useDrawingHandlers(); const stageSize = useStageSize(); useKeyboardMovement(); + const { handleSelect, handleCellClick, handleCellDblClick } = useSelectionHandlers(); + const { handleObjectUpdate, handleGroupWithMask, handleUngroupFromMask, handleCellEditorSave, handleCellEditorCancel } = + useObjectHandlers(); + + const { isGroupedWithMask, maskObject, canGroup, canUngroup, selectedObject } = useMaskGroupState(selectedObjectId); + + useTransformHandlers(transformerRef, layerRef); + useEffect(() => { if (stageRef.current) { setStageRef(stageRef); @@ -49,561 +50,6 @@ const EditorCanvas = () => { } }, [setStageRef, setLayerRef]); - 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); - } - - if (obj?.type === "grid") { - setSelectedTableId(id); - } else if (!isMultiSelect) { - setSelectedTableId(null); - } - - if (!isMultiSelect) { - setSelectedCellId(null); - } - }; - - const handleCellClick = (tableId: string, cellId: string, 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) => { - setEditingCell({ tableId, cellId, x, y, width, height, value: text }); - }; - - const handleCellEditorSave = (text: string) => { - if (editingCell) { - updateCellValue(editingCell.tableId, editingCell.cellId, text); - setEditingCell(null); - } - }; - - const handleCellEditorCancel = () => { - setEditingCell(null); - }; - - const handleObjectUpdate = (id: string, updates: Partial) => { - const obj = objects.find((o) => o.id === id); - if (!obj) return; - - // اگر 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; - - // به‌روزرسانی group object - updateObject(id, updates); - - // به‌روزرسانی همه اعضای گروه - groupMembers.forEach((member) => { - updateObject(member.id, { - x: (member.x || 0) + deltaX, - 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); - } - }; - - const handleGroupWithMask = (objectId: string, maskId: string) => { - groupObjects([objectId, maskId]); - }; - - const handleUngroupFromMask = (groupId: string) => { - ungroupObjects(groupId); - }; - - // Attach transformer to selected objects when they change - useEffect(() => { - if (!transformerRef.current || selectedObjectIds.length === 0 || !layerRef.current) { - if (transformerRef.current) { - transformerRef.current.nodes([]); - transformerRef.current.getLayer()?.batchDraw(); - } - return; - } - - // 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; - - const nodesToTransform: Konva.Node[] = []; - - selectedObjectIds.forEach((objId) => { - const selectedObject = objects.find((obj) => obj.id === objId); - if (!selectedObject) return; - - // 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); - }, [selectedObjectIds, objects, transformerRef, layerRef]); - - useEffect(() => { - if (!transformerRef.current || selectedObjectIds.length === 0) return; - - const transformer = transformerRef.current; - - 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 rotation = node.rotation(); - const newX = node.x(); - const newY = node.y(); - - const groupMembers = objects.filter((o) => o.groupId === selectedObject.id); - const oldGroupX = selectedObject.x || 0; - const oldGroupY = selectedObject.y || 0; - - // Update Konva nodes directly for live preview - groupMembers.forEach((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; - - const finalX = newX + scaledX; - const finalY = newY + scaledY; - - // Find the Konva node for this member - const memberNode = layerRef.current?.findOne(`#${member.id}`) || - layerRef.current?.findOne(`#masked-${member.id}`); - if (memberNode) { - // برای AbstractShape و TriangleShape که از مرکز استفاده می‌کنند، باید موقعیت را تنظیم کنیم - if (member.type === "rectangle" && (member.shapeType === "triangle" || member.shapeType === "abstract")) { - // موقعیت در store گوشه بالا-چپ است، اما در render از مرکز استفاده می‌شود - const w = (member.width || 100) * scaleX; - const h = (member.height || 100) * scaleY; - memberNode.x(finalX + w / 2); - memberNode.y(finalY + h / 2); - } else { - memberNode.x(finalX); - memberNode.y(finalY); - } - - memberNode.width((member.width || 100) * scaleX); - memberNode.height((member.height || 100) * scaleY); - memberNode.rotation((member.rotation || 0) + rotation); - memberNode.scaleX(1); - memberNode.scaleY(1); - } - }); - - layerRef.current?.batchDraw(); - }); - }; - - 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; - - // محاسبه 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 originalMember = groupMembers.find((gm) => gm.id === m.id); - if (!originalMember) return; - - const w = m.width || 0; - const h = m.height || 0; - - // برای دایره، x و y مرکز دایره است و bounding box همیشه یک مربع است - if (originalMember.type === "rectangle" && originalMember.shapeType === "circle") { - const radius = w / 2; - const cx = m.x || 0; - const cy = m.y || 0; - // برای دایره، bounding box همیشه یک مربع با ضلع برابر با قطر است - minX = Math.min(minX, cx - radius); - minY = Math.min(minY, cy - radius); - maxX = Math.max(maxX, cx + radius); - maxY = Math.max(maxY, cy + radius); - return; - } - - // برای مثلث و abstract، x و y در store گوشه بالا-چپ است اما در render مرکز استفاده می‌شود - if (originalMember.type === "rectangle" && (originalMember.shapeType === "triangle" || originalMember.shapeType === "abstract")) { - // محاسبه مرکز از گوشه بالا-چپ - const cx = (m.x || 0) + w / 2; - const cy = (m.y || 0) + h / 2; - const rot = ((m.rotation || 0) * Math.PI) / 180; - - // برای abstract، از radius واقعی استفاده کن (0.85 * baseRadius) - // برای triangle، از radius واقعی استفاده کن (min(width, height) / 2) - let actualRadius: number; - if (originalMember.shapeType === "abstract") { - const baseRadius = Math.min(w / 2, h / 2); - actualRadius = baseRadius * 0.85; - } else { - // triangle - actualRadius = Math.min(w / 2, h / 2); - } - - // محاسبه چهار گوشه با استفاده از radius واقعی - const corners = [ - { x: -actualRadius, y: -actualRadius }, - { x: actualRadius, y: -actualRadius }, - { x: actualRadius, y: actualRadius }, - { x: -actualRadius, y: actualRadius }, - ]; - - 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); - }); - return; - } - - // برای arrow و line، x و y نقطه شروع است و width/height نقطه پایان است - if (originalMember.type === "arrow" || originalMember.type === "line") { - const startX = m.x || 0; - const startY = m.y || 0; - const endX = m.width || 0; - const endY = m.height || 0; - - // برای arrow و line، bounding box از نقطه شروع تا نقطه پایان است - minX = Math.min(minX, startX, endX); - minY = Math.min(minY, startY, endY); - maxX = Math.max(maxX, startX, endX); - maxY = Math.max(maxY, startY, endY); - return; - } - - // برای سایر اشکال، x و y گوشه بالا-چپ است - const cx = (m.x || 0) + w / 2; - const cy = (m.y || 0) + h / 2; - 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 node directly in Konva first to prevent transformer flicker - if (layerRef.current) { - const groupNode = layerRef.current.findOne(`#${selectedObject.id}`); - if (groupNode) { - groupNode.x(minX); - groupNode.y(minY); - groupNode.width(groupWidth); - groupNode.height(groupHeight); - groupNode.rotation(0); - } - } - - // Update group object with new bounds (rotation should remain 0) - updateObject(selectedObject.id, { - x: minX, - y: minY, - width: groupWidth, - height: groupHeight, - rotation: 0, - }); - - // Force transformer to update immediately - if (transformerRef.current && layerRef.current) { - const groupNode = layerRef.current.findOne(`#${selectedObject.id}`); - if (groupNode) { - transformerRef.current.nodes([groupNode]); - transformerRef.current.getLayer()?.batchDraw(); - } - } - } 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); - - // Handle special shapes that use radius (Circle, Triangle, Abstract) - if (selectedObject.type === "rectangle") { - if (selectedObject.shapeType === "circle") { - // Circle: maintain aspect ratio and use average scale - const avgScale = (scaleX + scaleY) / 2; - const newRadius = Math.max(5, ((selectedObject.width || 50) / 2) * avgScale); - const newSize = newRadius * 2; - updateObject(selectedObject.id, { - x: node.x(), - y: node.y(), - rotation: node.rotation(), - width: newSize, - height: newSize, - }); - } else if (selectedObject.shapeType === "triangle" || selectedObject.shapeType === "abstract") { - // Triangle and Abstract: maintain aspect ratio and use average scale - const avgScale = (scaleX + scaleY) / 2; - const baseRadius = Math.min((selectedObject.width || 100) / 2, (selectedObject.height || 100) / 2); - const newRadius = Math.max(5, baseRadius * avgScale); - const newSize = newRadius * 2; - updateObject(selectedObject.id, { - x: node.x() - newSize / 2, - y: node.y() - newSize / 2, - rotation: node.rotation(), - width: newSize, - height: newSize, - }); - } else { - // Regular rectangle - 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), - }); - } - } else if (selectedObject.type === "line" || selectedObject.type === "arrow") { - // Line and Arrow: scale the end point - const startX = selectedObject.x || 0; - const startY = selectedObject.y || 0; - const endX = selectedObject.width || 0; - const endY = selectedObject.height || 0; - const dx = (endX - startX) * scaleX; - const dy = (endY - startY) * scaleY; - updateObject(selectedObject.id, { - x: node.x(), - y: node.y(), - rotation: node.rotation(), - width: node.x() + dx, - height: node.y() + dy, - }); - } else if (selectedObject.type === "text" || selectedObject.type === "link") { - // Text and Link: use stored width/height from store, not getClientRect - // because TextShape auto-calculates dimensions and we need to scale the stored values - const storedWidth = selectedObject.width || 100; - const storedHeight = selectedObject.height || 100; - // Use average scale to maintain aspect ratio for text - const avgScale = (scaleX + scaleY) / 2; - const newWidth = Math.max(5, storedWidth * scaleX); - const newHeight = Math.max(5, storedHeight * scaleY); - - if (selectedObject.type === "link") { - // For link, also update fontSize - updateObject(selectedObject.id, { - x: node.x(), - y: node.y(), - rotation: node.rotation(), - fontSize: selectedObject.fontSize ? Math.max(5, selectedObject.fontSize * scaleX) : undefined, - }); - } else { - // For text, update width, height, and fontSize - updateObject(selectedObject.id, { - x: node.x(), - y: node.y(), - rotation: node.rotation(), - width: newWidth, - height: newHeight, - fontSize: selectedObject.fontSize ? Math.max(5, selectedObject.fontSize * avgScale) : undefined, - }); - } - } else { - // Default for other objects (image, etc.) - 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; @@ -631,211 +77,25 @@ const EditorCanvas = () => { /> - - {/* 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 - let isGroupedWithMask = false; - let hasMask = false; - let maskObject = null; - - if (isGroup) { - // اگر این یک group است، بررسی کن که آیا از یک object با mask تشکیل شده - const groupMembers = objects.filter(o => o.groupId === selectedObject.id); - const objectWithMask = groupMembers.find(m => m.maskId); - if (objectWithMask) { - maskObject = groupMembers.find(m => m.id === objectWithMask.maskId); - isGroupedWithMask = !!maskObject; - } - } else { - // اگر یک object معمولی است - hasMask = !!selectedObject?.maskId; - maskObject = hasMask ? objects.find(m => m.id === selectedObject?.maskId) : null; - isGroupedWithMask = !!(selectedObject?.groupId && maskObject?.groupId === selectedObject?.groupId); - } - - const canGroup = hasMask && !isGroupedWithMask && maskObject && !selectedObject?.isMask; - const canUngroup = isGroupedWithMask && isGroup; - - // اگر با mask گروه شده، امکان ریسایز نباشد - const enabledAnchors = isGroupedWithMask ? [] : [ - "top-left", - "top-right", - "bottom-left", - "bottom-right", - "top-center", - "bottom-center", - "middle-left", - "middle-right", - ]; - - return ( - <> - { - if (Math.abs(newBox.width) < 5 || Math.abs(newBox.height) < 5) { - return oldBox; - } - // اگر با mask گروه شده، هیچ تغییر سایزی مجاز نیست - if (isGroupedWithMask) { - return oldBox; - } - return newBox; - }} - ignoreStroke={isText} - perfectDrawEnabled={false} - anchorFill="#3b82f6" - anchorStroke="#ffffff" - borderStroke="#3b82f6" - borderStrokeWidth={2} - anchorSize={8} - rotateEnabled={!isGroup && !isGroupedWithMask} - enabledAnchors={enabledAnchors} - /> - - {/* دکمه گروه‌بندی */} - {canGroup && selectedObject && maskObject && ( - - handleGroupWithMask(selectedObject.id, maskObject.id)} - onTap={() => handleGroupWithMask(selectedObject.id, maskObject.id)} - listening={true} - /> - handleGroupWithMask(selectedObject.id, maskObject.id)} - onTap={() => handleGroupWithMask(selectedObject.id, maskObject.id)} - listening={true} - /> - - )} - - {/* دکمه لغو گروه‌بندی */} - {canUngroup && selectedObject && ( - - handleUngroupFromMask(selectedObject.id)} - onTap={() => handleUngroupFromMask(selectedObject.id)} - listening={true} - /> - handleUngroupFromMask(selectedObject.id)} - onTap={() => handleUngroupFromMask(selectedObject.id)} - listening={true} - /> - - )} - - ); - })()} - + {editingCell && ( diff --git a/src/pages/editor/components/canvas/GroupMaskButtons.tsx b/src/pages/editor/components/canvas/GroupMaskButtons.tsx new file mode 100644 index 0000000..d9b4b6f --- /dev/null +++ b/src/pages/editor/components/canvas/GroupMaskButtons.tsx @@ -0,0 +1,102 @@ +import { Group, Rect, Text } from "react-konva"; +import type { EditorObject } from "../../store/editorStore"; + +interface GroupMaskButtonsProps { + selectedObject: EditorObject; + maskObject: EditorObject | null; + canGroup: boolean; + canUngroup: boolean; + onGroup: (objectId: string, maskId: string) => void; + onUngroup: (groupId: string) => void; +} + +const GroupMaskButtons = ({ + selectedObject, + maskObject, + canGroup, + canUngroup, + onGroup, + onUngroup, +}: GroupMaskButtonsProps) => { + if (!canGroup && !canUngroup) return null; + + const buttonX = (selectedObject.x || 0) + ((selectedObject.width || 100) / 2) - 50; + const buttonY = (selectedObject.y || 0) - 40; + + return ( + <> + {/* دکمه گروه‌بندی */} + {canGroup && maskObject && ( + + onGroup(selectedObject.id, maskObject.id)} + onTap={() => onGroup(selectedObject.id, maskObject.id)} + listening={true} + /> + onGroup(selectedObject.id, maskObject.id)} + onTap={() => onGroup(selectedObject.id, maskObject.id)} + listening={true} + /> + + )} + + {/* دکمه لغو گروه‌بندی */} + {canUngroup && ( + + onUngroup(selectedObject.id)} + onTap={() => onUngroup(selectedObject.id)} + listening={true} + /> + onUngroup(selectedObject.id)} + onTap={() => onUngroup(selectedObject.id)} + listening={true} + /> + + )} + + ); +}; + +export default GroupMaskButtons; + diff --git a/src/pages/editor/components/canvas/ObjectsLayer.tsx b/src/pages/editor/components/canvas/ObjectsLayer.tsx new file mode 100644 index 0000000..e8ef668 --- /dev/null +++ b/src/pages/editor/components/canvas/ObjectsLayer.tsx @@ -0,0 +1,146 @@ +import { Layer, Group } from "react-konva"; +import Konva from "konva"; +import ObjectRenderer from "./ObjectRenderer"; +import TransformerControls from "./TransformerControls"; +import GroupMaskButtons from "./GroupMaskButtons"; +import type { EditorObject } from "../../store/editorStore"; + +interface ObjectsLayerProps { + layerRef: React.RefObject; + objects: EditorObject[]; + selectedObjectIds: string[]; + selectedCellId: string | null; + tool: string; + transformerRef: React.RefObject; + selectedObject: EditorObject | null; + isGroupedWithMask: boolean; + maskObject: EditorObject | null; + canGroup: boolean; + canUngroup: boolean; + onSelect: (id: string, node: Konva.Node, e?: Konva.KonvaEventObject) => void; + onUpdate: (id: string, updates: Partial) => void; + onCellClick: (tableId: string, cellId: string, e?: Konva.KonvaEventObject) => void; + onCellDblClick: ( + tableId: string, + cellId: string, + x: number, + y: number, + width: number, + height: number, + text: string + ) => void; + onGroup: (objectId: string, maskId: string) => void; + onUngroup: (groupId: string) => void; +} + +const ObjectsLayer = ({ + layerRef, + objects, + selectedObjectIds, + selectedCellId, + tool, + transformerRef, + selectedObject, + isGroupedWithMask, + maskObject, + canGroup, + canUngroup, + onSelect, + onUpdate, + onCellClick, + onCellDblClick, + onGroup, + onUngroup, +}: ObjectsLayerProps) => { + return ( + + {/* 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 ( + + + + ); + })} + + {/* Transformer and Group/Mask buttons must be inside the same Layer */} + {selectedObject && ( + <> + + + + )} + + ); +}; + +export default ObjectsLayer; + diff --git a/src/pages/editor/components/canvas/TransformerControls.tsx b/src/pages/editor/components/canvas/TransformerControls.tsx new file mode 100644 index 0000000..651befc --- /dev/null +++ b/src/pages/editor/components/canvas/TransformerControls.tsx @@ -0,0 +1,58 @@ +import { Transformer } from "react-konva"; +import Konva from "konva"; +import type { EditorObject } from "../../store/editorStore"; + +interface TransformerControlsProps { + transformerRef: React.RefObject; + selectedObject: EditorObject | null; + isGroupedWithMask: boolean; +} + +const TransformerControls = ({ transformerRef, selectedObject, isGroupedWithMask }: TransformerControlsProps) => { + if (!selectedObject) return null; + + const isText = selectedObject.type === "text"; + const isGroup = selectedObject.type === "group"; + + // اگر با mask گروه شده، امکان ریسایز نباشد + const enabledAnchors = isGroupedWithMask + ? [] + : [ + "top-left", + "top-right", + "bottom-left", + "bottom-right", + "top-center", + "bottom-center", + "middle-left", + "middle-right", + ]; + + return ( + { + if (Math.abs(newBox.width) < 5 || Math.abs(newBox.height) < 5) { + return oldBox; + } + // اگر با mask گروه شده، هیچ تغییر سایزی مجاز نیست + if (isGroupedWithMask) { + return oldBox; + } + return newBox; + }} + ignoreStroke={isText} + perfectDrawEnabled={false} + anchorFill="#3b82f6" + anchorStroke="#ffffff" + borderStroke="#3b82f6" + borderStrokeWidth={2} + anchorSize={8} + rotateEnabled={!isGroup && !isGroupedWithMask} + enabledAnchors={enabledAnchors} + /> + ); +}; + +export default TransformerControls; + diff --git a/src/pages/editor/components/canvas/useMaskGroupState.ts b/src/pages/editor/components/canvas/useMaskGroupState.ts new file mode 100644 index 0000000..ea49541 --- /dev/null +++ b/src/pages/editor/components/canvas/useMaskGroupState.ts @@ -0,0 +1,63 @@ +import { useEditorStore, type EditorObject } from "../../store/editorStore"; + +export const useMaskGroupState = (selectedObjectId: string | null) => { + const { objects } = useEditorStore(); + + if (!selectedObjectId) { + return { + isGroupedWithMask: false, + hasMask: false, + maskObject: null, + canGroup: false, + canUngroup: false, + selectedObject: null, + }; + } + + const selectedObject = objects.find((obj) => obj.id === selectedObjectId); + if (!selectedObject) { + return { + isGroupedWithMask: false, + hasMask: false, + maskObject: null, + canGroup: false, + canUngroup: false, + selectedObject: null, + }; + } + + const isGroup = selectedObject.type === "group"; + + // بررسی امکان group/ungroup با mask + let isGroupedWithMask = false; + let hasMask = false; + let maskObject: EditorObject | null = null; + + if (isGroup) { + // اگر این یک group است، بررسی کن که آیا از یک object با mask تشکیل شده + const groupMembers = objects.filter((o) => o.groupId === selectedObject.id); + const objectWithMask = groupMembers.find((m) => m.maskId); + if (objectWithMask) { + maskObject = groupMembers.find((m) => m.id === objectWithMask.maskId) || null; + isGroupedWithMask = !!maskObject; + } + } else { + // اگر یک object معمولی است + hasMask = !!selectedObject.maskId; + maskObject = hasMask ? objects.find((m) => m.id === selectedObject.maskId) || null : null; + isGroupedWithMask = !!(selectedObject.groupId && maskObject?.groupId === selectedObject.groupId); + } + + const canGroup = !!(hasMask && !isGroupedWithMask && maskObject && !selectedObject.isMask); + const canUngroup = Boolean(isGroupedWithMask && isGroup); + + return { + isGroupedWithMask, + hasMask, + maskObject, + canGroup, + canUngroup, + selectedObject, + }; +}; + diff --git a/src/pages/editor/components/canvas/useObjectHandlers.ts b/src/pages/editor/components/canvas/useObjectHandlers.ts new file mode 100644 index 0000000..90b42ea --- /dev/null +++ b/src/pages/editor/components/canvas/useObjectHandlers.ts @@ -0,0 +1,63 @@ +import { useEditorStore, type EditorObject } from "../../store/editorStore"; + +export const useObjectHandlers = () => { + const { objects, updateObject, groupObjects, ungroupObjects, updateCellValue, setEditingCell, editingCell } = + useEditorStore(); + + const handleObjectUpdate = (id: string, updates: Partial) => { + const obj = objects.find((o) => o.id === id); + if (!obj) return; + + // اگر 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; + + // به‌روزرسانی group object + updateObject(id, updates); + + // به‌روزرسانی همه اعضای گروه + groupMembers.forEach((member) => { + updateObject(member.id, { + x: (member.x || 0) + deltaX, + 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); + } + }; + + const handleGroupWithMask = (objectId: string, maskId: string) => { + groupObjects([objectId, maskId]); + }; + + const handleUngroupFromMask = (groupId: string) => { + ungroupObjects(groupId); + }; + + const handleCellEditorSave = (text: string) => { + if (editingCell) { + updateCellValue(editingCell.tableId, editingCell.cellId, text); + setEditingCell(null); + } + }; + + const handleCellEditorCancel = () => { + setEditingCell(null); + }; + + return { + handleObjectUpdate, + handleGroupWithMask, + handleUngroupFromMask, + handleCellEditorSave, + handleCellEditorCancel, + }; +}; + diff --git a/src/pages/editor/components/canvas/useSelectionHandlers.ts b/src/pages/editor/components/canvas/useSelectionHandlers.ts new file mode 100644 index 0000000..5e0964d --- /dev/null +++ b/src/pages/editor/components/canvas/useSelectionHandlers.ts @@ -0,0 +1,98 @@ +import Konva from "konva"; +import { useEditorStore } from "../../store/editorStore"; + +export const useSelectionHandlers = () => { + const { + objects, + selectedObjectIds, + setSelectedObjectId, + addToSelection, + removeFromSelection, + setSelectedTableId, + setSelectedCellId, + } = useEditorStore(); + + 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); + } + + if (obj?.type === "grid") { + setSelectedTableId(id); + } else if (!isMultiSelect) { + setSelectedTableId(null); + } + + if (!isMultiSelect) { + setSelectedCellId(null); + } + }; + + const handleCellClick = (tableId: string, cellId: string, 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 + ) => { + const { setEditingCell } = useEditorStore.getState(); + setEditingCell({ tableId, cellId, x, y, width, height, value: text }); + }; + + return { + handleSelect, + handleCellClick, + handleCellDblClick, + }; +}; + diff --git a/src/pages/editor/components/canvas/useTransformHandlers.ts b/src/pages/editor/components/canvas/useTransformHandlers.ts new file mode 100644 index 0000000..8548e45 --- /dev/null +++ b/src/pages/editor/components/canvas/useTransformHandlers.ts @@ -0,0 +1,512 @@ +import { useEffect, type RefObject } from "react"; +import Konva from "konva"; +import { useEditorStore } from "../../store/editorStore"; + +export const useTransformHandlers = ( + transformerRef: RefObject, + layerRef: RefObject +) => { + const { + objects, + selectedObjectIds, + updateObject, + applyTableResize, + } = useEditorStore(); + + // Attach transformer to selected objects when they change + useEffect(() => { + if ( + !transformerRef.current || + selectedObjectIds.length === 0 || + !layerRef.current + ) { + if (transformerRef.current) { + transformerRef.current.nodes([]); + transformerRef.current.getLayer()?.batchDraw(); + } + return; + } + + // 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; + + const nodesToTransform: Konva.Node[] = []; + + selectedObjectIds.forEach((objId) => { + const selectedObject = objects.find((obj) => obj.id === objId); + if (!selectedObject) return; + + // 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); + }, [selectedObjectIds, objects, transformerRef, layerRef]); + + useEffect(() => { + if (!transformerRef.current || selectedObjectIds.length === 0) return; + + const transformer = transformerRef.current; + + 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 rotation = node.rotation(); + const newX = node.x(); + const newY = node.y(); + + const groupMembers = objects.filter( + (o) => o.groupId === selectedObject.id + ); + const oldGroupX = selectedObject.x || 0; + const oldGroupY = selectedObject.y || 0; + + // Update Konva nodes directly for live preview + groupMembers.forEach((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; + + const finalX = newX + scaledX; + const finalY = newY + scaledY; + + // Find the Konva node for this member + const memberNode = + layerRef.current?.findOne(`#${member.id}`) || + layerRef.current?.findOne(`#masked-${member.id}`); + if (memberNode) { + // برای AbstractShape و TriangleShape که از مرکز استفاده می‌کنند، باید موقعیت را تنظیم کنیم + if ( + member.type === "rectangle" && + (member.shapeType === "triangle" || + member.shapeType === "abstract") + ) { + // موقعیت در store گوشه بالا-چپ است، اما در render از مرکز استفاده می‌شود + const w = (member.width || 100) * scaleX; + const h = (member.height || 100) * scaleY; + memberNode.x(finalX + w / 2); + memberNode.y(finalY + h / 2); + } else { + memberNode.x(finalX); + memberNode.y(finalY); + } + + memberNode.width((member.width || 100) * scaleX); + memberNode.height((member.height || 100) * scaleY); + memberNode.rotation((member.rotation || 0) + rotation); + memberNode.scaleX(1); + memberNode.scaleY(1); + } + }); + + layerRef.current?.batchDraw(); + }); + }; + + 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; + + // محاسبه 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 originalMember = groupMembers.find((gm) => gm.id === m.id); + if (!originalMember) return; + + const w = m.width || 0; + const h = m.height || 0; + + // برای دایره، x و y مرکز دایره است و bounding box همیشه یک مربع است + if ( + originalMember.type === "rectangle" && + originalMember.shapeType === "circle" + ) { + const radius = w / 2; + const cx = m.x || 0; + const cy = m.y || 0; + // برای دایره، bounding box همیشه یک مربع با ضلع برابر با قطر است + minX = Math.min(minX, cx - radius); + minY = Math.min(minY, cy - radius); + maxX = Math.max(maxX, cx + radius); + maxY = Math.max(maxY, cy + radius); + return; + } + + // برای مثلث و abstract، x و y در store گوشه بالا-چپ است اما در render مرکز استفاده می‌شود + if ( + originalMember.type === "rectangle" && + (originalMember.shapeType === "triangle" || + originalMember.shapeType === "abstract") + ) { + // محاسبه مرکز از گوشه بالا-چپ + const cx = (m.x || 0) + w / 2; + const cy = (m.y || 0) + h / 2; + const rot = ((m.rotation || 0) * Math.PI) / 180; + + // برای abstract، از radius واقعی استفاده کن (0.85 * baseRadius) + // برای triangle، از radius واقعی استفاده کن (min(width, height) / 2) + let actualRadius: number; + if (originalMember.shapeType === "abstract") { + const baseRadius = Math.min(w / 2, h / 2); + actualRadius = baseRadius * 0.85; + } else { + // triangle + actualRadius = Math.min(w / 2, h / 2); + } + + // محاسبه چهار گوشه با استفاده از radius واقعی + const corners = [ + { x: -actualRadius, y: -actualRadius }, + { x: actualRadius, y: -actualRadius }, + { x: actualRadius, y: actualRadius }, + { x: -actualRadius, y: actualRadius }, + ]; + + 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); + }); + return; + } + + // برای arrow و line، x و y نقطه شروع است و width/height نقطه پایان است + if ( + originalMember.type === "arrow" || + originalMember.type === "line" + ) { + const startX = m.x || 0; + const startY = m.y || 0; + const endX = m.width || 0; + const endY = m.height || 0; + + // برای arrow و line، bounding box از نقطه شروع تا نقطه پایان است + minX = Math.min(minX, startX, endX); + minY = Math.min(minY, startY, endY); + maxX = Math.max(maxX, startX, endX); + maxY = Math.max(maxY, startY, endY); + return; + } + + // برای سایر اشکال، x و y گوشه بالا-چپ است + const cx = (m.x || 0) + w / 2; + const cy = (m.y || 0) + h / 2; + 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 node directly in Konva first to prevent transformer flicker + if (layerRef.current) { + const groupNode = layerRef.current.findOne(`#${selectedObject.id}`); + if (groupNode) { + groupNode.x(minX); + groupNode.y(minY); + groupNode.width(groupWidth); + groupNode.height(groupHeight); + groupNode.rotation(0); + } + } + + // Update group object with new bounds (rotation should remain 0) + updateObject(selectedObject.id, { + x: minX, + y: minY, + width: groupWidth, + height: groupHeight, + rotation: 0, + }); + + // Force transformer to update immediately + if (transformerRef.current && layerRef.current) { + const groupNode = layerRef.current.findOne(`#${selectedObject.id}`); + if (groupNode) { + transformerRef.current.nodes([groupNode]); + transformerRef.current.getLayer()?.batchDraw(); + } + } + } 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); + + // Handle special shapes that use radius (Circle, Triangle, Abstract) + if (selectedObject.type === "rectangle") { + if (selectedObject.shapeType === "circle") { + // Circle: maintain aspect ratio and use average scale + const avgScale = (scaleX + scaleY) / 2; + const newRadius = Math.max( + 5, + ((selectedObject.width || 50) / 2) * avgScale + ); + const newSize = newRadius * 2; + updateObject(selectedObject.id, { + x: node.x(), + y: node.y(), + rotation: node.rotation(), + width: newSize, + height: newSize, + }); + } else if ( + selectedObject.shapeType === "triangle" || + selectedObject.shapeType === "abstract" + ) { + // Triangle and Abstract: maintain aspect ratio and use average scale + const avgScale = (scaleX + scaleY) / 2; + const baseRadius = Math.min( + (selectedObject.width || 100) / 2, + (selectedObject.height || 100) / 2 + ); + const newRadius = Math.max(5, baseRadius * avgScale); + const newSize = newRadius * 2; + updateObject(selectedObject.id, { + x: node.x() - newSize / 2, + y: node.y() - newSize / 2, + rotation: node.rotation(), + width: newSize, + height: newSize, + }); + } else { + // Regular rectangle + 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), + }); + } + } else if ( + selectedObject.type === "line" || + selectedObject.type === "arrow" + ) { + // Line and Arrow: scale the end point + const startX = selectedObject.x || 0; + const startY = selectedObject.y || 0; + const endX = selectedObject.width || 0; + const endY = selectedObject.height || 0; + const dx = (endX - startX) * scaleX; + const dy = (endY - startY) * scaleY; + updateObject(selectedObject.id, { + x: node.x(), + y: node.y(), + rotation: node.rotation(), + width: node.x() + dx, + height: node.y() + dy, + }); + } else if ( + selectedObject.type === "text" || + selectedObject.type === "link" + ) { + // Text and Link: use stored width/height from store, not getClientRect + // because TextShape auto-calculates dimensions and we need to scale the stored values + const storedWidth = selectedObject.width || 100; + const storedHeight = selectedObject.height || 100; + // Use average scale to maintain aspect ratio for text + const avgScale = (scaleX + scaleY) / 2; + const newWidth = Math.max(5, storedWidth * scaleX); + const newHeight = Math.max(5, storedHeight * scaleY); + + if (selectedObject.type === "link") { + // For link, also update fontSize + updateObject(selectedObject.id, { + x: node.x(), + y: node.y(), + rotation: node.rotation(), + fontSize: selectedObject.fontSize + ? Math.max(5, selectedObject.fontSize * scaleX) + : undefined, + }); + } else { + // For text, update width, height, and fontSize + updateObject(selectedObject.id, { + x: node.x(), + y: node.y(), + rotation: node.rotation(), + width: newWidth, + height: newHeight, + fontSize: selectedObject.fontSize + ? Math.max(5, selectedObject.fontSize * avgScale) + : undefined, + }); + } + } else { + // Default for other objects (image, etc.) + 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, + layerRef, + ]); +};