This commit is contained in:
hamid zarghami
2026-06-20 12:19:23 +03:30
parent 052b737653
commit e93ab38b05
9 changed files with 167 additions and 113 deletions
@@ -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 = <ArrowShape key={obj.id} {...commonProps} />;
break;
case "image":
shapeElement = <ImageObject key={obj.id} {...commonProps} />;
shapeElement = <ImageObject key={obj.id} {...commonProps} onImageReady={handleImageReady} />;
break;
case "link":
shapeElement = <LinkShape key={obj.id} {...commonProps} />;
@@ -234,10 +250,10 @@ const ObjectRenderer = ({
);
break;
case "document":
shapeElement = <ImageObject key={obj.id} {...commonProps} />;
shapeElement = <ImageObject key={obj.id} {...commonProps} onImageReady={handleImageReady} />;
break;
case "sticker":
shapeElement = <ImageObject key={obj.id} {...commonProps} />;
shapeElement = <ImageObject key={obj.id} {...commonProps} onImageReady={handleImageReady} />;
break;
case "grid":
shapeElement = (
@@ -317,8 +333,8 @@ 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";
// Default: destination-in (نمایش فقط داخل ماسک — جاهایی که ماسک هست نمایش می‌دهد)
const compositeOp = obj.maskInvert === false ? "destination-out" : "destination-in";
const handleGroupDragEnd = (e: Konva.KonvaEventObject<DragEvent>) => {
const node = e.target;
@@ -328,18 +344,10 @@ const ObjectRenderer = ({
});
};
const handleGroupDragMove = (e: Konva.KonvaEventObject<DragEvent>) => {
const node = e.target;
// به‌روزرسانی transformer در حین drag
if (transformerRef.current && isSelected) {
transformerRef.current.nodes([node]);
transformerRef.current.getLayer()?.batchDraw();
}
const handleGroupDragMove = (_e: Konva.KonvaEventObject<DragEvent>) => {
// Intentionally empty — see comment above.
};
// بررسی آیا object با mask گروه شده است
const isGroupedWithMask = obj.groupId && maskShape && maskShape.groupId === obj.groupId;
const handleGroupTransformEnd = () => {
if (!groupRef.current || !maskShape) return;
@@ -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);
@@ -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<EditorObject>) => {
// همیشه آخرین state را بخوان تا چند dragmove پشت‌سرهم قبل از رِندر React، دلتا درست بماند
const liveObjects = useEditorStore.getState().objects;
const obj = liveObjects.find((o) => o.id === id);
const handleObjectUpdate = useCallback((id: string, updates: Partial<EditorObject>) => {
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,
};
};
@@ -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<MouseEvent>) => {
const {
objects,
selectedObjectIds,
tool,
setTool,
setSelectedObjectId,
addToSelection,
removeFromSelection,
setSelectedTableId,
setSelectedCellId,
} = useEditorStore.getState();
const handleSelect = (id: string, _node: Konva.Node, e?: Konva.KonvaEventObject<MouseEvent>) => {
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<MouseEvent>) => {
const {
selectedObjectIds,
addToSelection,
removeFromSelection,
setSelectedTableId,
setSelectedObjectId,
setSelectedCellId,
} = useEditorStore.getState();
const handleCellClick = (tableId: string, cellId: string, e?: Konva.KonvaEventObject<MouseEvent>) => {
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,
};
};
@@ -132,7 +132,10 @@ const MaskSettings = ({ objectId }: MaskSettingsProps) => {
{object.maskId && (
<>
<div className="flex items-center justify-between mt-4 pt-3 border-t">
<span className="text-xs">نمایش فقط همپوشانی</span>
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium">معکوس کردن ماسک</span>
<span className="text-[10px] text-gray-400">نمایش خارج از محدوده ماسک</span>
</div>
<Switch
checked={object.maskInvert === false}
onCheckedChange={(checked) => handleToggleMaskInvert(!checked)}
@@ -153,7 +156,7 @@ const MaskSettings = ({ objectId }: MaskSettingsProps) => {
</>
) : (
<>
ماسک اعمال شد. {object.maskInvert === false ? "فقط قسمت‌های همپوشانی نمایش داده می‌شوند." : "قسمت‌های همپوشانی مخفی می‌شوند."}
ماسک اعمال شد. {object.maskInvert === false ? "قسمت‌های خارج از ماسک نمایش داده می‌شوند." : "فقط داخل محدوده ماسک نمایش داده می‌شود."}
<br />
💡 برای گروهبندی با ماسک، object را انتخاب کنید و دکمه "گروه‌بندی با ماسک" را بزنید.
</>
@@ -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<Konva.Image>(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;
@@ -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 & {
+3 -2
View File
@@ -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 = {
+50 -26
View File
@@ -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;