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
+1 -1
View File
@@ -56,7 +56,7 @@ const Editor: FC = () => {
) : null}
<LayersPanel isOpen={isLayersPanelOpen} setIsOpen={setIsLayersPanelOpen} />
<EditorCanvas catalogSize={data?.data?.size} />
<EditorSidebar />
<EditorSidebar catalogSize={data?.data?.size} />
</div>
)
}
+13 -3
View File
@@ -1,15 +1,23 @@
import Logo from "@/assets/images/logo.svg";
import { getPaperDimensions } from "@/config/paperSizes";
import { clx } from "@/helpers/utils";
import { useSharedStore } from "@/shared/store/sharedStore";
import { CloseSquare } from "iconsax-react";
import { useEditorStore, type ToolType } from "../store/editorStore";
import { useObjectHandlers } from "./canvas/useObjectHandlers";
import { ToolsBar, ObjectSettings, ToolInstructions } from "./sidebar";
const EditorSidebar = () => {
type EditorSidebarProps = {
catalogSize?: string;
};
const EditorSidebar = ({ catalogSize }: EditorSidebarProps) => {
const { openSidebar, setOpenSidebar } = useSharedStore();
const { tool, setTool, selectedObjectId, objects, updateObject, deleteObject } = useEditorStore();
const { tool, setTool, selectedObjectId, objects, deleteObject } = useEditorStore();
const { handleObjectUpdate } = useObjectHandlers();
const selectedObject = objects.find((obj) => obj.id === selectedObjectId);
const { width: pageWidth, height: pageHeight } = getPaperDimensions(catalogSize ?? "a4");
const handleToolClick = (selectedTool: ToolType) => {
setTool(selectedTool);
@@ -66,7 +74,9 @@ const EditorSidebar = () => {
{selectedObject ? (
<ObjectSettings
selectedObject={selectedObject}
onUpdate={updateObject}
pageWidth={pageWidth}
pageHeight={pageHeight}
onUpdate={handleObjectUpdate}
onDelete={deleteObject}
/>
) : (
@@ -6,6 +6,7 @@ import {
SizeSettings,
LinkSettings,
VideoSettings,
AlignmentSettings,
TransformSettings,
GridSettings,
MaskSettings,
@@ -14,11 +15,19 @@ import TextInstruction from "./instructions/TextInstruction";
type ObjectSettingsProps = {
selectedObject: EditorObject;
pageWidth: number;
pageHeight: number;
onUpdate: (id: string, updates: Partial<EditorObject>) => void;
onDelete: (id: string) => void;
};
const ObjectSettings = ({ selectedObject, onUpdate, onDelete }: ObjectSettingsProps) => {
const ObjectSettings = ({
selectedObject,
pageWidth,
pageHeight,
onUpdate,
onDelete,
}: ObjectSettingsProps) => {
return (
<div className="space-y-4">
<div className="flex items-center justify-between pb-4 border-b border-border">
@@ -69,6 +78,11 @@ const ObjectSettings = ({ selectedObject, onUpdate, onDelete }: ObjectSettingsPr
<GridSettings selectedObject={selectedObject} onUpdate={onUpdate} />
)}
<AlignmentSettings
pageWidth={pageWidth}
pageHeight={pageHeight}
onUpdate={onUpdate}
/>
<TransformSettings selectedObject={selectedObject} onUpdate={onUpdate} />
</div>
);
@@ -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";
+36 -4
View File
@@ -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;