Files
dpage-editor/src/pages/editor/components/canvas/ObjectRenderer.tsx
T
hamid zarghami cb42dd6968 fix mask in image
2026-01-04 15:08:02 +03:30

299 lines
9.9 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) => 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(() => {
if (!groupRef.current || !shouldApplyMask) return;
// Use requestAnimationFrame to ensure shapes are rendered before caching
const rafId = 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(rafId);
groupRef.current?.clearCache();
};
}, [shouldApplyMask, maskShape?.x, maskShape?.y, maskShape?.width, maskShape?.height, maskShape?.rotation, obj.x, obj.y, obj.width, obj.height, obj.rotation]);
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) => {
onSelect(tableId, node);
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;
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;
onUpdate(obj.id, {
x: node.x(),
y: node.y(),
});
// به‌روزرسانی transformer در حین drag
if (transformerRef.current && isSelected) {
transformerRef.current.nodes([node]);
transformerRef.current.getLayer()?.batchDraw();
}
};
const handleGroupTransformEnd = () => {
if (!groupRef.current) return;
const node = groupRef.current;
const scaleX = node.scaleX();
const scaleY = node.scaleY();
node.scaleX(1);
node.scaleY(1);
onUpdate(obj.id, {
x: node.x(),
y: node.y(),
rotation: node.rotation(),
width: Math.max(5, (obj.width || 100) * scaleX),
height: Math.max(5, (obj.height || 100) * scaleY),
});
};
return (
<Group
ref={groupRef}
id={`masked-${obj.id}`}
name="masked-group"
x={obj.x}
y={obj.y}
draggable={draggable}
onClick={() => {
if (groupRef.current) {
onSelect(obj.id, groupRef.current);
}
}}
onDragEnd={handleGroupDragEnd}
onDragMove={handleGroupDragMove}
onTransformEnd={handleGroupTransformEnd}
>
{shapeElement}
<Group globalCompositeOperation={compositeOp} listening={false}>
{renderMaskShape(maskShape)}
</Group>
</Group>
);
};
export default ObjectRenderer;