From acb2f33b673419377e2a4d2a5fbdeba4142e2271 Mon Sep 17 00:00:00 2001 From: hamid zarghami Date: Sat, 2 May 2026 16:14:34 +0330 Subject: [PATCH] alignment setting --- src/pages/editor/Editor.tsx | 2 +- src/pages/editor/components/EditorSidebar.tsx | 16 +- .../components/sidebar/ObjectSettings.tsx | 16 +- .../sidebar/settings/AlignmentSettings.tsx | 115 ++++++++ .../sidebar/settings/alignmentUtils.ts | 252 ++++++++++++++++++ .../components/sidebar/settings/index.ts | 1 + src/pages/editor/store/editorStore.helpers.ts | 40 ++- 7 files changed, 433 insertions(+), 9 deletions(-) create mode 100644 src/pages/editor/components/sidebar/settings/AlignmentSettings.tsx create mode 100644 src/pages/editor/components/sidebar/settings/alignmentUtils.ts diff --git a/src/pages/editor/Editor.tsx b/src/pages/editor/Editor.tsx index 839a881..dbf79e9 100644 --- a/src/pages/editor/Editor.tsx +++ b/src/pages/editor/Editor.tsx @@ -56,7 +56,7 @@ const Editor: FC = () => { ) : null} - + ) } diff --git a/src/pages/editor/components/EditorSidebar.tsx b/src/pages/editor/components/EditorSidebar.tsx index 1bd70b4..74dcd65 100644 --- a/src/pages/editor/components/EditorSidebar.tsx +++ b/src/pages/editor/components/EditorSidebar.tsx @@ -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 ? ( ) : ( diff --git a/src/pages/editor/components/sidebar/ObjectSettings.tsx b/src/pages/editor/components/sidebar/ObjectSettings.tsx index c7ab1e5..d5ee651 100644 --- a/src/pages/editor/components/sidebar/ObjectSettings.tsx +++ b/src/pages/editor/components/sidebar/ObjectSettings.tsx @@ -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) => void; onDelete: (id: string) => void; }; -const ObjectSettings = ({ selectedObject, onUpdate, onDelete }: ObjectSettingsProps) => { +const ObjectSettings = ({ + selectedObject, + pageWidth, + pageHeight, + onUpdate, + onDelete, +}: ObjectSettingsProps) => { return (
@@ -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;