@@ -69,6 +78,11 @@ const ObjectSettings = ({ selectedObject, onUpdate, onDelete }: ObjectSettingsPr
)}
+
);
diff --git a/src/pages/editor/components/sidebar/settings/AlignmentSettings.tsx b/src/pages/editor/components/sidebar/settings/AlignmentSettings.tsx
new file mode 100644
index 0000000..734f44a
--- /dev/null
+++ b/src/pages/editor/components/sidebar/settings/AlignmentSettings.tsx
@@ -0,0 +1,115 @@
+
+import {
+ AlignBottom,
+ AlignHorizontally,
+ AlignLeft,
+ AlignRight,
+ AlignTop,
+ AlignVertically,
+} from "iconsax-react";
+import { useEditorStore, type EditorObject } from "../../../store/editorStore";
+import type { AlignKind } from "./alignmentUtils";
+import {
+ deltasForAlign,
+ pageBounds,
+ unionBoundsOfAlignTargets,
+ computeGroupFrameUpdate,
+ updatesFromDeltas,
+} from "./alignmentUtils";
+
+type AlignmentSettingsProps = {
+ pageWidth: number;
+ pageHeight: number;
+ onUpdate: (id: string, updates: Partial
) => void;
+};
+
+
+
+const alignIconProps = { size: 24, 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) => {
+ const { objects: objs, selectedObjectIds: ids, layerRef } = useEditorStore.getState();
+ const layer = layerRef?.current ?? null;
+
+ const initial = new Map();
+ for (const id of ids) {
+ const o = objs.find((x) => x.id === id);
+ if (o && o.visible !== false) initial.set(id, { ...o });
+ }
+ if (initial.size === 0) return;
+
+ const initialList = [...initial.values()];
+ const useSelectionBounds = ids.length === 2;
+ const target =
+ useSelectionBounds && initialList.length === 2
+ ? unionBoundsOfAlignTargets(initialList, objs, layer)
+ : pageBounds(pageWidth, pageHeight);
+
+ if (!target) return;
+
+ const alignedGroupIds = new Set();
+
+ for (const id of initial.keys()) {
+ const obj = initial.get(id)!;
+ const { dx, dy } = deltasForAlign(obj, kind, target, objs, layer);
+ const updates = updatesFromDeltas(obj, dx, dy);
+ if (Object.keys(updates).length === 0) continue;
+ onUpdate(id, updates);
+ if (obj.type === "group") {
+ alignedGroupIds.add(id);
+ }
+ }
+
+ const { updateObject } = useEditorStore.getState();
+ const afterObjects = useEditorStore.getState().objects;
+ for (const gid of alignedGroupIds) {
+ const patch = computeGroupFrameUpdate(gid, afterObjects, layer);
+ if (patch) updateObject(gid, patch);
+ }
+ };
+
+ // const hint = alignToSelection ? "نسبت به دو شی انتخابشده" : "نسبت به صفحه";
+
+ return (
+
+
+
ابزار جابهجایی
+ {/*
{hint}
*/}
+
+
+
+
افقی
+
+
applyAlign("right")} {...alignIconProps} />
+ applyAlign("centerH")} {...alignIconProps} />
+ applyAlign("left")} {...alignIconProps} />
+
+
+
+
+
عمودی
+
+
+
applyAlign("top")} {...alignIconProps} />
+
+ applyAlign("centerV")} {...alignIconProps} />
+
+ applyAlign("bottom")} {...alignIconProps} />
+
+
+
+
+ );
+};
+
+export default AlignmentSettings;
\ No newline at end of file
diff --git a/src/pages/editor/components/sidebar/settings/alignmentUtils.ts b/src/pages/editor/components/sidebar/settings/alignmentUtils.ts
new file mode 100644
index 0000000..d6cf1e0
--- /dev/null
+++ b/src/pages/editor/components/sidebar/settings/alignmentUtils.ts
@@ -0,0 +1,252 @@
+import Konva from "konva";
+import type { EditorObject } from "../../../store/editorStore";
+import { getObjectBounds } from "../../../store/editorStore.helpers";
+
+export type AlignKind =
+ | "left"
+ | "centerH"
+ | "right"
+ | "top"
+ | "centerV"
+ | "bottom";
+
+export type BoundsRect = {
+ minX: number;
+ minY: number;
+ maxX: number;
+ maxY: number;
+};
+
+/** مرز آمار واقعی روی لایه (فونت RTL، چرخش، mask، …)؛ همنس با مختصات ذخیرهشده در استور */
+export function boundsFromKonvaLayer(
+ objId: string,
+ layer: Konva.Layer | null | undefined,
+): BoundsRect | null {
+ if (!layer) return null;
+ const node =
+ layer.findOne(`#${objId}`) ?? layer.findOne(`#masked-${objId}`);
+ if (!node) return null;
+
+ try {
+ const r = node.getClientRect({
+ relativeTo: layer,
+ skipStroke: false,
+ skipShadow: true,
+ });
+ return {
+ minX: r.x,
+ minY: r.y,
+ maxX: r.x + r.width,
+ maxY: r.y + r.height,
+ };
+ } catch {
+ return null;
+ }
+}
+
+export function unionKonvaBoundsOfGroupMembers(
+ groupId: string,
+ objects: EditorObject[],
+ layer: Konva.Layer | null | undefined,
+): BoundsRect | null {
+ const members = objects.filter(
+ (o) =>
+ o.groupId === groupId &&
+ o.type !== "group" &&
+ o.visible !== false,
+ );
+ if (members.length === 0) return null;
+
+ let minX = Infinity;
+ let minY = Infinity;
+ let maxX = -Infinity;
+ let maxY = -Infinity;
+ let any = false;
+
+ for (const m of members) {
+ const b = boundsFromKonvaLayer(m.id, layer) ?? getObjectBounds(m);
+ any = true;
+ minX = Math.min(minX, b.minX);
+ minY = Math.min(minY, b.minY);
+ maxX = Math.max(maxX, b.maxX);
+ maxY = Math.max(maxY, b.maxY);
+ }
+
+ return any ? { minX, minY, maxX, maxY } : null;
+}
+
+export const pageBounds = (width: number, height: number): BoundsRect => ({
+ minX: 0,
+ minY: 0,
+ maxX: width,
+ maxY: height,
+});
+
+/** مرز واقعی اعضای یک گروه (نه مستطیل ذخیرهشدهٔ شیء group که ممکن است قدیمی باشد) */
+export function unionBoundsOfGroupMembers(
+ groupId: string,
+ objects: EditorObject[],
+): BoundsRect | null {
+ const members = objects.filter(
+ (o) =>
+ o.groupId === groupId &&
+ o.type !== "group" &&
+ o.visible !== false,
+ );
+ return unionBounds(members);
+}
+
+/**
+ * مرزی که برای همترازی استفاده میشود؛ برای گروه از اتحاد اعضا تا با لبهٔ صفحه همپوشانی دقیق باشد.
+ */
+export function boundsForAlign(
+ obj: EditorObject,
+ objects: EditorObject[],
+ layer?: Konva.Layer | null,
+): BoundsRect {
+ if (layer) {
+ if (obj.type === "group") {
+ const k = unionKonvaBoundsOfGroupMembers(obj.id, objects, layer);
+ if (k) return k;
+ } else {
+ const k = boundsFromKonvaLayer(obj.id, layer);
+ if (k) return k;
+ }
+ }
+
+ if (obj.type !== "group") {
+ return getObjectBounds(obj);
+ }
+ const fromMembers = unionBoundsOfGroupMembers(obj.id, objects);
+ return fromMembers ?? getObjectBounds(obj);
+}
+
+export function unionBounds(objects: EditorObject[]): BoundsRect | null {
+ let minX = Infinity;
+ let minY = Infinity;
+ let maxX = -Infinity;
+ let maxY = -Infinity;
+ let any = false;
+
+ for (const obj of objects) {
+ if (obj.visible === false) continue;
+ const b = getObjectBounds(obj);
+ any = true;
+ minX = Math.min(minX, b.minX);
+ minY = Math.min(minY, b.minY);
+ maxX = Math.max(maxX, b.maxX);
+ maxY = Math.max(maxY, b.maxY);
+ }
+
+ return any ? { minX, minY, maxX, maxY } : null;
+}
+
+/** اتحاد مرز همترازیِ چند هدف (گروهها با اعضایشان) */
+export function unionBoundsOfAlignTargets(
+ targets: EditorObject[],
+ allObjects: EditorObject[],
+ layer?: Konva.Layer | null,
+): BoundsRect | null {
+ let minX = Infinity;
+ let minY = Infinity;
+ let maxX = -Infinity;
+ let maxY = -Infinity;
+ let any = false;
+
+ for (const obj of targets) {
+ if (obj.visible === false) continue;
+ const b = boundsForAlign(obj, allObjects, layer);
+ any = true;
+ minX = Math.min(minX, b.minX);
+ minY = Math.min(minY, b.minY);
+ maxX = Math.max(maxX, b.maxX);
+ maxY = Math.max(maxY, b.maxY);
+ }
+
+ return any ? { minX, minY, maxX, maxY } : null;
+}
+
+export function deltasForAlign(
+ obj: EditorObject,
+ kind: AlignKind,
+ target: BoundsRect,
+ objects: EditorObject[],
+ layer?: Konva.Layer | null,
+): { dx: number; dy: number } {
+ const b = boundsForAlign(obj, objects, layer);
+ const midX = (b.minX + b.maxX) / 2;
+ const midY = (b.minY + b.maxY) / 2;
+ const tMidX = (target.minX + target.maxX) / 2;
+ const tMidY = (target.minY + target.maxY) / 2;
+
+ let dx = 0;
+ let dy = 0;
+
+ switch (kind) {
+ case "left":
+ dx = target.minX - b.minX;
+ break;
+ case "centerH":
+ dx = tMidX - midX;
+ break;
+ case "right":
+ dx = target.maxX - b.maxX;
+ break;
+ case "top":
+ dy = target.minY - b.minY;
+ break;
+ case "centerV":
+ dy = tMidY - midY;
+ break;
+ case "bottom":
+ dy = target.maxY - b.maxY;
+ break;
+ default:
+ break;
+ }
+
+ return {
+ dx: kind === "left" || kind === "centerH" || kind === "right" ? Math.round(dx) : 0,
+ dy: kind === "top" || kind === "centerV" || kind === "bottom" ? Math.round(dy) : 0,
+ };
+}
+
+/** قاب گروه را مطابق اتحاد اعضا بدون جابجاییٔ اعضا تنظیم میکند */
+export function computeGroupFrameUpdate(
+ groupId: string,
+ objects: EditorObject[],
+ layer?: Konva.Layer | null,
+): Partial | null {
+ const u =
+ (layer && unionKonvaBoundsOfGroupMembers(groupId, objects, layer)) ||
+ unionBoundsOfGroupMembers(groupId, objects);
+ if (!u) return null;
+ return {
+ x: u.minX,
+ y: u.minY,
+ width: u.maxX - u.minX,
+ height: u.maxY - u.minY,
+ };
+}
+
+export function updatesFromDeltas(
+ obj: EditorObject,
+ dx: number,
+ dy: number,
+): Partial {
+ if (dx === 0 && dy === 0) return {};
+
+ if (obj.type === "line" || obj.type === "arrow") {
+ return {
+ x: (obj.x ?? 0) + dx,
+ y: (obj.y ?? 0) + dy,
+ width: (obj.width ?? 0) + dx,
+ height: (obj.height ?? 0) + dy,
+ };
+ }
+
+ return {
+ x: (obj.x ?? 0) + dx,
+ y: (obj.y ?? 0) + dy,
+ };
+}
diff --git a/src/pages/editor/components/sidebar/settings/index.ts b/src/pages/editor/components/sidebar/settings/index.ts
index ed74a5d..cd7c37a 100644
--- a/src/pages/editor/components/sidebar/settings/index.ts
+++ b/src/pages/editor/components/sidebar/settings/index.ts
@@ -4,6 +4,7 @@ export { default as LineSettings } from "./LineSettings";
export { default as SizeSettings } from "./SizeSettings";
export { default as LinkSettings } from "./LinkSettings";
export { default as VideoSettings } from "./VideoSettings";
+export { default as AlignmentSettings } from "./AlignmentSettings";
export { default as TransformSettings } from "./TransformSettings";
export { default as GridSettings } from "./GridSettings";
export { default as MaskSettings } from "./MaskSettings";
diff --git a/src/pages/editor/store/editorStore.helpers.ts b/src/pages/editor/store/editorStore.helpers.ts
index f812821..6f54ea6 100644
--- a/src/pages/editor/store/editorStore.helpers.ts
+++ b/src/pages/editor/store/editorStore.helpers.ts
@@ -71,11 +71,11 @@ export const reorderByIds = (
return nextItems;
};
-const getObjectBounds = (obj: EditorObject) => {
- let w = obj.width || 0;
- let h = obj.height || 0;
-
+export const getObjectBounds = (obj: EditorObject) => {
+ // TextShape: متن با align="right" در Konva؛ گوشهٔ بالا-راست جعبه در (x,y)، نقاط بدنه بهسمت چپ است.
if (obj.type === "text") {
+ let w = obj.width || 0;
+ let h = obj.height || 0;
if (!w) {
const textLength = (obj.text || "").length;
const fontSize = obj.fontSize || 24;
@@ -84,8 +84,40 @@ const getObjectBounds = (obj: EditorObject) => {
if (!h) {
h = obj.fontSize || 24;
}
+
+ const px = obj.x ?? 0;
+ const py = obj.y ?? 0;
+ const rot = ((obj.rotation || 0) * Math.PI) / 180;
+
+ const cornersLocal = [
+ { x: -w, y: 0 },
+ { x: 0, y: 0 },
+ { x: 0, y: h },
+ { x: -w, y: h },
+ ];
+
+ let minX = Infinity;
+ let minY = Infinity;
+ let maxX = -Infinity;
+ let maxY = -Infinity;
+
+ cornersLocal.forEach(({ x: lx, y: ly }) => {
+ const rx = lx * Math.cos(rot) - ly * Math.sin(rot);
+ const ry = lx * Math.sin(rot) + ly * Math.cos(rot);
+ const wx = px + rx;
+ const wy = py + ry;
+ minX = Math.min(minX, wx);
+ minY = Math.min(minY, wy);
+ maxX = Math.max(maxX, wx);
+ maxY = Math.max(maxY, wy);
+ });
+
+ return { minX, minY, maxX, maxY };
}
+ let w = obj.width || 0;
+ let h = obj.height || 0;
+
if (obj.type === "rectangle" && obj.shapeType === "circle") {
const radius = w / 2;
const cx = obj.x || 0;