base group

This commit is contained in:
hamid zarghami
2026-01-05 12:27:58 +03:30
parent e683b30fa1
commit 61d8e5a814
17 changed files with 607 additions and 231 deletions
+2 -2
View File
@@ -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<MouseEvent>) => 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<MouseEvent>) => {
e.cancelBubble = true;
if (groupRef.current) {
onCellClick(obj.id, cellId, groupRef.current);
onCellClick(obj.id, cellId, groupRef.current, e);
}
};
+314 -55
View File
@@ -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<MouseEvent>) => {
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 {
} else if (!isMultiSelect) {
setSelectedTableId(null);
}
if (!isMultiSelect) {
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();
}
};
const handleCellClick = (tableId: string, cellId: string) => {
const handleCellClick = (tableId: string, cellId: string, node?: Konva.Node, e?: Konva.KonvaEventObject<MouseEvent>) => {
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 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 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,55 +166,207 @@ 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[] = [];
if (node && transformerRef.current) {
transformerRef.current.nodes([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);
}, [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();
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;
const scaledX = relativeX * scaleX;
const scaledY = relativeY * scaleY;
// 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);
}
});
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;
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();
@@ -190,15 +381,33 @@ const EditorCanvas = () => {
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);
};
}
}, [selectedObjectId, objects, transformerRef, applyTableResize, updateObject]);
}, [selectedObjectIds, objects, transformerRef, applyTableResize, updateObject]);
const finalScale = stageSize.scale * zoom;
@@ -227,11 +436,14 @@ const EditorCanvas = () => {
</Layer>
<Layer ref={layerRef}>
{objects.map((obj) => (
{/* Render group objects first */}
{objects
.filter((obj) => obj.type === "group")
.map((obj) => (
<ObjectRenderer
key={obj.id}
obj={obj}
isSelected={selectedObjectId === obj.id}
isSelected={selectedObjectIds.includes(obj.id)}
transformerRef={transformerRef}
layerRef={layerRef}
onSelect={handleSelect}
@@ -243,10 +455,57 @@ const EditorCanvas = () => {
allObjects={objects}
/>
))}
{/* Render non-grouped objects */}
{objects
.filter((obj) => !obj.groupId && obj.type !== "group")
.map((obj) => (
<ObjectRenderer
key={obj.id}
obj={obj}
isSelected={selectedObjectIds.includes(obj.id)}
transformerRef={transformerRef}
layerRef={layerRef}
onSelect={handleSelect}
onUpdate={handleObjectUpdate}
draggable={tool === "select"}
onCellClick={handleCellClick}
onCellDblClick={handleCellDblClick}
selectedCellId={selectedCellId}
allObjects={objects}
/>
))}
{/* 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 (
<Group key={`group-member-${obj.id}`} listening={false}>
<ObjectRenderer
key={obj.id}
obj={obj}
isSelected={false}
transformerRef={transformerRef}
layerRef={layerRef}
onSelect={handleSelect}
onUpdate={handleObjectUpdate}
draggable={false}
onCellClick={handleCellClick}
onCellDblClick={handleCellDblClick}
selectedCellId={selectedCellId}
allObjects={objects}
/>
</Group>
);
})}
{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",
@@ -21,7 +21,7 @@ type ObjectRendererProps = {
isSelected: boolean;
transformerRef: React.RefObject<Konva.Transformer | null>;
layerRef: React.RefObject<Konva.Layer | null>;
onSelect: (id: string, node: Konva.Node) => void;
onSelect: (id: string, node: Konva.Node, e?: Konva.KonvaEventObject<MouseEvent>) => void;
onUpdate: (id: string, updates: Partial<EditorObject>) => void;
draggable: boolean;
onCellClick?: (tableId: string, cellId: string) => void;
@@ -51,7 +51,8 @@ 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;
@@ -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]);
@@ -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 = (
<Group
key={obj.id}
id={obj.id}
name="group-container"
x={obj.x}
y={obj.y}
draggable={draggable}
onClick={(e) => {
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(),
});
}}
>
<Rect
x={0}
y={0}
width={obj.width || 100}
height={obj.height || 100}
fill="transparent"
stroke={isSelected ? "#3b82f6" : "#e5e7eb"}
strokeWidth={isSelected ? 2 : 1}
dash={[5, 5]}
listening={true}
/>
</Group>
);
break;
default:
return null;
}
@@ -332,9 +379,9 @@ const ObjectRenderer = ({
x={obj.x}
y={obj.y}
draggable={draggable}
onClick={() => {
onClick={(e) => {
if (groupRef.current) {
onSelect(obj.id, groupRef.current);
onSelect(obj.id, groupRef.current, e);
}
}}
onDragEnd={handleGroupDragEnd}
@@ -10,7 +10,7 @@ export const useDrawingHandlers = () => {
const [tempObject, setTempObject] = useState<EditorObject | null>(null);
const transformerRef = useRef<Konva.Transformer>(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;
}
@@ -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;
}
}
const selectedObject = objects.find((obj) => obj.id === selectedObjectId);
if (!selectedObject) return;
if (selectedObjectIds.length === 0) {
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, {
// Move all selected objects
selectedObjectIds.forEach((id) => {
const selectedObject = objects.find((obj) => obj.id === id);
if (!selectedObject) return;
const newX = (selectedObject.x || 0) + deltaX;
const newY = (selectedObject.y || 0) + deltaY;
updateObject(id, {
x: newX,
y: newY,
});
if (layerRef?.current) {
const node = layerRef.current.findOne(`#${selectedObjectId}`);
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]);
};
@@ -6,12 +6,7 @@ import type { ShapeProps } from "./types";
const AbstractShape = ({ obj, isSelected, transformerRef, onSelect, onUpdate, draggable }: ShapeProps) => {
const shapeRef = useRef<Konva.Star>(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) => {
@@ -6,12 +6,7 @@ import type { ShapeProps } from "./types";
const ArrowShape = ({ obj, isSelected, transformerRef, onSelect, onUpdate, draggable }: ShapeProps) => {
const shapeRef = useRef<Konva.Arrow>(null);
useEffect(() => {
if (isSelected && shapeRef.current && transformerRef.current) {
transformerRef.current.nodes([shapeRef.current]);
transformerRef.current.getLayer()?.batchDraw();
}
}, [isSelected, transformerRef]);
// 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) => {
@@ -6,12 +6,7 @@ import type { ShapeProps } from "./types";
const CircleShape = ({ obj, isSelected, transformerRef, onSelect, onUpdate, draggable }: ShapeProps) => {
const shapeRef = useRef<Konva.Circle>(null);
useEffect(() => {
if (isSelected && shapeRef.current && transformerRef.current) {
transformerRef.current.nodes([shapeRef.current]);
transformerRef.current.getLayer()?.batchDraw();
}
}, [isSelected, transformerRef]);
// 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) => {
@@ -15,16 +15,7 @@ const ImageObject = ({
const [image] = useImage(obj.imageUrl || "");
const shapeRef = useRef<Konva.Image>(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) => {
@@ -6,12 +6,7 @@ import type { ShapeProps } from "./types";
const LineShape = ({ obj, isSelected, transformerRef, onSelect, onUpdate, draggable }: ShapeProps) => {
const shapeRef = useRef<Konva.Line>(null);
useEffect(() => {
if (isSelected && shapeRef.current && transformerRef.current) {
transformerRef.current.nodes([shapeRef.current]);
transformerRef.current.getLayer()?.batchDraw();
}
}, [isSelected, transformerRef]);
// 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) => {
@@ -23,12 +23,7 @@ const LinkShape = ({
const shapeRef = useRef<Konva.Text>(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 (
<Text
@@ -43,11 +38,11 @@ const LinkShape = ({
underline={true}
rotation={obj.rotation || 0}
draggable={draggable}
onClick={() => {
onClick={(e) => {
// در حالت editor، لینک‌ها قابل کلیک نیستند
// این قابلیت در viewer اضافه خواهد شد
if (shapeRef.current) {
onSelect(obj.id, shapeRef.current);
onSelect(obj.id, shapeRef.current, e);
}
}}
onDragEnd={(e) => {
@@ -6,12 +6,7 @@ import type { ShapeProps } from "./types";
const RectangleShape = ({ obj, isSelected, transformerRef, onSelect, onUpdate, draggable }: ShapeProps) => {
const shapeRef = useRef<Konva.Rect>(null);
useEffect(() => {
if (isSelected && shapeRef.current && transformerRef.current) {
transformerRef.current.nodes([shapeRef.current]);
transformerRef.current.getLayer()?.batchDraw();
}
}, [isSelected, transformerRef]);
// 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) => {
@@ -44,25 +44,14 @@ const TextShape = ({
}: TextShapeProps) => {
const shapeRef = useRef<Konva.Text>(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();
if (shapeRef.current) {
shapeRef.current.offsetX(0);
shapeRef.current.offsetY(0);
}
}, 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) => {
@@ -6,12 +6,7 @@ import type { ShapeProps } from "./types";
const TriangleShape = ({ obj, isSelected, transformerRef, onSelect, onUpdate, draggable }: ShapeProps) => {
const shapeRef = useRef<Konva.RegularPolygon>(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) => {
@@ -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);
}
};
+1 -1
View File
@@ -5,7 +5,7 @@ export type ShapeProps = {
obj: EditorObject;
isSelected: boolean;
transformerRef: React.RefObject<Konva.Transformer | null>;
onSelect: (id: string, node: Konva.Node) => void;
onSelect: (id: string, node: Konva.Node, e?: Konva.KonvaEventObject<MouseEvent>) => void;
onUpdate: (id: string, updates: Partial<EditorObject>) => void;
draggable: boolean;
};
+108 -7
View File
@@ -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<EditorStoreType>((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<EditorStoreType>((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<EditorStoreType>((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) =>
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<EditorStoreType>((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);
},
};
});