diff --git a/src/pages/editor/components/canvas/ObjectRenderer.tsx b/src/pages/editor/components/canvas/ObjectRenderer.tsx index 5e7d88e..b2802f2 100644 --- a/src/pages/editor/components/canvas/ObjectRenderer.tsx +++ b/src/pages/editor/components/canvas/ObjectRenderer.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { Group, Rect, Circle, RegularPolygon, Star } from "react-konva"; import Konva from "konva"; import type { EditorObject } from "../../store/editorStore"; @@ -56,21 +56,26 @@ const ObjectRenderer = ({ 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 + // Whether the object and its mask share a groupId (move together as one unit). + const isGroupedWithMask = !!(obj.groupId && maskShape && maskShape.groupId === obj.groupId); + + // For GROUPED masks the relative offset is always constant — no need to track it. + // For NON-GROUPED masks we must recache when the user repositions just the mask. + // Round to integer to suppress floating-point noise that accumulates when + // handleObjectUpdate adds a delta to every group member on each drag pixel: + // e.g. 281.6692917998996 vs 281.6692917998995 would otherwise fire the effect. + const maskRelXDep = (shouldApplyMask && !isGroupedWithMask) ? Math.round((maskShape?.x || 0) - (obj.x || 0)) : 0; + const maskRelYDep = (shouldApplyMask && !isGroupedWithMask) ? Math.round((maskShape?.y || 0) - (obj.y || 0)) : 0; + 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(); }); @@ -81,13 +86,13 @@ const ObjectRenderer = ({ 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, obj.fillType, obj.gradient]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [shouldApplyMask, isSelected, maskRelXDep, maskRelYDep, maskShape?.width, maskShape?.height, maskShape?.rotation, obj.width, obj.height, obj.rotation, obj.maskInvert, obj.strokeWidth, obj.stroke, obj.fill, obj.fillType, obj.gradient]); // 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(); @@ -100,6 +105,17 @@ const ObjectRenderer = ({ }; }, [shouldApplyMask, isSelected, transformerRef]); + // Called by async shapes (images) once their content is fully decoded and painted. + const handleImageReady = useCallback(() => { + if (!groupRef.current || !shouldApplyMask) return; + requestAnimationFrame(() => { + if (!groupRef.current) return; + groupRef.current.clearCache(); + groupRef.current.cache(); + groupRef.current.getLayer()?.batchDraw(); + }); + }, [shouldApplyMask]); + if (obj.visible === false) { return null; } @@ -216,7 +232,7 @@ const ObjectRenderer = ({ shapeElement = ; break; case "image": - shapeElement = ; + shapeElement = ; break; case "link": shapeElement = ; @@ -234,10 +250,10 @@ const ObjectRenderer = ({ ); break; case "document": - shapeElement = ; + shapeElement = ; break; case "sticker": - shapeElement = ; + shapeElement = ; break; case "grid": shapeElement = ( @@ -316,9 +332,9 @@ const ObjectRenderer = ({ // 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"; + // The mask layer has listening={false} to not interfere with selection + // Default: destination-in (نمایش فقط داخل ماسک — جاهایی که ماسک هست نمایش می‌دهد) + const compositeOp = obj.maskInvert === false ? "destination-out" : "destination-in"; const handleGroupDragEnd = (e: Konva.KonvaEventObject) => { const node = e.target; @@ -328,18 +344,10 @@ const ObjectRenderer = ({ }); }; - const handleGroupDragMove = (e: Konva.KonvaEventObject) => { - const node = e.target; - // به‌روزرسانی transformer در حین drag - if (transformerRef.current && isSelected) { - transformerRef.current.nodes([node]); - transformerRef.current.getLayer()?.batchDraw(); - } + const handleGroupDragMove = (_e: Konva.KonvaEventObject) => { + // Intentionally empty — see comment above. }; - // بررسی آیا object با mask گروه شده است - const isGroupedWithMask = obj.groupId && maskShape && maskShape.groupId === obj.groupId; - const handleGroupTransformEnd = () => { if (!groupRef.current || !maskShape) return; diff --git a/src/pages/editor/components/canvas/ObjectsLayer.tsx b/src/pages/editor/components/canvas/ObjectsLayer.tsx index 294651b..5722449 100644 --- a/src/pages/editor/components/canvas/ObjectsLayer.tsx +++ b/src/pages/editor/components/canvas/ObjectsLayer.tsx @@ -1,3 +1,4 @@ +import { memo } from "react"; import { Layer, Group } from "react-konva"; import Konva from "konva"; import ObjectRenderer from "./ObjectRenderer"; @@ -152,5 +153,11 @@ const ObjectsLayer = ({ ); }; -export default ObjectsLayer; +// Memoized to prevent re-renders when EditorCanvas state that is unrelated to +// object rendering changes (e.g. activeGuideIds during drag). All function +// props are stabilised via useCallback in their respective hooks so this memo +// is effective — preventing the cascade: setActiveGuideIds → EditorCanvas +// re-render → ObjectsLayer re-render → every ObjectRenderer re-render → +// react-konva reconcile + batchDraw for every drag-move pixel. +export default memo(ObjectsLayer); diff --git a/src/pages/editor/components/canvas/useObjectHandlers.ts b/src/pages/editor/components/canvas/useObjectHandlers.ts index 4a0ed11..80d312f 100644 --- a/src/pages/editor/components/canvas/useObjectHandlers.ts +++ b/src/pages/editor/components/canvas/useObjectHandlers.ts @@ -1,58 +1,55 @@ +import { useCallback } from "react"; import { useEditorStore, type EditorObject } from "../../store/editorStore"; export const useObjectHandlers = () => { - const { updateObject, groupObjects, ungroupObjects, updateCellValue, setEditingCell, editingCell } = - useEditorStore(); + // All handlers read live state via getState() and are wrapped in useCallback + // with empty deps so their references never change between renders. + // This prevents ObjectsLayer (and every ObjectRenderer inside it) from + // re-rendering whenever unrelated EditorCanvas state (e.g. active guide IDs) + // changes during drag — eliminating lag and flickering on masked groups. - const handleObjectUpdate = (id: string, updates: Partial) => { - // همیشه آخرین state را بخوان تا چند dragmove پشت‌سرهم قبل از رِندر React، دلتا درست بماند - const liveObjects = useEditorStore.getState().objects; - const obj = liveObjects.find((o) => o.id === id); + const handleObjectUpdate = useCallback((id: string, updates: Partial) => { + const { objects, updateObject } = useEditorStore.getState(); + const obj = objects.find((o) => o.id === id); if (!obj) return; - // اگر group object است و position تغییر کرد، همه objectهای داخل group را جابجا کن if (obj.type === "group" && (updates.x !== undefined || updates.y !== undefined)) { - const groupMembers = liveObjects.filter((o) => o.groupId === id); + const groupMembers = objects.filter((o) => o.groupId === id); const deltaX = updates.x !== undefined ? updates.x - (obj.x || 0) : 0; const deltaY = updates.y !== undefined ? updates.y - (obj.y || 0) : 0; - // به‌روزرسانی group object updateObject(id, updates); - // به‌روزرسانی همه اعضای گروه groupMembers.forEach((member) => { updateObject(member.id, { x: (member.x || 0) + deltaX, y: (member.y || 0) + deltaY, }); }); - } else if (obj.groupId && (updates.x !== undefined || updates.y !== undefined)) { - // اگر object در یک گروه است و position تغییر کرد، فقط object را به‌روزرسانی کن - // bounds group فقط موقع transform به‌روزرسانی می‌شود - updateObject(id, updates); } else { updateObject(id, updates); } - }; + }, []); - const handleGroupWithMask = (objectId: string, maskId: string) => { - groupObjects([objectId, maskId]); - }; + const handleGroupWithMask = useCallback((objectId: string, maskId: string) => { + useEditorStore.getState().groupObjects([objectId, maskId]); + }, []); - const handleUngroupFromMask = (groupId: string) => { - ungroupObjects(groupId); - }; + const handleUngroupFromMask = useCallback((groupId: string) => { + useEditorStore.getState().ungroupObjects(groupId); + }, []); - const handleCellEditorSave = (text: string) => { + const handleCellEditorSave = useCallback((text: string) => { + const { editingCell, updateCellValue, setEditingCell } = useEditorStore.getState(); if (editingCell) { updateCellValue(editingCell.tableId, editingCell.cellId, text); setEditingCell(null); } - }; + }, []); - const handleCellEditorCancel = () => { - setEditingCell(null); - }; + const handleCellEditorCancel = useCallback(() => { + useEditorStore.getState().setEditingCell(null); + }, []); return { handleObjectUpdate, @@ -62,4 +59,3 @@ export const useObjectHandlers = () => { handleCellEditorCancel, }; }; - diff --git a/src/pages/editor/components/canvas/useSelectionHandlers.ts b/src/pages/editor/components/canvas/useSelectionHandlers.ts index 93839a6..bf739a1 100644 --- a/src/pages/editor/components/canvas/useSelectionHandlers.ts +++ b/src/pages/editor/components/canvas/useSelectionHandlers.ts @@ -1,20 +1,25 @@ +import { useCallback } from "react"; import Konva from "konva"; import { useEditorStore } from "../../store/editorStore"; export const useSelectionHandlers = () => { - const { - objects, - selectedObjectIds, - setSelectedObjectId, - addToSelection, - removeFromSelection, - setSelectedTableId, - setSelectedCellId, - tool, - setTool, - } = useEditorStore(); + // Read live state via getState() so callbacks never go stale and can have + // empty deps — this prevents ObjectsLayer from re-rendering every time + // unrelated React state (e.g. active guide IDs) changes in EditorCanvas. + + const handleSelect = useCallback((id: string, _node: Konva.Node, e?: Konva.KonvaEventObject) => { + const { + objects, + selectedObjectIds, + tool, + setTool, + setSelectedObjectId, + addToSelection, + removeFromSelection, + setSelectedTableId, + setSelectedCellId, + } = useEditorStore.getState(); - const handleSelect = (id: string, _node: Konva.Node, e?: Konva.KonvaEventObject) => { if (tool !== "select") { setTool("select"); } @@ -22,7 +27,6 @@ export const useSelectionHandlers = () => { const obj = objects.find((o) => o.id === id); const isMultiSelect = e?.evt?.metaKey || e?.evt?.ctrlKey || e?.evt?.shiftKey; - // اگر object در یک group است، group را انتخاب کن نه object if (obj?.groupId && obj.type !== "group") { const groupObject = objects.find((o) => o.id === obj.groupId); if (groupObject) { @@ -40,16 +44,12 @@ export const useSelectionHandlers = () => { } if (isMultiSelect) { - // انتخاب چندتایی if (selectedObjectIds.includes(id)) { - // اگر قبلاً انتخاب شده بود، از انتخاب خارج کن removeFromSelection(id); } else { - // اگر انتخاب نشده بود، به انتخاب اضافه کن addToSelection(id); } } else { - // انتخاب تک‌تایی setSelectedObjectId(id); } @@ -62,13 +62,21 @@ export const useSelectionHandlers = () => { if (!isMultiSelect) { setSelectedCellId(null); } - }; + }, []); + + const handleCellClick = useCallback((tableId: string, cellId: string, e?: Konva.KonvaEventObject) => { + const { + selectedObjectIds, + addToSelection, + removeFromSelection, + setSelectedTableId, + setSelectedObjectId, + setSelectedCellId, + } = useEditorStore.getState(); - const handleCellClick = (tableId: string, cellId: string, e?: Konva.KonvaEventObject) => { const isMultiSelect = e?.evt?.metaKey || e?.evt?.ctrlKey || e?.evt?.shiftKey; if (isMultiSelect) { - // انتخاب چندتایی if (selectedObjectIds.includes(tableId)) { removeFromSelection(tableId); } else { @@ -80,20 +88,19 @@ export const useSelectionHandlers = () => { } setSelectedCellId(cellId); - }; + }, []); - const handleCellDblClick = ( + const handleCellDblClick = useCallback(( tableId: string, cellId: string, x: number, y: number, width: number, height: number, - text: string + text: string, ) => { - const { setEditingCell } = useEditorStore.getState(); - setEditingCell({ tableId, cellId, x, y, width, height, value: text }); - }; + useEditorStore.getState().setEditingCell({ tableId, cellId, x, y, width, height, value: text }); + }, []); return { handleSelect, @@ -101,4 +108,3 @@ export const useSelectionHandlers = () => { handleCellDblClick, }; }; - diff --git a/src/pages/editor/components/sidebar/settings/MaskSettings.tsx b/src/pages/editor/components/sidebar/settings/MaskSettings.tsx index fa0d509..5061634 100644 --- a/src/pages/editor/components/sidebar/settings/MaskSettings.tsx +++ b/src/pages/editor/components/sidebar/settings/MaskSettings.tsx @@ -132,7 +132,10 @@ const MaskSettings = ({ objectId }: MaskSettingsProps) => { {object.maskId && ( <>
- نمایش فقط همپوشانی +
+ معکوس کردن ماسک + نمایش خارج از محدوده ماسک +
handleToggleMaskInvert(!checked)} @@ -153,7 +156,7 @@ const MaskSettings = ({ objectId }: MaskSettingsProps) => { ) : ( <> - ✓ ماسک اعمال شد. {object.maskInvert === false ? "فقط قسمت‌های همپوشانی نمایش داده می‌شوند." : "قسمت‌های همپوشانی مخفی می‌شوند."} + ✓ ماسک اعمال شد. {object.maskInvert === false ? "قسمت‌های خارج از ماسک نمایش داده می‌شوند." : "فقط داخل محدوده ماسک نمایش داده می‌شود."}
💡 برای گروه‌بندی با ماسک، object را انتخاب کنید و دکمه "گروه‌بندی با ماسک" را بزنید. diff --git a/src/pages/editor/components/tools/ImageObject.tsx b/src/pages/editor/components/tools/ImageObject.tsx index 7a6ed14..cb1b629 100644 --- a/src/pages/editor/components/tools/ImageObject.tsx +++ b/src/pages/editor/components/tools/ImageObject.tsx @@ -1,4 +1,4 @@ -import { useRef } from "react"; +import { useEffect, useRef } from "react"; import { Image as KonvaImage } from "react-konva"; import Konva from "konva"; import useImage from "use-image"; @@ -13,11 +13,18 @@ const ImageObject = ({ draggable, stageWidth, stageHeight, + onImageReady, }: ShapeProps) => { - const [image] = useImage(obj.imageUrl || ""); + const [image, status] = useImage(obj.imageUrl || ""); const shapeRef = useRef(null); - // Transformer is managed in EditorCanvas for multi-select support + // Notify parent (ObjectRenderer) as soon as the image is decoded and ready. + // This lets the masked group re-cache itself after the image content appears. + useEffect(() => { + if (status === "loaded" && onImageReady) { + onImageReady(); + } + }, [status, onImageReady]); if (!image) return null; diff --git a/src/pages/editor/components/tools/types.ts b/src/pages/editor/components/tools/types.ts index 7932010..6f405da 100644 --- a/src/pages/editor/components/tools/types.ts +++ b/src/pages/editor/components/tools/types.ts @@ -10,6 +10,8 @@ export type ShapeProps = { draggable: boolean; stageWidth?: number; stageHeight?: number; + /** Called by async shapes (images) once their content is fully ready to render. */ + onImageReady?: () => void; }; export type TextShapeProps = ShapeProps & { diff --git a/src/pages/editor/utils/exportUtils.ts b/src/pages/editor/utils/exportUtils.ts index d0750ad..14fd07f 100644 --- a/src/pages/editor/utils/exportUtils.ts +++ b/src/pages/editor/utils/exportUtils.ts @@ -196,8 +196,9 @@ export const exportObjectWithMask = async ( // Step 1: Draw object await drawObject(ctx, { ...object, x: 0, y: 0 }); - // Step 2: Apply mask با destination-in - ctx.globalCompositeOperation = "destination-in"; + // Step 2: Apply mask — default is destination-in (show inside mask). + // maskInvert === false → destination-out (show outside mask, hide inside). + ctx.globalCompositeOperation = object.maskInvert === false ? "destination-out" : "destination-in"; // Adjust mask position relative to object const adjustedMaskShape: EditorObject = { diff --git a/src/pages/viewer/utils/maskStyle.ts b/src/pages/viewer/utils/maskStyle.ts index 3404d32..d68a1f2 100644 --- a/src/pages/viewer/utils/maskStyle.ts +++ b/src/pages/viewer/utils/maskStyle.ts @@ -105,6 +105,16 @@ const getMaskBounds = (mask: EditorObject): Bounds => { }; }; +/** + * Computes the wrapper bounding box for a masked object. + * + * Default (maskInvert !== false) → destination-in → show inside mask: + * Uses intersection bounds — only the overlap area needs to be rendered, + * which is smaller and faster than the union. + * + * maskInvert === false → destination-out → show outside mask (hide inside): + * Uses object bounds — the entire object is visible except where the mask sits. + */ export const getMaskedLayout = ( obj: EditorObject, mask: EditorObject, @@ -113,33 +123,42 @@ export const getMaskedLayout = ( const objBounds = getObjectBounds(obj); const maskBounds = getMaskBounds(mask); - const unionLeft = Math.min(objBounds.left, maskBounds.left); - const unionTop = Math.min(objBounds.top, maskBounds.top); - const unionRight = Math.max(objBounds.right, maskBounds.right); - const unionBottom = Math.max(objBounds.bottom, maskBounds.bottom); + const isDestinationIn = obj.maskInvert !== false; - let maskRelX: number; - let maskRelY: number; + let left: number; + let top: number; + let right: number; + let bottom: number; - if (mask.type === 'rectangle' && mask.shapeType === 'circle') { - maskRelX = (mask.x || 0) - unionLeft; - maskRelY = (mask.y || 0) - unionTop; - } else if ( - mask.type === 'rectangle' && - (mask.shapeType === 'triangle' || mask.shapeType === 'abstract') - ) { - maskRelX = (mask.x || 0) - unionLeft; - maskRelY = (mask.y || 0) - unionTop; + if (isDestinationIn) { + // Show inside mask: only the intersection area will be visible. + // Use intersection bounds to minimise the wrapper size. + left = Math.max(objBounds.left, maskBounds.left); + top = Math.max(objBounds.top, maskBounds.top); + right = Math.min(objBounds.right, maskBounds.right); + bottom = Math.min(objBounds.bottom, maskBounds.bottom); + + if (right <= left || bottom <= top) { + // No overlap — nothing to render. + return { left: left * scale, top: top * scale, width: 0, height: 0, maskRelX: 0, maskRelY: 0 }; + } } else { - maskRelX = (mask.x || 0) - unionLeft; - maskRelY = (mask.y || 0) - unionTop; + // Show outside mask: the full object area is the canvas. + // The mask shape (drawn black) will punch a hole inside it. + left = objBounds.left; + top = objBounds.top; + right = objBounds.right; + bottom = objBounds.bottom; } + const maskRelX = (mask.x || 0) - left; + const maskRelY = (mask.y || 0) - top; + return { - left: unionLeft * scale, - top: unionTop * scale, - width: Math.max(1, (unionRight - unionLeft) * scale), - height: Math.max(1, (unionBottom - unionTop) * scale), + left: left * scale, + top: top * scale, + width: Math.max(1, (right - left) * scale), + height: Math.max(1, (bottom - top) * scale), maskRelX: maskRelX * scale, maskRelY: maskRelY * scale, }; @@ -199,8 +218,12 @@ const renderMaskShapeSvg = ( /** * Builds CSS mask properties matching editor Konva destination-in / destination-out. - * maskInvert === false → destination-in (show overlap only) - * otherwise → destination-out (hide overlap) + * + * Default (maskInvert !== false) → destination-in → show inside mask: + * bg = black (hide everything), shape = white (reveal mask area) + * + * maskInvert === false → destination-out → show outside mask (hide inside): + * bg = white (show everything), shape = black (hide mask area) */ export const getMaskImageStyle = ( obj: EditorObject, @@ -211,9 +234,10 @@ export const getMaskImageStyle = ( const canvasW = layout.width; const canvasH = layout.height; - const showOverlapOnly = obj.maskInvert === false; - const bgFill = showOverlapOnly ? 'black' : 'white'; - const shapeFill = showOverlapOnly ? 'white' : 'black'; + // Default behaviour: show only inside the mask (destination-in) + const showInsideMask = obj.maskInvert !== false; + const bgFill = showInsideMask ? 'black' : 'white'; + const shapeFill = showInsideMask ? 'white' : 'black'; const maskShapeX = layout.maskRelX; const maskShapeY = layout.maskRelY;