From 0c6bf2b52b625d7f15c6e4ca9450b69a878b30cd Mon Sep 17 00:00:00 2001 From: hamid zarghami Date: Sun, 4 Jan 2026 11:27:53 +0330 Subject: [PATCH] mask --- src/pages/editor/components/EditorCanvas.tsx | 270 +++++++++++------ .../components/canvas/MaskShapeRenderer.tsx | 76 ----- .../components/canvas/ObjectRenderer.tsx | 147 +++++++--- .../editor/components/canvas/maskUtils.ts | 58 ---- .../components/sidebar/ObjectSettings.tsx | 8 +- .../sidebar/settings/MaskSettings.tsx | 159 +++++++--- .../editor/components/tools/AbstractShape.tsx | 2 + .../editor/components/tools/CircleShape.tsx | 2 + .../components/tools/RectangleShape.tsx | 2 + .../editor/components/tools/TriangleShape.tsx | 2 + src/pages/editor/store/editorStore.ts | 52 ++++ src/pages/editor/utils/exportUtils.ts | 271 ++++++++++++++++++ 12 files changed, 758 insertions(+), 291 deletions(-) delete mode 100644 src/pages/editor/components/canvas/MaskShapeRenderer.tsx delete mode 100644 src/pages/editor/components/canvas/maskUtils.ts create mode 100644 src/pages/editor/utils/exportUtils.ts diff --git a/src/pages/editor/components/EditorCanvas.tsx b/src/pages/editor/components/EditorCanvas.tsx index 82cfcbd..7944b3c 100644 --- a/src/pages/editor/components/EditorCanvas.tsx +++ b/src/pages/editor/components/EditorCanvas.tsx @@ -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) => { + 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 = () => { - {objects.map((obj) => { - const maskShape = getMaskForObject(obj, objects); - - return ( - - ); - })} - + {objects.map((obj) => ( + + ))} + {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 ( - { - 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", - ]} - /> + <> + { + 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 && ( + + handleGroupWithMask(selectedObject.id, maskObject.id)} + onTap={() => handleGroupWithMask(selectedObject.id, maskObject.id)} + listening={true} + /> + handleGroupWithMask(selectedObject.id, maskObject.id)} + onTap={() => handleGroupWithMask(selectedObject.id, maskObject.id)} + listening={true} + /> + + )} + + {/* دکمه لغو گروه‌بندی */} + {canUngroup && selectedObject && ( + + handleUngroupFromMask(selectedObject.groupId!)} + onTap={() => handleUngroupFromMask(selectedObject.groupId!)} + listening={true} + /> + handleUngroupFromMask(selectedObject.groupId!)} + onTap={() => handleUngroupFromMask(selectedObject.groupId!)} + listening={true} + /> + + )} + ); })()} diff --git a/src/pages/editor/components/canvas/MaskShapeRenderer.tsx b/src/pages/editor/components/canvas/MaskShapeRenderer.tsx deleted file mode 100644 index a245753..0000000 --- a/src/pages/editor/components/canvas/MaskShapeRenderer.tsx +++ /dev/null @@ -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 ( - - ); - } - - if (obj.type === "rectangle" && obj.shapeType === "triangle") { - const radius = Math.min((obj.width || 100) / 2, (obj.height || 100) / 2); - return ( - - ); - } - - 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 ( - - ); - } - - return ( - - ); -}; - -export default MaskShapeRenderer; - diff --git a/src/pages/editor/components/canvas/ObjectRenderer.tsx b/src/pages/editor/components/canvas/ObjectRenderer.tsx index 4a38450..1480d33 100644 --- a/src/pages/editor/components/canvas/ObjectRenderer.tsx +++ b/src/pages/editor/components/canvas/ObjectRenderer.tsx @@ -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(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 ( + + ); + } + + if (mask.type === "rectangle" && mask.shapeType === "triangle") { + const radius = Math.min((mask.width || 100) / 2, (mask.height || 100) / 2); + return ( + + ); + } + + 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 ( + + ); + } + + return ( + + ); + }; + 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 ( - + {shapeElement} - - + + {renderMaskShape(maskShape)} ); diff --git a/src/pages/editor/components/canvas/maskUtils.ts b/src/pages/editor/components/canvas/maskUtils.ts deleted file mode 100644 index 758c52d..0000000 --- a/src/pages/editor/components/canvas/maskUtils.ts +++ /dev/null @@ -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; -}; - diff --git a/src/pages/editor/components/sidebar/ObjectSettings.tsx b/src/pages/editor/components/sidebar/ObjectSettings.tsx index 4f11f6e..c7ab1e5 100644 --- a/src/pages/editor/components/sidebar/ObjectSettings.tsx +++ b/src/pages/editor/components/sidebar/ObjectSettings.tsx @@ -31,13 +31,13 @@ const ObjectSettings = ({ selectedObject, onUpdate, onDelete }: ObjectSettingsPr + {/* Mask Settings for all objects */} + + {selectedObject.type === "text" && } {selectedObject.type === "rectangle" && ( - <> - - - + )} {(selectedObject.type === "line" || selectedObject.type === "arrow") && ( diff --git a/src/pages/editor/components/sidebar/settings/MaskSettings.tsx b/src/pages/editor/components/sidebar/settings/MaskSettings.tsx index 75077be..50f5d01 100644 --- a/src/pages/editor/components/sidebar/settings/MaskSettings.tsx +++ b/src/pages/editor/components/sidebar/settings/MaskSettings.tsx @@ -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 ( -
-
-
- استفاده به عنوان ماسک - - ناحیه‌ای که با ماسک همپوشانی دارد مخفی می‌شود - -
- -
- - {isMask && ( - <> +
+ {/* Section 1: Convert shape to mask (only for shapes) */} + {canBeMask && ( +
- نمایش راهنمای ماسک +
+ استفاده به عنوان ماسک + + این شکل را به ماسک تبدیل کن + +
- -
-

- 💡 قسمت‌های همپوشانی با این ماسک مخفی می‌شوند. + + {isMask && ( + <> +

+ نمایش راهنمای ماسک + +
+ +
+

+ 💡 این شکل به ماسک تبدیل شد. از تنظیمات objects دیگر می‌توانید این ماسک را apply کنید. +

+
+ + )} +
+ )} + + {/* Section 2: Apply mask to object (for all non-mask objects) */} + {!isMask && availableMasks.length > 0 && ( +
+
+ اعمال ماسک +

+ یک ماسک برای این object انتخاب کنید

- + +
+ {availableMasks.map((mask) => { + const isActive = object.maskId === mask.id; + const maskLabel = `${mask.shapeType === "circle" ? "دایره" : mask.shapeType === "triangle" ? "مثلث" : mask.shapeType === "abstract" ? "ستاره" : "مربع"} `; + + return ( +
+ {maskLabel} + handleSelectMask(checked ? mask.id : "")} + /> +
+ ); + })} +
+ + {object.maskId && ( + <> +
+ نمایش فقط همپوشانی + handleToggleMaskInvert(!checked)} + /> +
+ + {/* نمایش وضعیت گروه‌بندی */} + {(() => { + const maskObj = objects.find(m => m.id === object.maskId); + const isGrouped = object.groupId && maskObj?.groupId === object.groupId; + + return ( +
+

+ {isGrouped ? ( + <> + 🔗 گروه‌بندی شده: Object و ماسک با هم حرکت می‌کنند. برای لغو، object را انتخاب کنید و دکمه "لغو گروه‌بندی" را بزنید. + + ) : ( + <> + ✓ ماسک اعمال شد. {object.maskInvert === false ? "فقط قسمت‌های همپوشانی نمایش داده می‌شوند." : "قسمت‌های همپوشانی مخفی می‌شوند."} +
+ 💡 برای گروه‌بندی با ماسک، object را انتخاب کنید و دکمه "گروه‌بندی با ماسک" را بزنید. + + )} +

+
+ ); + })()} + + )} +
)}
); diff --git a/src/pages/editor/components/tools/AbstractShape.tsx b/src/pages/editor/components/tools/AbstractShape.tsx index cda51a9..8c2a48c 100644 --- a/src/pages/editor/components/tools/AbstractShape.tsx +++ b/src/pages/editor/components/tools/AbstractShape.tsx @@ -23,6 +23,8 @@ const AbstractShape = ({ obj, isSelected, transformerRef, onSelect, onUpdate, dr return ( 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((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), + })); + }, }; }); diff --git a/src/pages/editor/utils/exportUtils.ts b/src/pages/editor/utils/exportUtils.ts new file mode 100644 index 0000000..788a956 --- /dev/null +++ b/src/pages/editor/utils/exportUtils.ts @@ -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 => { + 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 => { + // محاسبه 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> => { + const results = new Map(); + + 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 => { + 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"); +}; +