Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b13a46ec65 | |||
| e93ab38b05 | |||
| 052b737653 |
@@ -67,6 +67,14 @@ export const useUpdateCatalog = () => {
|
||||
mutationFn: api.updateCatalog,
|
||||
});
|
||||
|
||||
// Ref to always hold the latest mutation without adding it to useCallback/useEffect deps.
|
||||
// React Query's useMutation returns a new object reference on every render, so
|
||||
// closing over `mutation` directly would cause scheduleUpdate and the cleanup effect
|
||||
// to be recreated/re-run on every render — firing mutation.mutate on each re-render
|
||||
// during drag (potentially 100+ times).
|
||||
const mutationRef = useRef(mutation);
|
||||
mutationRef.current = mutation;
|
||||
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingParamsRef = useRef<
|
||||
Parameters<typeof api.updateCatalog>[0] | null
|
||||
@@ -96,11 +104,11 @@ export const useUpdateCatalog = () => {
|
||||
pendingParamsRef.current = null;
|
||||
setIsScheduledSaving(false);
|
||||
if (pendingParams) {
|
||||
mutation.mutate(pendingParams);
|
||||
mutationRef.current.mutate(pendingParams);
|
||||
}
|
||||
}, delayMs);
|
||||
},
|
||||
[mutation],
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
@@ -112,12 +120,12 @@ export const useUpdateCatalog = () => {
|
||||
// اگر کاربر قبل از پایان debounce از ادیتور خارج شد،
|
||||
// آخرین تغییرات معلق را همان لحظه ذخیره کن.
|
||||
if (pendingParamsRef.current) {
|
||||
mutation.mutate(pendingParamsRef.current);
|
||||
mutationRef.current.mutate(pendingParamsRef.current);
|
||||
pendingParamsRef.current = null;
|
||||
}
|
||||
setIsScheduledSaving(false);
|
||||
},
|
||||
[mutation],
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -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 = (
|
||||
@@ -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<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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
AlignBottom,
|
||||
AlignHorizontally,
|
||||
@@ -23,20 +23,40 @@ type AlignmentSettingsProps = {
|
||||
onUpdate: (id: string, updates: Partial<EditorObject>) => void;
|
||||
};
|
||||
|
||||
type AlignButtonGroupProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
type AlignButtonProps = {
|
||||
title: string;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const alignIconProps = { size: 24, color: "currentColor" as const, variant: "Linear" as const };
|
||||
const AlignButtonGroup = ({ children }: AlignButtonGroupProps) => (
|
||||
<div className="flex flex-1 overflow-hidden rounded-lg border border-[#E5E5E5] bg-[#F2F2F2] divide-x divide-[#E5E5E5]">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const AlignButton = ({ title, onClick, children }: AlignButtonProps) => (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
onClick={onClick}
|
||||
className="flex flex-1 items-center justify-center py-2.5 text-[#333333] transition-colors hover:bg-black/4 active:bg-black/8"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
const alignIconProps = { size: 20, color: "currentColor" as const, variant: "Linear" as const };
|
||||
|
||||
const AlignmentSettings = ({
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
onUpdate,
|
||||
}: AlignmentSettingsProps) => {
|
||||
// const selectedObjectIds = useEditorStore((s) => s.selectedObjectIds);
|
||||
|
||||
/** دقیقاً دو شیٔ مجزا (هنوز یک گروه نشدهاند) → مرز همان دو شی؛ یک شی یا گروه یا بیش از دو تا → صفحه */
|
||||
// const alignToSelection = selectedObjectIds.length === 2;
|
||||
|
||||
const applyAlign = (kind: AlignKind) => {
|
||||
useEditorStore.getState().commitObjectHistoryBeforeChange();
|
||||
const { objects: objs, selectedObjectIds: ids, layerRef } = useEditorStore.getState();
|
||||
@@ -79,38 +99,37 @@ const AlignmentSettings = ({
|
||||
}
|
||||
};
|
||||
|
||||
// const hint = alignToSelection ? "نسبت به دو شی انتخابشده" : "نسبت به صفحه";
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-foreground">ابزار جابهجایی</h4>
|
||||
{/* <p className="mt-1 text-xs text-muted-foreground">{hint}</p> */}
|
||||
</div>
|
||||
<h4 className="text-[13px] text-[#7A7A7A]">ترازبندی</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">افقی</p>
|
||||
<div className="flex gap-12 items-center mt-4">
|
||||
<AlignRight onClick={() => applyAlign("right")} {...alignIconProps} />
|
||||
<AlignHorizontally onClick={() => applyAlign("centerH")} {...alignIconProps} />
|
||||
<AlignLeft onClick={() => applyAlign("left")} {...alignIconProps} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<AlignButtonGroup>
|
||||
<AlignButton title="تراز راست" onClick={() => applyAlign("right")}>
|
||||
<AlignRight {...alignIconProps} />
|
||||
</AlignButton>
|
||||
<AlignButton title="تراز افقی وسط" onClick={() => applyAlign("centerH")}>
|
||||
<AlignHorizontally {...alignIconProps} />
|
||||
</AlignButton>
|
||||
<AlignButton title="تراز چپ" onClick={() => applyAlign("left")}>
|
||||
<AlignLeft {...alignIconProps} />
|
||||
</AlignButton>
|
||||
</AlignButtonGroup>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground mt-4">عمودی</p>
|
||||
<div className="flex gap-12 items-center mt-4">
|
||||
|
||||
<AlignTop onClick={() => applyAlign("top")} {...alignIconProps} />
|
||||
|
||||
<AlignVertically onClick={() => applyAlign("centerV")} {...alignIconProps} />
|
||||
|
||||
<AlignBottom onClick={() => applyAlign("bottom")} {...alignIconProps} />
|
||||
|
||||
</div>
|
||||
<AlignButtonGroup>
|
||||
<AlignButton title="تراز بالا" onClick={() => applyAlign("top")}>
|
||||
<AlignTop {...alignIconProps} />
|
||||
</AlignButton>
|
||||
<AlignButton title="تراز عمودی وسط" onClick={() => applyAlign("centerV")}>
|
||||
<AlignVertically {...alignIconProps} />
|
||||
</AlignButton>
|
||||
<AlignButton title="تراز پایین" onClick={() => applyAlign("bottom")}>
|
||||
<AlignBottom {...alignIconProps} />
|
||||
</AlignButton>
|
||||
</AlignButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlignmentSettings;
|
||||
export default AlignmentSettings;
|
||||
|
||||
@@ -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 را انتخاب کنید و دکمه "گروهبندی با ماسک" را بزنید.
|
||||
</>
|
||||
|
||||
@@ -64,11 +64,7 @@ const AbstractShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape
|
||||
}
|
||||
}}
|
||||
onDragMove={(e) => {
|
||||
const node = e.target;
|
||||
onUpdate(obj.id, {
|
||||
x: node.x() - (obj.width || 100) / 2,
|
||||
y: node.y() - (obj.height || 100) / 2,
|
||||
});
|
||||
e.target.getLayer()?.batchDraw();
|
||||
}}
|
||||
onDragEnd={(e) => {
|
||||
const node = e.target;
|
||||
|
||||
@@ -53,11 +53,7 @@ const CircleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapePr
|
||||
}
|
||||
}}
|
||||
onDragMove={(e) => {
|
||||
const node = e.target;
|
||||
onUpdate(obj.id, {
|
||||
x: node.x(),
|
||||
y: node.y(),
|
||||
});
|
||||
e.target.getLayer()?.batchDraw();
|
||||
}}
|
||||
onDragEnd={(e) => {
|
||||
const node = e.target;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -60,11 +60,7 @@ const TriangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape
|
||||
}
|
||||
}}
|
||||
onDragMove={(e) => {
|
||||
const node = e.target;
|
||||
onUpdate(obj.id, {
|
||||
x: node.x() - (obj.width || 100) / 2,
|
||||
y: node.y() - (obj.height || 100) / 2,
|
||||
});
|
||||
e.target.getLayer()?.batchDraw();
|
||||
}}
|
||||
onDragEnd={(e) => {
|
||||
const node = e.target;
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user