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
@@ -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,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 = (
<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;
}
@@ -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<DragEvent>) => {
const node = e.target;
onUpdate(obj.id, {
@@ -323,18 +370,18 @@ const ObjectRenderer = ({
});
}
};
return (
<Group
<Group
ref={groupRef}
id={`masked-${obj.id}`}
name="masked-group"
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;
}
}
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]);
};