alignment setting

This commit is contained in:
hamid zarghami
2026-05-02 16:14:34 +03:30
parent 47309c30dc
commit acb2f33b67
7 changed files with 433 additions and 9 deletions
@@ -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<EditorObject>) => 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<string, EditorObject>();
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<string>();
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 (
<div className="space-y-3 border-t border-border pt-4">
<div>
<h4 className="text-sm font-bold text-foreground">ابزار جابهجایی</h4>
{/* <p className="mt-1 text-xs text-muted-foreground">{hint}</p> */}
</div>
<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="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>
</div>
</div>
);
};
export default AlignmentSettings;
@@ -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<EditorObject> | 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<EditorObject> {
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,
};
}
@@ -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";