-
نمایش فقط همپوشانی
+
+ معکوس کردن ماسک
+ نمایش خارج از محدوده ماسک
+
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;