Files
dpage-editor/src/pages/editor/components/canvas/ObjectRenderer.tsx
T
2026-04-26 10:50:22 +03:30

425 lines
15 KiB
TypeScript

import { useEffect, useRef } from "react";
import { Group, Rect, Circle, RegularPolygon, Star } from "react-konva";
import Konva from "konva";
import type { EditorObject } from "../../store/editorStore";
import {
RectangleShape,
CircleShape,
TriangleShape,
AbstractShape,
TextShape,
LineShape,
ArrowShape,
ImageObject,
LinkShape,
VideoShape,
} from "../tools";
import Table from "@/components/Table";
type ObjectRendererProps = {
obj: EditorObject;
isSelected: boolean;
transformerRef: React.RefObject<Konva.Transformer | null>;
layerRef: React.RefObject<Konva.Layer | null>;
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;
onCellDblClick?: (tableId: string, cellId: string, x: number, y: number, width: number, height: number, text: string) => void;
selectedCellId?: string | null;
allObjects?: EditorObject[];
};
const ObjectRenderer = ({
obj,
isSelected,
transformerRef,
layerRef,
onSelect,
onUpdate,
draggable,
onCellClick,
onCellDblClick,
selectedCellId,
allObjects = [],
}: ObjectRendererProps) => {
const groupRef = useRef<Konva.Group>(null);
// Find mask for this object
const maskShape = obj.maskId ? allObjects.find((m) => m.id === obj.maskId) : null;
const shouldApplyMask = maskShape && !obj.isMask;
// Apply caching for masked objects - REQUIRED for destination-in to work
useEffect(() => {
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();
});
});
return () => {
cancelAnimationFrame(rafId2);
if (rafId1) cancelAnimationFrame(rafId1);
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, obj.strokeWidth, obj.stroke, obj.fill]);
// Refresh cache after transformer is attached (when isSelected changes)
useEffect(() => {
if (!groupRef.current || !shouldApplyMask || !isSelected) return;
// Wait for transformer to attach, then refresh cache
const timeoutId = setTimeout(() => {
if (!groupRef.current) return;
groupRef.current.clearCache();
groupRef.current.cache();
groupRef.current.getLayer()?.batchDraw();
}, 150);
return () => {
clearTimeout(timeoutId);
};
}, [shouldApplyMask, isSelected, transformerRef]);
if (obj.visible === false) {
return null;
}
// برای objectهای با mask، یک obj جدید با موقعیت (0, 0) ایجاد کن
const shapeObj = shouldApplyMask
? { ...obj, x: 0, y: 0 }
: obj;
const commonProps = {
obj: shapeObj,
isSelected,
transformerRef,
onSelect,
onUpdate,
// اگر mask اعمال شده، shape را غیر draggable کن (Group drag می‌شود)
draggable: shouldApplyMask ? false : draggable,
};
const renderMaskShape = (mask: EditorObject) => {
const maskProps = {
listening: false,
perfectDrawEnabled: false,
fill: "black",
};
// موقعیت mask نسبت به object - برای overlap صحیح
// mask در موقعیت مطلق است، پس باید آن را relative به object کنیم
// چون shape در موقعیت (0, 0) است، باید موقعیت mask را نسبت به shape محاسبه کنم
const relativeX = (mask.x || 0) - (obj.x || 0);
const relativeY = (mask.y || 0) - (obj.y || 0);
if (mask.type === "rectangle" && mask.shapeType === "circle") {
return (
<Circle
{...maskProps}
x={relativeX}
y={relativeY}
radius={(mask.width || 50) / 2}
rotation={mask.rotation || 0}
/>
);
}
if (mask.type === "rectangle" && mask.shapeType === "triangle") {
const radius = Math.min((mask.width || 100) / 2, (mask.height || 100) / 2);
return (
<RegularPolygon
{...maskProps}
x={relativeX + (mask.width || 100) / 2}
y={relativeY + (mask.height || 100) / 2}
sides={3}
radius={radius}
rotation={mask.rotation || 0}
/>
);
}
if (mask.type === "rectangle" && mask.shapeType === "abstract") {
const baseRadius = Math.min((mask.width || 100) / 2, (mask.height || 100) / 2);
const abstractScale = 0.85;
const radius = baseRadius * abstractScale;
const innerRadius = radius * 0.5;
return (
<Star
{...maskProps}
x={relativeX + (mask.width || 100) / 2}
y={relativeY + (mask.height || 100) / 2}
numPoints={5}
innerRadius={innerRadius}
outerRadius={radius}
rotation={mask.rotation || 0}
/>
);
}
return (
<Rect
{...maskProps}
x={relativeX}
y={relativeY}
width={mask.width || 100}
height={mask.height || 100}
rotation={mask.rotation || 0}
/>
);
};
let shapeElement: React.ReactNode = null;
switch (obj.type) {
case "rectangle":
if (obj.shapeType === "circle") {
shapeElement = <CircleShape key={obj.id} {...commonProps} />;
} else if (obj.shapeType === "triangle") {
shapeElement = <TriangleShape key={obj.id} {...commonProps} />;
} else if (obj.shapeType === "abstract") {
shapeElement = <AbstractShape key={obj.id} {...commonProps} />;
} else {
shapeElement = <RectangleShape key={obj.id} {...commonProps} />;
}
break;
case "text":
shapeElement = <TextShape key={obj.id} {...commonProps} layerRef={layerRef} />;
break;
case "line":
shapeElement = <LineShape key={obj.id} {...commonProps} />;
break;
case "arrow":
shapeElement = <ArrowShape key={obj.id} {...commonProps} />;
break;
case "image":
shapeElement = <ImageObject key={obj.id} {...commonProps} />;
break;
case "link":
shapeElement = <LinkShape key={obj.id} {...commonProps} />;
break;
case "video":
shapeElement = <VideoShape key={obj.id} {...commonProps} />;
break;
case "document":
shapeElement = <ImageObject key={obj.id} {...commonProps} />;
break;
case "grid":
shapeElement = (
<Table
key={obj.id}
obj={obj}
selectedCellId={selectedCellId || null}
onCellClick={(tableId, cellId, node, e) => {
onSelect(tableId, node, e);
onCellClick?.(tableId, cellId);
}}
onCellDblClick={(tableId, cellId, x, y, width, height, text) => {
onCellDblClick?.(tableId, cellId, x, y, width, height, text);
}}
onDragEnd={(tableId, x, y) => {
onUpdate(tableId, { x, y });
}}
draggable={draggable}
/>
);
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) => {
// Snapping is handled at Stage level for smoother dragging.
e.target.getLayer()?.batchDraw();
}}
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;
}
// If no mask, render normally
if (!shouldApplyMask || !maskShape) {
return shapeElement;
}
// Apply masking with Group + destination-in or destination-out
// IMPORTANT: The shape inside keeps its normal event handlers
// 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, {
x: node.x(),
y: node.y(),
});
};
const handleGroupDragMove = (e: Konva.KonvaEventObject<DragEvent>) => {
const node = e.target;
// به‌روزرسانی transformer در حین drag
if (transformerRef.current && isSelected) {
transformerRef.current.nodes([node]);
transformerRef.current.getLayer()?.batchDraw();
}
};
// بررسی آیا object با mask گروه شده است
const isGroupedWithMask = obj.groupId && maskShape && maskShape.groupId === obj.groupId;
const handleGroupTransformEnd = () => {
if (!groupRef.current || !maskShape) return;
const node = groupRef.current;
const scaleX = node.scaleX();
const scaleY = node.scaleY();
// محاسبه موقعیت نسبی mask نسبت به object (قبل از resize)
// این موقعیت نسبی در داخل Group استفاده می‌شود
// reset scale و offset
node.scaleX(1);
node.scaleY(1);
node.offsetX(0);
node.offsetY(0);
// محاسبه موقعیت جدید object
const newX = node.x();
const newY = node.y();
// Handle special shapes that use radius (Circle, Triangle, Abstract)
let newWidth: number;
let newHeight: number;
let finalX = newX;
let finalY = newY;
if (obj.type === "rectangle") {
if (obj.shapeType === "circle") {
// Circle: maintain aspect ratio and use average scale
const avgScale = (scaleX + scaleY) / 2;
const newRadius = Math.max(5, ((obj.width || 50) / 2) * avgScale);
const newSize = newRadius * 2;
newWidth = newSize;
newHeight = newSize;
} else if (obj.shapeType === "triangle" || obj.shapeType === "abstract") {
// Triangle and Abstract: maintain aspect ratio and use average scale
const avgScale = (scaleX + scaleY) / 2;
const baseRadius = Math.min((obj.width || 100) / 2, (obj.height || 100) / 2);
const newRadius = Math.max(5, baseRadius * avgScale);
const newSize = newRadius * 2;
newWidth = newSize;
newHeight = newSize;
// Adjust position for centered shapes
finalX = newX - newSize / 2;
finalY = newY - newSize / 2;
} else {
// Regular rectangle
newWidth = Math.max(5, (obj.width || 100) * scaleX);
newHeight = Math.max(5, (obj.height || 100) * scaleY);
}
} else if (obj.type === "line" || obj.type === "arrow") {
// Line and Arrow: scale the end point
const startX = obj.x || 0;
const startY = obj.y || 0;
const endX = obj.width || 0;
const endY = obj.height || 0;
const dx = (endX - startX) * scaleX;
const dy = (endY - startY) * scaleY;
newWidth = newX + dx;
newHeight = newY + dy;
} else if (obj.type === "text" || obj.type === "link") {
// Text and Link: use scale directly
newWidth = Math.max(5, (obj.width || 100) * scaleX);
newHeight = Math.max(5, (obj.height || 100) * scaleY);
} else {
// Default for other objects (image, etc.)
newWidth = Math.max(5, (obj.width || 100) * scaleX);
newHeight = Math.max(5, (obj.height || 100) * scaleY);
}
// به‌روزرسانی object
onUpdate(obj.id, {
x: finalX,
y: finalY,
rotation: node.rotation(),
width: newWidth,
height: newHeight,
});
};
return (
<Group
ref={groupRef}
id={`masked-${obj.id}`}
name="masked-group"
x={obj.x}
y={obj.y}
draggable={draggable}
onClick={(e) => {
if (groupRef.current) {
onSelect(obj.id, groupRef.current, e);
}
}}
onDragEnd={handleGroupDragEnd}
onDragMove={handleGroupDragMove}
{...(!isGroupedWithMask && { onTransformEnd: handleGroupTransformEnd })}
>
{shapeElement}
<Group globalCompositeOperation={compositeOp} listening={false}>
{renderMaskShape(maskShape)}
</Group>
</Group>
);
};
export default ObjectRenderer;