alignment setting
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -71,11 +71,11 @@ export const reorderByIds = (
|
||||
return nextItems;
|
||||
};
|
||||
|
||||
const getObjectBounds = (obj: EditorObject) => {
|
||||
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 (obj.type === "text") {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user