This commit is contained in:
hamid zarghami
2026-01-04 11:27:53 +03:30
parent aa6153d289
commit 0c6bf2b52b
12 changed files with 758 additions and 291 deletions
+188 -82
View File
@@ -1,12 +1,11 @@
import { useEffect, useRef } from "react";
import { Stage, Layer, Transformer, Rect } from "react-konva";
import { Stage, Layer, Transformer, Rect, Group, Text } from "react-konva";
import Konva from "konva";
import { useEditorStore } from "../store/editorStore";
import { useEditorStore, type EditorObject } from "../store/editorStore";
import { useDrawingHandlers } from "./canvas/useDrawingHandlers";
import { useStageSize } from "./canvas/useStageSize";
import { useKeyboardMovement } from "./canvas/useKeyboardMovement";
import ObjectRenderer from "./canvas/ObjectRenderer";
import { getMaskForObject } from "./canvas/maskUtils";
import CellEditor from "@/components/CellEditor";
import ZoomControls from "./ZoomControls";
@@ -30,6 +29,8 @@ const EditorCanvas = () => {
setStageRef,
setLayerRef,
zoom,
groupObjects,
ungroupObjects,
} = useEditorStore();
const { transformerRef, handleStageMouseDown, handleStageMouseMove, handleStageMouseUp } = useDrawingHandlers();
@@ -47,6 +48,7 @@ const EditorCanvas = () => {
const handleSelect = (id: string, node: Konva.Node) => {
const obj = objects.find((o) => o.id === id);
setSelectedObjectId(id);
if (obj?.type === "grid") {
setSelectedTableId(id);
@@ -89,6 +91,39 @@ const EditorCanvas = () => {
setEditingCell(null);
};
const handleObjectUpdate = (id: string, updates: Partial<EditorObject>) => {
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);
const deltaX = updates.x !== undefined ? updates.x - (obj.x || 0) : 0;
const deltaY = updates.y !== undefined ? updates.y - (obj.y || 0) : 0;
// به‌روزرسانی object اصلی
updateObject(id, updates);
// به‌روزرسانی همه اعضای گروه
groupMembers.forEach((member) => {
updateObject(member.id, {
x: (member.x || 0) + deltaX,
y: (member.y || 0) + deltaY,
});
});
} else {
updateObject(id, updates);
}
};
const handleGroupWithMask = (objectId: string, maskId: string) => {
groupObjects([objectId, maskId]);
};
const handleUngroupFromMask = (groupId: string) => {
ungroupObjects(groupId);
};
// Attach transformer to selected object when it changes
useEffect(() => {
if (!transformerRef.current || !selectedObjectId || !layerRef.current) return;
@@ -106,21 +141,13 @@ const EditorCanvas = () => {
return;
}
// For other objects (like images), find the node by id and attach transformer
// 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)
const timeoutId = setTimeout(() => {
if (!layerRef.current || !transformerRef.current) return;
// Check if object is masked - if so, find parent Group
const maskShape = getMaskForObject(selectedObject, objects);
let node: Konva.Node | undefined;
if (maskShape) {
node = layerRef.current.findOne(`.masked-group-${selectedObjectId}`);
} else {
node = layerRef.current.findOne(`#${selectedObjectId}`);
}
const node = layerRef.current.findOne(`#${selectedObjectId}`);
if (node && transformerRef.current) {
transformerRef.current.nodes([node]);
transformerRef.current.getLayer()?.batchDraw();
@@ -134,34 +161,38 @@ const EditorCanvas = () => {
if (!transformerRef.current || !selectedObjectId) return;
const selectedObject = objects.find((obj) => obj.id === selectedObjectId);
if (selectedObject?.type !== "grid") return;
if (!selectedObject) return;
const transformer = transformerRef.current;
const handleTransformEnd = () => {
const node = transformer.nodes()[0];
if (!node || !selectedObject) return;
const scaleX = node.scaleX();
const scaleY = node.scaleY();
// Handle grid objects separately
if (selectedObject.type === "grid") {
const handleTransformEnd = () => {
const node = transformer.nodes()[0];
if (!node || !selectedObject) return;
node.scaleX(1);
node.scaleY(1);
const scaleX = node.scaleX();
const scaleY = node.scaleY();
const newWidth = (selectedObject.width || 300) * scaleX;
const newHeight = (selectedObject.height || 200) * scaleY;
node.scaleX(1);
node.scaleY(1);
applyTableResize(selectedObject.id, newWidth, newHeight);
updateObject(selectedObject.id, {
x: node.x(),
y: node.y(),
});
};
const newWidth = (selectedObject.width || 300) * scaleX;
const newHeight = (selectedObject.height || 200) * scaleY;
transformer.on("transformend", handleTransformEnd);
applyTableResize(selectedObject.id, newWidth, newHeight);
updateObject(selectedObject.id, {
x: node.x(),
y: node.y(),
});
};
return () => {
transformer.off("transformend", handleTransformEnd);
};
transformer.on("transformend", handleTransformEnd);
return () => {
transformer.off("transformend", handleTransformEnd);
};
}
}, [selectedObjectId, objects, transformerRef, applyTableResize, updateObject]);
const finalScale = stageSize.scale * zoom;
@@ -191,59 +222,134 @@ const EditorCanvas = () => {
</Layer>
<Layer ref={layerRef}>
{objects.map((obj) => {
const maskShape = getMaskForObject(obj, objects);
return (
<ObjectRenderer
key={obj.id}
obj={obj}
isSelected={selectedObjectId === obj.id}
transformerRef={transformerRef}
layerRef={layerRef}
onSelect={handleSelect}
onUpdate={updateObject}
draggable={tool === "select"}
onCellClick={handleCellClick}
onCellDblClick={handleCellDblClick}
selectedCellId={selectedCellId}
maskShape={maskShape}
/>
);
})}
{objects.map((obj) => (
<ObjectRenderer
key={obj.id}
obj={obj}
isSelected={selectedObjectId === obj.id}
transformerRef={transformerRef}
layerRef={layerRef}
onSelect={handleSelect}
onUpdate={handleObjectUpdate}
draggable={tool === "select"}
onCellClick={handleCellClick}
onCellDblClick={handleCellDblClick}
selectedCellId={selectedCellId}
allObjects={objects}
/>
))}
{selectedObjectId && (() => {
const selectedObject = objects.find(obj => obj.id === selectedObjectId);
const isText = selectedObject?.type === "text";
// بررسی امکان group/ungroup با mask
const hasMask = selectedObject?.maskId;
const maskObject = hasMask ? objects.find(m => m.id === selectedObject.maskId) : null;
const isGrouped = selectedObject?.groupId && maskObject?.groupId === selectedObject.groupId;
const canGroup = hasMask && !isGrouped && maskObject && !selectedObject?.isMask;
const canUngroup = isGrouped && selectedObject?.groupId;
return (
<Transformer
ref={transformerRef}
boundBoxFunc={(oldBox, newBox) => {
if (Math.abs(newBox.width) < 5 || Math.abs(newBox.height) < 5) {
return oldBox;
}
return newBox;
}}
ignoreStroke={isText}
perfectDrawEnabled={false}
anchorFill="#3b82f6"
anchorStroke="#ffffff"
borderStroke="#3b82f6"
borderStrokeWidth={2}
anchorSize={8}
rotateEnabled={true}
enabledAnchors={[
"top-left",
"top-right",
"bottom-left",
"bottom-right",
"top-center",
"bottom-center",
"middle-left",
"middle-right",
]}
/>
<>
<Transformer
ref={transformerRef}
boundBoxFunc={(oldBox, newBox) => {
if (Math.abs(newBox.width) < 5 || Math.abs(newBox.height) < 5) {
return oldBox;
}
return newBox;
}}
ignoreStroke={isText}
perfectDrawEnabled={false}
anchorFill="#3b82f6"
anchorStroke="#ffffff"
borderStroke="#3b82f6"
borderStrokeWidth={2}
anchorSize={8}
rotateEnabled={true}
enabledAnchors={[
"top-left",
"top-right",
"bottom-left",
"bottom-right",
"top-center",
"bottom-center",
"middle-left",
"middle-right",
]}
/>
{/* دکمه گروه‌بندی */}
{canGroup && selectedObject && maskObject && (
<Group>
<Rect
x={(selectedObject.x || 0) + ((selectedObject.width || 100) / 2) - 50}
y={(selectedObject.y || 0) - 40}
width={100}
height={32}
fill="#3b82f6"
cornerRadius={8}
shadowColor="black"
shadowBlur={10}
shadowOpacity={0.2}
onClick={() => handleGroupWithMask(selectedObject.id, maskObject.id)}
onTap={() => handleGroupWithMask(selectedObject.id, maskObject.id)}
listening={true}
/>
<Text
x={(selectedObject.x || 0) + ((selectedObject.width || 100) / 2) - 50}
y={(selectedObject.y || 0) - 40}
width={100}
height={32}
text="گروه‌بندی با ماسک"
fontSize={13}
fontFamily="irancell"
fill="white"
align="center"
verticalAlign="middle"
onClick={() => handleGroupWithMask(selectedObject.id, maskObject.id)}
onTap={() => handleGroupWithMask(selectedObject.id, maskObject.id)}
listening={true}
/>
</Group>
)}
{/* دکمه لغو گروه‌بندی */}
{canUngroup && selectedObject && (
<Group>
<Rect
x={(selectedObject.x || 0) + ((selectedObject.width || 100) / 2) - 50}
y={(selectedObject.y || 0) - 40}
width={100}
height={32}
fill="#ef4444"
cornerRadius={8}
shadowColor="black"
shadowBlur={10}
shadowOpacity={0.2}
onClick={() => handleUngroupFromMask(selectedObject.groupId!)}
onTap={() => handleUngroupFromMask(selectedObject.groupId!)}
listening={true}
/>
<Text
x={(selectedObject.x || 0) + ((selectedObject.width || 100) / 2) - 50}
y={(selectedObject.y || 0) - 40}
width={100}
height={32}
text="لغو گروه‌بندی"
fontSize={13}
fontFamily="irancell"
fill="white"
align="center"
verticalAlign="middle"
onClick={() => handleUngroupFromMask(selectedObject.groupId!)}
onTap={() => handleUngroupFromMask(selectedObject.groupId!)}
listening={true}
/>
</Group>
)}
</>
);
})()}
</Layer>
@@ -1,76 +0,0 @@
import { Circle, Rect, RegularPolygon, Star } from "react-konva";
import type { EditorObject } from "../../store/editorStore";
type MaskShapeRendererProps = {
obj: EditorObject;
};
const MaskShapeRenderer = ({ obj }: MaskShapeRendererProps) => {
const commonProps = {
listening: false,
perfectDrawEnabled: false,
};
if (obj.type === "rectangle" && obj.shapeType === "circle") {
return (
<Circle
{...commonProps}
x={obj.x}
y={obj.y}
radius={(obj.width || 50) / 2}
fill="black"
rotation={obj.rotation || 0}
/>
);
}
if (obj.type === "rectangle" && obj.shapeType === "triangle") {
const radius = Math.min((obj.width || 100) / 2, (obj.height || 100) / 2);
return (
<RegularPolygon
{...commonProps}
x={obj.x + (obj.width || 100) / 2}
y={obj.y + (obj.height || 100) / 2}
sides={3}
radius={radius}
fill="black"
rotation={obj.rotation || 0}
/>
);
}
if (obj.type === "rectangle" && obj.shapeType === "abstract") {
const baseRadius = Math.min((obj.width || 100) / 2, (obj.height || 100) / 2);
const abstractScale = 0.85;
const radius = baseRadius * abstractScale;
const innerRadius = radius * 0.5;
return (
<Star
{...commonProps}
x={obj.x + (obj.width || 100) / 2}
y={obj.y + (obj.height || 100) / 2}
numPoints={5}
innerRadius={innerRadius}
outerRadius={radius}
fill="black"
rotation={obj.rotation || 0}
/>
);
}
return (
<Rect
{...commonProps}
x={obj.x}
y={obj.y}
width={obj.width || 100}
height={obj.height || 100}
fill="black"
rotation={obj.rotation || 0}
/>
);
};
export default MaskShapeRenderer;
@@ -1,6 +1,6 @@
import Konva from "konva";
import { Group } from "react-konva";
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,
@@ -15,7 +15,6 @@ import {
VideoShape,
} from "../tools";
import Table from "@/components/Table";
import MaskShapeRenderer from "./MaskShapeRenderer";
type ObjectRendererProps = {
obj: EditorObject;
@@ -28,7 +27,7 @@ type ObjectRendererProps = {
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;
maskShape?: EditorObject | null;
allObjects?: EditorObject[];
};
const ObjectRenderer = ({
@@ -42,39 +41,35 @@ const ObjectRenderer = ({
onCellClick,
onCellDblClick,
selectedCellId,
maskShape,
allObjects = [],
}: ObjectRendererProps) => {
const groupRef = useRef<Konva.Group>(null);
useEffect(() => {
if (!groupRef.current) return;
// Find mask for this object
const maskShape = obj.maskId ? allObjects.find((m) => m.id === obj.maskId) : null;
const shouldApplyMask = maskShape && !obj.isMask;
const isImage = obj.type === "image" || obj.type === "document";
if (maskShape && !isImage) {
// فقط برای non-image objects از cache استفاده کن
groupRef.current.cache({
pixelRatio: 1,
});
} else {
// 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();
}
groupRef.current.getLayer()?.batchDraw();
}, [
maskShape,
maskShape?.x,
maskShape?.y,
maskShape?.width,
maskShape?.height,
maskShape?.rotation,
obj.type,
obj.x,
obj.y,
obj.width,
obj.height,
obj.rotation
]);
// 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;
@@ -89,6 +84,75 @@ const ObjectRenderer = ({
draggable,
};
const renderMaskShape = (mask: EditorObject) => {
const maskProps = {
listening: false,
perfectDrawEnabled: false,
fill: "black",
};
// موقعیت mask نسبت به object - برای overlap صحیح
// mask در موقعیت مطلق است، پس باید آن را relative به object کنیم
const relativeX = (mask.x || 0);
const relativeY = (mask.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) {
@@ -148,15 +212,26 @@ const ObjectRenderer = ({
return null;
}
if (!maskShape) {
// 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";
return (
<Group ref={groupRef} name={`masked-group-${obj.id}`}>
<Group
ref={groupRef}
id={`masked-${obj.id}`}
name="masked-group"
>
{shapeElement}
<Group globalCompositeOperation="destination-out" listening={false}>
<MaskShapeRenderer obj={maskShape} />
<Group globalCompositeOperation={compositeOp} listening={false}>
{renderMaskShape(maskShape)}
</Group>
</Group>
);
@@ -1,58 +0,0 @@
import type { EditorObject } from "../../store/editorStore";
export const getBounds = (obj: EditorObject) => {
const x = obj.x || 0;
const y = obj.y || 0;
const width = obj.width || 100;
const height = obj.height || 100;
return { x, y, width, height };
};
export const isObjectBelowMask = (
obj: EditorObject,
mask: EditorObject,
objects: EditorObject[]
): boolean => {
const objIndex = objects.findIndex((o) => o.id === obj.id);
const maskIndex = objects.findIndex((o) => o.id === mask.id);
// mask باید همه objects رو mask کنه، نه فقط پایین‌تر
// فقط خودش mask نشه
return objIndex !== maskIndex;
};
export const overlaps = (obj1: EditorObject, obj2: EditorObject): boolean => {
const b1 = getBounds(obj1);
const b2 = getBounds(obj2);
return !(
b1.x + b1.width < b2.x ||
b2.x + b2.width < b1.x ||
b1.y + b1.height < b2.y ||
b2.y + b2.height < b1.y
);
};
export const getMaskForObject = (
obj: EditorObject,
allObjects: EditorObject[]
): EditorObject | null => {
// اگر خود object یک mask است، نباید mask بشه
if (obj.isMask) {
return null;
}
const maskShapes = allObjects.filter(
(o) => o.isMask && o.type === "rectangle" && o.visible !== false && o.id !== obj.id
);
for (const mask of maskShapes) {
if (isObjectBelowMask(obj, mask, allObjects) && overlaps(obj, mask)) {
return mask;
}
}
return null;
};
@@ -31,13 +31,13 @@ const ObjectSettings = ({ selectedObject, onUpdate, onDelete }: ObjectSettingsPr
</button>
</div>
{/* Mask Settings for all objects */}
<MaskSettings objectId={selectedObject.id} />
{selectedObject.type === "text" && <TextInstruction />}
{selectedObject.type === "rectangle" && (
<>
<MaskSettings objectId={selectedObject.id} />
<ShapeSettings selectedObject={selectedObject} onUpdate={onUpdate} />
</>
<ShapeSettings selectedObject={selectedObject} onUpdate={onUpdate} />
)}
{(selectedObject.type === "line" || selectedObject.type === "arrow") && (
@@ -6,28 +6,35 @@ type MaskSettingsProps = {
};
/**
* MaskSettings - تنظیمات mask mode برای shapes
* MaskSettings - تنظیمات masking برای objects
*
* این component اجازه می‌ده کاربر یک shape رو به mask تبدیل کنه
* فقط برای shapes قابل استفاده است (rectangle, circle, triangle, abstract)
* دو حالت دارد:
* 1. برای shapes: تبدیل shape به mask (isMask)
* 2. برای objects: انتخاب یک mask برای apply کردن (maskId)
*/
const MaskSettings = ({ objectId }: MaskSettingsProps) => {
const { objects, updateObject, selectedObjectId, setSelectedObjectId } = useEditorStore();
const object = objects.find((obj) => obj.id === objectId);
if (!object) return null;
const canBeMask = object.type === "rectangle";
if (!canBeMask) return null;
const isMask = object.isMask || false;
const currentShowGuide = object.showMaskGuide !== false;
// Get all available masks (excluding self if it's a shape)
const availableMasks = objects.filter(
(obj) => obj.isMask && obj.id !== objectId && obj.visible !== false
);
const handleToggleMask = (checked: boolean) => {
updateObject(objectId, { isMask: checked, showMaskGuide: checked ? true : undefined });
updateObject(objectId, {
isMask: checked,
showMaskGuide: checked ? true : undefined,
maskId: undefined // Clear maskId when becoming a mask
});
if (checked && selectedObjectId === objectId) {
setSelectedObjectId(null);
}
@@ -37,37 +44,119 @@ const MaskSettings = ({ objectId }: MaskSettingsProps) => {
updateObject(objectId, { showMaskGuide: checked });
};
const handleSelectMask = (maskId: string) => {
updateObject(objectId, { maskId: maskId || undefined });
};
const handleToggleMaskInvert = (checked: boolean) => {
updateObject(objectId, { maskInvert: checked });
};
return (
<div className="p-4 border-b">
<div className="flex items-center justify-between mb-3">
<div className="flex flex-col gap-1">
<span className="text-sm font-medium">استفاده به عنوان ماسک</span>
<span className="text-xs text-gray-500">
ناحیهای که با ماسک همپوشانی دارد مخفی میشود
</span>
</div>
<Switch
checked={isMask}
onCheckedChange={handleToggleMask}
/>
</div>
{isMask && (
<>
<div className="p-4 border-b space-y-4">
{/* Section 1: Convert shape to mask (only for shapes) */}
{canBeMask && (
<div>
<div className="flex items-center justify-between mb-3">
<span className="text-sm">نمایش راهنمای ماسک</span>
<div className="flex flex-col gap-1">
<span className="text-sm font-medium">استفاده به عنوان ماسک</span>
<span className="text-xs text-gray-500">
این شکل را به ماسک تبدیل کن
</span>
</div>
<Switch
checked={currentShowGuide}
onCheckedChange={handleToggleGuide}
checked={isMask}
onCheckedChange={handleToggleMask}
/>
</div>
<div className="p-2 bg-blue-50 rounded-md">
<p className="text-xs text-blue-700">
💡 قسمتهای همپوشانی با این ماسک مخفی میشوند.
{isMask && (
<>
<div className="flex items-center justify-between mb-3">
<span className="text-sm">نمایش راهنمای ماسک</span>
<Switch
checked={currentShowGuide}
onCheckedChange={handleToggleGuide}
/>
</div>
<div className="p-2 bg-blue-50 rounded-md">
<p className="text-xs text-blue-700">
💡 این شکل به ماسک تبدیل شد. از تنظیمات objects دیگر میتوانید این ماسک را apply کنید.
</p>
</div>
</>
)}
</div>
)}
{/* Section 2: Apply mask to object (for all non-mask objects) */}
{!isMask && availableMasks.length > 0 && (
<div>
<div className="mb-3">
<span className="text-sm font-medium">اعمال ماسک</span>
<p className="text-xs text-gray-500 mt-1">
یک ماسک برای این object انتخاب کنید
</p>
</div>
</>
<div className="space-y-2 max-h-48 overflow-y-auto">
{availableMasks.map((mask) => {
const isActive = object.maskId === mask.id;
const maskLabel = `${mask.shapeType === "circle" ? "دایره" : mask.shapeType === "triangle" ? "مثلث" : mask.shapeType === "abstract" ? "ستاره" : "مربع"} `;
return (
<div
key={mask.id}
className={`flex items-center justify-between p-2 rounded-lg border transition-colors ${isActive ? "border-blue-500 bg-blue-50" : "border-gray-200 hover:border-gray-300"
}`}
>
<span className="text-sm flex-1">{maskLabel}</span>
<Switch
checked={isActive}
onCheckedChange={(checked) => handleSelectMask(checked ? mask.id : "")}
/>
</div>
);
})}
</div>
{object.maskId && (
<>
<div className="flex items-center justify-between mt-4 pt-3 border-t">
<span className="text-xs">نمایش فقط همپوشانی</span>
<Switch
checked={object.maskInvert === false}
onCheckedChange={(checked) => handleToggleMaskInvert(!checked)}
/>
</div>
{/* نمایش وضعیت گروه‌بندی */}
{(() => {
const maskObj = objects.find(m => m.id === object.maskId);
const isGrouped = object.groupId && maskObj?.groupId === object.groupId;
return (
<div className={`mt-3 p-2 rounded-md ${isGrouped ? "bg-purple-50" : "bg-green-50"}`}>
<p className={`text-xs ${isGrouped ? "text-purple-700" : "text-green-700"}`}>
{isGrouped ? (
<>
🔗 گروهبندی شده: Object و ماسک با هم حرکت میکنند. برای لغو، object را انتخاب کنید و دکمه "لغو گروه‌بندی" را بزنید.
</>
) : (
<>
ماسک اعمال شد. {object.maskInvert === false ? "فقط قسمت‌های همپوشانی نمایش داده می‌شوند." : "قسمت‌های همپوشانی مخفی می‌شوند."}
<br />
💡 برای گروهبندی با ماسک، object را انتخاب کنید و دکمه "گروه‌بندی با ماسک" را بزنید.
</>
)}
</p>
</div>
);
})()}
</>
)}
</div>
)}
</div>
);
@@ -23,6 +23,8 @@ const AbstractShape = ({ obj, isSelected, transformerRef, onSelect, onUpdate, dr
return (
<Star
ref={shapeRef}
id={obj.id}
name={isMask ? "mask-shape" : "canvas-object"}
x={obj.x + (obj.width || 100) / 2}
y={obj.y + (obj.height || 100) / 2}
numPoints={5}
@@ -19,6 +19,8 @@ const CircleShape = ({ obj, isSelected, transformerRef, onSelect, onUpdate, drag
return (
<Circle
ref={shapeRef}
id={obj.id}
name={isMask ? "mask-shape" : "canvas-object"}
x={obj.x}
y={obj.y}
radius={(obj.width || 50) / 2}
@@ -19,6 +19,8 @@ const RectangleShape = ({ obj, isSelected, transformerRef, onSelect, onUpdate, d
return (
<Rect
ref={shapeRef}
id={obj.id}
name={isMask ? "mask-shape" : "canvas-object"}
x={obj.x}
y={obj.y}
width={obj.width || 100}
@@ -20,6 +20,8 @@ const TriangleShape = ({ obj, isSelected, transformerRef, onSelect, onUpdate, dr
return (
<RegularPolygon
ref={shapeRef}
id={obj.id}
name={isMask ? "mask-shape" : "canvas-object"}
x={obj.x + (obj.width || 100) / 2}
y={obj.y + (obj.height || 100) / 2}
sides={3}
+52
View File
@@ -62,6 +62,9 @@ export type EditorObject = {
visible?: boolean;
isMask?: boolean;
showMaskGuide?: boolean;
maskId?: string; // Reference to mask shape (DATA only, NO rendering logic)
maskInvert?: boolean; // اگر true: overlap مخفی می‌شود، اگر false: overlap نمایش داده می‌شود
groupId?: string;
};
export type Page = {
@@ -136,6 +139,8 @@ type EditorStoreType = {
setCurrentPage: (pageId: string) => void;
updatePageName: (pageId: string, name: string) => void;
toggleObjectVisibility: (id: string) => void;
groupObjects: (objectIds: string[]) => void;
ungroupObjects: (groupId: string) => void;
};
const createTableCellId = (row: number, col: number) => `cell-${row}-${col}`;
@@ -640,5 +645,52 @@ export const useEditorStore = create<EditorStoreType>((set, get) => {
selectedTableId: shouldDeselect ? null : state.selectedTableId,
}));
},
groupObjects: (objectIds) => {
const state = get();
if (objectIds.length < 2) return;
const groupId = `group-${crypto.randomUUID()}`;
const updateObjects = (objects: EditorObject[]) =>
objects.map((obj) =>
objectIds.includes(obj.id) ? { ...obj, groupId } : obj
);
if (state.currentPageId) {
set((state) => ({
objects: updateObjects(state.objects),
pages: state.pages.map((p) =>
p.id === state.currentPageId
? { ...p, objects: updateObjects(p.objects) }
: p
),
}));
return;
}
set((state) => ({
objects: updateObjects(state.objects),
}));
},
ungroupObjects: (groupId) => {
const state = get();
const updateObjects = (objects: EditorObject[]) =>
objects.map((obj) =>
obj.groupId === groupId ? { ...obj, groupId: undefined } : obj
);
if (state.currentPageId) {
set((state) => ({
objects: updateObjects(state.objects),
pages: state.pages.map((p) =>
p.id === state.currentPageId
? { ...p, objects: updateObjects(p.objects) }
: p
),
}));
return;
}
set((state) => ({
objects: updateObjects(state.objects),
}));
},
};
});
+271
View File
@@ -0,0 +1,271 @@
import type { EditorObject } from "../store/editorStore";
/**
* exportUtils - Utilities برای export با real masking
*
* CRITICAL: Real masking فقط در export اعمال می‌شود، نه در editor.
* این تضمین می‌کند که masking هیچ‌وقت روی hit detection، selection،
* grouping یا transforms تاثیر نگذارد.
*/
/**
* رسم یک mask shape روی canvas context
*/
const drawMaskShape = (
ctx: CanvasRenderingContext2D,
maskShape: EditorObject
): void => {
ctx.save();
ctx.translate(maskShape.x || 0, maskShape.y || 0);
ctx.rotate(((maskShape.rotation || 0) * Math.PI) / 180);
ctx.fillStyle = "black";
ctx.strokeStyle = "transparent";
if (maskShape.type === "rectangle" && maskShape.shapeType === "circle") {
const radius = (maskShape.width || 50) / 2;
ctx.beginPath();
ctx.arc(0, 0, radius, 0, Math.PI * 2);
ctx.fill();
} else if (maskShape.type === "rectangle" && maskShape.shapeType === "triangle") {
const width = maskShape.width || 100;
const height = maskShape.height || 100;
const radius = Math.min(width / 2, height / 2);
ctx.beginPath();
ctx.translate(width / 2, height / 2);
for (let i = 0; i < 3; i++) {
const angle = (i * 2 * Math.PI) / 3 - Math.PI / 2;
const x = radius * Math.cos(angle);
const y = radius * Math.sin(angle);
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.closePath();
ctx.fill();
} else if (maskShape.type === "rectangle" && maskShape.shapeType === "abstract") {
const width = maskShape.width || 100;
const height = maskShape.height || 100;
const baseRadius = Math.min(width / 2, height / 2);
const abstractScale = 0.85;
const outerRadius = baseRadius * abstractScale;
const innerRadius = outerRadius * 0.5;
const numPoints = 5;
ctx.beginPath();
ctx.translate(width / 2, height / 2);
for (let i = 0; i < numPoints * 2; i++) {
const radius = i % 2 === 0 ? outerRadius : innerRadius;
const angle = (i * Math.PI) / numPoints - Math.PI / 2;
const x = radius * Math.cos(angle);
const y = radius * Math.sin(angle);
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.closePath();
ctx.fill();
} else {
// Default: Rectangle
const width = maskShape.width || 100;
const height = maskShape.height || 100;
ctx.fillRect(0, 0, width, height);
}
ctx.restore();
};
/**
* رسم یک object روی canvas context
* این تابع یک نسخه ساده است - در پیاده‌سازی واقعی باید
* از Konva stage.toCanvas() یا image rendering استفاده شود
*/
const drawObject = async (
ctx: CanvasRenderingContext2D,
object: EditorObject
): Promise<void> => {
ctx.save();
ctx.translate(object.x || 0, object.y || 0);
ctx.rotate(((object.rotation || 0) * Math.PI) / 180);
ctx.scale(object.scaleX || 1, object.scaleY || 1);
// برای image objects
if (object.type === "image" || object.type === "document") {
if (object.imageUrl) {
const img = new Image();
img.crossOrigin = "anonymous";
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
img.src = object.imageUrl!;
});
ctx.drawImage(img, 0, 0, object.width || 100, object.height || 100);
}
}
// برای shape objects
else if (object.type === "rectangle") {
ctx.fillStyle = object.fill || "#000000";
ctx.strokeStyle = object.stroke || "transparent";
ctx.lineWidth = object.strokeWidth || 0;
if (object.shapeType === "circle") {
const radius = (object.width || 50) / 2;
ctx.beginPath();
ctx.arc(0, 0, radius, 0, Math.PI * 2);
ctx.fill();
if (ctx.lineWidth > 0) ctx.stroke();
} else {
ctx.fillRect(0, 0, object.width || 100, object.height || 100);
if (ctx.lineWidth > 0) {
ctx.strokeRect(0, 0, object.width || 100, object.height || 100);
}
}
}
// برای text objects
else if (object.type === "text") {
ctx.fillStyle = object.fill || "#000000";
ctx.font = `${object.fontWeight || "normal"} ${object.fontSize || 16}px ${object.fontFamily || "Arial"}`;
ctx.fillText(object.text || "", 0, 0);
}
ctx.restore();
};
/**
* Export یک object با masking به صورت data URL
*
* @param object - Object برای export
* @param maskShape - Mask shape برای اعمال (optional)
* @returns Data URL (PNG)
*/
export const exportObjectWithMask = async (
object: EditorObject,
maskShape: EditorObject | null
): Promise<string> => {
// محاسبه bounds
const x = object.x || 0;
const y = object.y || 0;
const width = object.width || 100;
const height = object.height || 100;
// ایجاد offscreen canvas
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("Failed to get canvas context");
}
// اگر mask وجود ندارد، فقط object را draw کن
if (!maskShape) {
await drawObject(ctx, { ...object, x: 0, y: 0 });
return canvas.toDataURL("image/png");
}
// Step 1: Draw object
await drawObject(ctx, { ...object, x: 0, y: 0 });
// Step 2: Apply mask با destination-in
ctx.globalCompositeOperation = "destination-in";
// Adjust mask position relative to object
const adjustedMaskShape: EditorObject = {
...maskShape,
x: (maskShape.x || 0) - x,
y: (maskShape.y || 0) - y,
};
drawMaskShape(ctx, adjustedMaskShape);
// Return PNG
return canvas.toDataURL("image/png");
};
/**
* Export تمام objects از یک page با masking
*
* @param objects - لیست objects برای export
* @returns Map از object IDs به data URLs
*/
export const exportPageWithMasks = async (
objects: EditorObject[]
): Promise<Map<string, string>> => {
const results = new Map<string, string>();
for (const obj of objects) {
// Skip masks themselves
if (obj.isMask) {
continue;
}
let maskShape: EditorObject | null = null;
if (obj.maskId) {
maskShape = objects.find((m) => m.id === obj.maskId) || null;
}
try {
const dataUrl = await exportObjectWithMask(obj, maskShape);
results.set(obj.id, dataUrl);
} catch (error) {
console.error(`Failed to export object ${obj.id}:`, error);
}
}
return results;
};
/**
* Export کل stage به عنوان یک image با masking اعمال شده
*
* این تابع می‌تواند با Konva Stage API integrate شود
* برای بهتر شدن quality و performance
*/
export const exportStageWithMasks = async (
objects: EditorObject[],
stageWidth: number,
stageHeight: number,
backgroundColor: string = "#ffffff"
): Promise<string> => {
const canvas = document.createElement("canvas");
canvas.width = stageWidth;
canvas.height = stageHeight;
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("Failed to get canvas context");
}
// Background
ctx.fillStyle = backgroundColor;
ctx.fillRect(0, 0, stageWidth, stageHeight);
// Export هر object با mask
const exports = await exportPageWithMasks(objects);
// Draw exported images روی stage
for (const [objId, dataUrl] of exports.entries()) {
const obj = objects.find((o) => o.id === objId);
if (!obj) continue;
const img = new Image();
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
img.src = dataUrl;
});
ctx.drawImage(img, obj.x || 0, obj.y || 0);
}
return canvas.toDataURL("image/png");
};