cleaned editorstore
This commit is contained in:
@@ -0,0 +1,264 @@
|
|||||||
|
import type { EditorObject, Page, TableCell } from "./editorStore.types";
|
||||||
|
|
||||||
|
export const createTableCellId = (row: number, col: number) => `cell-${row}-${col}`;
|
||||||
|
|
||||||
|
const createUuid = (): string => {
|
||||||
|
if (typeof globalThis.crypto?.randomUUID === "function") {
|
||||||
|
return globalThis.crypto.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof globalThis.crypto?.getRandomValues === "function") {
|
||||||
|
const bytes = new Uint8Array(16);
|
||||||
|
globalThis.crypto.getRandomValues(bytes);
|
||||||
|
|
||||||
|
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||||
|
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||||
|
|
||||||
|
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"));
|
||||||
|
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10, 16).join("")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createScopedId = (prefix: string) => `${prefix}-${createUuid()}`;
|
||||||
|
|
||||||
|
export const isInvalidPageId = (id?: string) =>
|
||||||
|
!id || id === "page-undefined" || id.endsWith("-undefined");
|
||||||
|
|
||||||
|
export const createInitialCells = (
|
||||||
|
rows: number,
|
||||||
|
cols: number,
|
||||||
|
cellWidth: number,
|
||||||
|
cellHeight: number,
|
||||||
|
): Record<string, TableCell> => {
|
||||||
|
const cells: Record<string, TableCell> = {};
|
||||||
|
for (let row = 0; row < rows; row++) {
|
||||||
|
for (let col = 0; col < cols; col++) {
|
||||||
|
const cellId = createTableCellId(row, col);
|
||||||
|
cells[cellId] = {
|
||||||
|
id: cellId,
|
||||||
|
text: "",
|
||||||
|
background: "#f5f5f5",
|
||||||
|
width: cellWidth,
|
||||||
|
height: cellHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cells;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createInitialPage = (name: string): Page => ({
|
||||||
|
id: createScopedId("page"),
|
||||||
|
name,
|
||||||
|
objects: [],
|
||||||
|
guides: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const reorderByIds = (
|
||||||
|
items: EditorObject[],
|
||||||
|
draggedId: string,
|
||||||
|
targetId: string,
|
||||||
|
) => {
|
||||||
|
const fromIndex = items.findIndex((item) => item.id === draggedId);
|
||||||
|
const toIndex = items.findIndex((item) => item.id === targetId);
|
||||||
|
|
||||||
|
if (fromIndex === -1 || toIndex === -1) return items;
|
||||||
|
|
||||||
|
const nextItems = [...items];
|
||||||
|
const [movedItem] = nextItems.splice(fromIndex, 1);
|
||||||
|
nextItems.splice(toIndex, 0, movedItem);
|
||||||
|
return nextItems;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getObjectBounds = (obj: EditorObject) => {
|
||||||
|
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;
|
||||||
|
w = Math.max(textLength * fontSize * 0.6, 50);
|
||||||
|
}
|
||||||
|
if (!h) {
|
||||||
|
h = obj.fontSize || 24;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (obj.type === "rectangle" && obj.shapeType === "circle") {
|
||||||
|
const radius = w / 2;
|
||||||
|
const cx = obj.x || 0;
|
||||||
|
const cy = obj.y || 0;
|
||||||
|
return {
|
||||||
|
minX: cx - radius,
|
||||||
|
minY: cy - radius,
|
||||||
|
maxX: cx + radius,
|
||||||
|
maxY: cy + radius,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
obj.type === "rectangle" &&
|
||||||
|
(obj.shapeType === "triangle" || obj.shapeType === "abstract")
|
||||||
|
) {
|
||||||
|
const cx = (obj.x || 0) + w / 2;
|
||||||
|
const cy = (obj.y || 0) + h / 2;
|
||||||
|
const rot = ((obj.rotation || 0) * Math.PI) / 180;
|
||||||
|
|
||||||
|
const baseRadius = Math.min(w / 2, h / 2);
|
||||||
|
const actualRadius =
|
||||||
|
obj.shapeType === "abstract" ? baseRadius * 0.85 : baseRadius;
|
||||||
|
|
||||||
|
const corners = [
|
||||||
|
{ x: -actualRadius, y: -actualRadius },
|
||||||
|
{ x: actualRadius, y: -actualRadius },
|
||||||
|
{ x: actualRadius, y: actualRadius },
|
||||||
|
{ x: -actualRadius, y: actualRadius },
|
||||||
|
];
|
||||||
|
|
||||||
|
let minX = Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
let maxX = -Infinity;
|
||||||
|
let maxY = -Infinity;
|
||||||
|
|
||||||
|
corners.forEach((corner) => {
|
||||||
|
const rotatedX = corner.x * Math.cos(rot) - corner.y * Math.sin(rot);
|
||||||
|
const rotatedY = corner.x * Math.sin(rot) + corner.y * Math.cos(rot);
|
||||||
|
const finalX = cx + rotatedX;
|
||||||
|
const finalY = cy + rotatedY;
|
||||||
|
minX = Math.min(minX, finalX);
|
||||||
|
minY = Math.min(minY, finalY);
|
||||||
|
maxX = Math.max(maxX, finalX);
|
||||||
|
maxY = Math.max(maxY, finalY);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { minX, minY, maxX, maxY };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (obj.type === "arrow" || obj.type === "line") {
|
||||||
|
const startX = obj.x || 0;
|
||||||
|
const startY = obj.y || 0;
|
||||||
|
const endX = obj.width || 0;
|
||||||
|
const endY = obj.height || 0;
|
||||||
|
return {
|
||||||
|
minX: Math.min(startX, endX),
|
||||||
|
minY: Math.min(startY, endY),
|
||||||
|
maxX: Math.max(startX, endX),
|
||||||
|
maxY: Math.max(startY, endY),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const cx = (obj.x || 0) + w / 2;
|
||||||
|
const cy = (obj.y || 0) + h / 2;
|
||||||
|
const rot = ((obj.rotation || 0) * Math.PI) / 180;
|
||||||
|
|
||||||
|
const corners = [
|
||||||
|
{ x: -w / 2, y: -h / 2 },
|
||||||
|
{ x: w / 2, y: -h / 2 },
|
||||||
|
{ x: w / 2, y: h / 2 },
|
||||||
|
{ x: -w / 2, y: h / 2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
let minX = Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
let maxX = -Infinity;
|
||||||
|
let maxY = -Infinity;
|
||||||
|
|
||||||
|
corners.forEach((corner) => {
|
||||||
|
const rotatedX = corner.x * Math.cos(rot) - corner.y * Math.sin(rot);
|
||||||
|
const rotatedY = corner.x * Math.sin(rot) + corner.y * Math.cos(rot);
|
||||||
|
const finalX = cx + rotatedX;
|
||||||
|
const finalY = cy + rotatedY;
|
||||||
|
minX = Math.min(minX, finalX);
|
||||||
|
minY = Math.min(minY, finalY);
|
||||||
|
maxX = Math.max(maxX, finalX);
|
||||||
|
maxY = Math.max(maxY, finalY);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { minX, minY, maxX, maxY };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildGroupResult = (
|
||||||
|
objects: EditorObject[],
|
||||||
|
objectIds: string[],
|
||||||
|
nextGroupId?: string,
|
||||||
|
) => {
|
||||||
|
if (objectIds.length < 2) return null;
|
||||||
|
|
||||||
|
const allObjectIdsToGroup = new Set<string>(objectIds);
|
||||||
|
const oldGroupIdsToRemove = new Set<string>();
|
||||||
|
|
||||||
|
objectIds.forEach((id) => {
|
||||||
|
const obj = objects.find((o) => o.id === id);
|
||||||
|
if (obj?.type === "group") {
|
||||||
|
const oldGroupMembers = objects.filter((o) => o.groupId === id);
|
||||||
|
oldGroupMembers.forEach((member) => {
|
||||||
|
if (member.type !== "group") {
|
||||||
|
allObjectIdsToGroup.add(member.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
oldGroupIdsToRemove.add(id);
|
||||||
|
} else if (obj?.groupId) {
|
||||||
|
const oldGroupMembers = objects.filter((o) => o.groupId === obj.groupId);
|
||||||
|
oldGroupMembers.forEach((member) => {
|
||||||
|
if (member.type !== "group") {
|
||||||
|
allObjectIdsToGroup.add(member.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
oldGroupIdsToRemove.add(obj.groupId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (allObjectIdsToGroup.size < 2) return null;
|
||||||
|
|
||||||
|
const groupId = nextGroupId ?? `group-${crypto.randomUUID()}`;
|
||||||
|
const objectsToGroup = objects.filter(
|
||||||
|
(obj) => allObjectIdsToGroup.has(obj.id) && obj.type !== "group",
|
||||||
|
);
|
||||||
|
if (objectsToGroup.length === 0) return null;
|
||||||
|
|
||||||
|
let minX = Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
let maxX = -Infinity;
|
||||||
|
let maxY = -Infinity;
|
||||||
|
|
||||||
|
objectsToGroup.forEach((obj) => {
|
||||||
|
const bounds = getObjectBounds(obj);
|
||||||
|
minX = Math.min(minX, bounds.minX);
|
||||||
|
minY = Math.min(minY, bounds.minY);
|
||||||
|
maxX = Math.max(maxX, bounds.maxX);
|
||||||
|
maxY = Math.max(maxY, bounds.maxY);
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupObject: EditorObject = {
|
||||||
|
id: groupId,
|
||||||
|
type: "group",
|
||||||
|
x: minX,
|
||||||
|
y: minY,
|
||||||
|
width: maxX - minX,
|
||||||
|
height: maxY - minY,
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextObjects = objects
|
||||||
|
.map((obj) => {
|
||||||
|
if (allObjectIdsToGroup.has(obj.id) && obj.type !== "group") {
|
||||||
|
return { ...obj, groupId };
|
||||||
|
}
|
||||||
|
if (obj.groupId && oldGroupIdsToRemove.has(obj.groupId)) {
|
||||||
|
return { ...obj, groupId: undefined };
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
})
|
||||||
|
.filter((obj) => !oldGroupIdsToRemove.has(obj.id));
|
||||||
|
|
||||||
|
return {
|
||||||
|
groupId,
|
||||||
|
nextObjects: [...nextObjects, groupObject],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ungroupById = (objects: EditorObject[], groupId: string) =>
|
||||||
|
objects
|
||||||
|
.map((obj) => (obj.groupId === groupId ? { ...obj, groupId: undefined } : obj))
|
||||||
|
.filter((obj) => obj.id !== groupId);
|
||||||
@@ -1,85 +1,32 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import Konva from "konva";
|
import Konva from "konva";
|
||||||
import type { ShapeType } from "./shapeStore";
|
import {
|
||||||
|
buildGroupResult,
|
||||||
|
createInitialCells,
|
||||||
|
createInitialPage,
|
||||||
|
createScopedId,
|
||||||
|
createTableCellId,
|
||||||
|
isInvalidPageId,
|
||||||
|
reorderByIds,
|
||||||
|
ungroupById,
|
||||||
|
} from "./editorStore.helpers";
|
||||||
|
import type {
|
||||||
|
EditorObject,
|
||||||
|
Page,
|
||||||
|
PageGuide,
|
||||||
|
TableCell,
|
||||||
|
TableData,
|
||||||
|
ToolType,
|
||||||
|
} from "./editorStore.types";
|
||||||
|
|
||||||
export type ToolType =
|
export type {
|
||||||
| "select"
|
EditorObject,
|
||||||
| "rectangle"
|
Page,
|
||||||
| "text"
|
PageGuide,
|
||||||
| "image"
|
TableCell,
|
||||||
| "line"
|
TableData,
|
||||||
| "arrow"
|
ToolType,
|
||||||
| "sticker"
|
} from "./editorStore.types";
|
||||||
| "document"
|
|
||||||
| "video"
|
|
||||||
| "link"
|
|
||||||
| "grid"
|
|
||||||
| "group";
|
|
||||||
|
|
||||||
export type TableCell = {
|
|
||||||
id: string;
|
|
||||||
text: string;
|
|
||||||
background: string;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TableData = {
|
|
||||||
id: string;
|
|
||||||
rows: number;
|
|
||||||
cols: number;
|
|
||||||
cellWidth: number;
|
|
||||||
cellHeight: number;
|
|
||||||
cells: Record<string, TableCell>;
|
|
||||||
stroke?: string;
|
|
||||||
strokeWidth?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type EditorObject = {
|
|
||||||
id: string;
|
|
||||||
type: ToolType;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
fill?: string;
|
|
||||||
stroke?: string;
|
|
||||||
strokeWidth?: number;
|
|
||||||
text?: string;
|
|
||||||
fontSize?: number;
|
|
||||||
fontFamily?: string;
|
|
||||||
fontWeight?: string;
|
|
||||||
letterSpacing?: number;
|
|
||||||
wordSpacing?: number;
|
|
||||||
opacity?: number;
|
|
||||||
imageUrl?: string;
|
|
||||||
videoUrl?: string;
|
|
||||||
linkUrl?: string;
|
|
||||||
rotation?: number;
|
|
||||||
scaleX?: number;
|
|
||||||
scaleY?: number;
|
|
||||||
shapeType?: ShapeType;
|
|
||||||
tableData?: TableData;
|
|
||||||
visible?: boolean;
|
|
||||||
isMask?: boolean;
|
|
||||||
showMaskGuide?: boolean;
|
|
||||||
maskId?: string; // Reference to mask shape (DATA only, NO rendering logic)
|
|
||||||
maskInvert?: boolean; // اگر true: overlap مخفی میشود، اگر false: overlap نمایش داده میشود
|
|
||||||
groupId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Page = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
objects: EditorObject[];
|
|
||||||
guides: PageGuide[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PageGuide = {
|
|
||||||
id: string;
|
|
||||||
orientation: "vertical" | "horizontal";
|
|
||||||
position: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type EditorStoreType = {
|
type EditorStoreType = {
|
||||||
tool: ToolType;
|
tool: ToolType;
|
||||||
@@ -159,61 +106,6 @@ type EditorStoreType = {
|
|||||||
loadPages: (pages: Page[]) => void;
|
loadPages: (pages: Page[]) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const createTableCellId = (row: number, col: number) => `cell-${row}-${col}`;
|
|
||||||
|
|
||||||
const createUuid = (): string => {
|
|
||||||
if (typeof globalThis.crypto?.randomUUID === "function") {
|
|
||||||
return globalThis.crypto.randomUUID();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof globalThis.crypto?.getRandomValues === "function") {
|
|
||||||
const bytes = new Uint8Array(16);
|
|
||||||
globalThis.crypto.getRandomValues(bytes);
|
|
||||||
|
|
||||||
// RFC4122 v4 bits
|
|
||||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
||||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
||||||
|
|
||||||
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"));
|
|
||||||
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10, 16).join("")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const createScopedId = (prefix: string) => `${prefix}-${createUuid()}`;
|
|
||||||
const isInvalidPageId = (id?: string) =>
|
|
||||||
!id || id === "page-undefined" || id.endsWith("-undefined");
|
|
||||||
|
|
||||||
const createInitialCells = (
|
|
||||||
rows: number,
|
|
||||||
cols: number,
|
|
||||||
cellWidth: number,
|
|
||||||
cellHeight: number,
|
|
||||||
): Record<string, TableCell> => {
|
|
||||||
const cells: Record<string, TableCell> = {};
|
|
||||||
for (let row = 0; row < rows; row++) {
|
|
||||||
for (let col = 0; col < cols; col++) {
|
|
||||||
const cellId = createTableCellId(row, col);
|
|
||||||
cells[cellId] = {
|
|
||||||
id: cellId,
|
|
||||||
text: "",
|
|
||||||
background: "#f5f5f5",
|
|
||||||
width: cellWidth,
|
|
||||||
height: cellHeight,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cells;
|
|
||||||
};
|
|
||||||
|
|
||||||
const createInitialPage = (name: string): Page => ({
|
|
||||||
id: createScopedId("page"),
|
|
||||||
name,
|
|
||||||
objects: [],
|
|
||||||
guides: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const useEditorStore = create<EditorStoreType>((set, get) => {
|
export const useEditorStore = create<EditorStoreType>((set, get) => {
|
||||||
const initialPage = createInitialPage("جلد");
|
const initialPage = createInitialPage("جلد");
|
||||||
return {
|
return {
|
||||||
@@ -358,25 +250,16 @@ export const useEditorStore = create<EditorStoreType>((set, get) => {
|
|||||||
reorderObjects: (draggedId, targetId) => {
|
reorderObjects: (draggedId, targetId) => {
|
||||||
if (draggedId === targetId) return;
|
if (draggedId === targetId) return;
|
||||||
|
|
||||||
const reorder = (items: EditorObject[]) => {
|
|
||||||
const fromIndex = items.findIndex((item) => item.id === draggedId);
|
|
||||||
const toIndex = items.findIndex((item) => item.id === targetId);
|
|
||||||
|
|
||||||
if (fromIndex === -1 || toIndex === -1) return items;
|
|
||||||
|
|
||||||
const nextItems = [...items];
|
|
||||||
const [movedItem] = nextItems.splice(fromIndex, 1);
|
|
||||||
nextItems.splice(toIndex, 0, movedItem);
|
|
||||||
return nextItems;
|
|
||||||
};
|
|
||||||
|
|
||||||
const state = get();
|
const state = get();
|
||||||
if (state.currentPageId) {
|
if (state.currentPageId) {
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
objects: reorder(state.objects),
|
objects: reorderByIds(state.objects, draggedId, targetId),
|
||||||
pages: state.pages.map((page) =>
|
pages: state.pages.map((page) =>
|
||||||
page.id === state.currentPageId
|
page.id === state.currentPageId
|
||||||
? { ...page, objects: reorder(page.objects) }
|
? {
|
||||||
|
...page,
|
||||||
|
objects: reorderByIds(page.objects, draggedId, targetId),
|
||||||
|
}
|
||||||
: page,
|
: page,
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
@@ -384,7 +267,7 @@ export const useEditorStore = create<EditorStoreType>((set, get) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
objects: reorder(state.objects),
|
objects: reorderByIds(state.objects, draggedId, targetId),
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
selectedObjectId: null,
|
selectedObjectId: null,
|
||||||
@@ -774,239 +657,46 @@ export const useEditorStore = create<EditorStoreType>((set, get) => {
|
|||||||
},
|
},
|
||||||
groupObjects: (objectIds) => {
|
groupObjects: (objectIds) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
if (objectIds.length < 2) return;
|
const result = buildGroupResult(state.objects, objectIds);
|
||||||
|
if (!result) return;
|
||||||
// پیدا کردن همه آبجکتهایی که باید در گروه جدید باشند
|
|
||||||
// شامل آبجکتهای انتخاب شده + آبجکتهای داخل گروههای قبلی آنها
|
|
||||||
const allObjectIdsToGroup = new Set<string>(objectIds);
|
|
||||||
const oldGroupIdsToRemove = new Set<string>();
|
|
||||||
|
|
||||||
// بررسی اینکه آیا یکی از آبجکتهای انتخاب شده در یک گروه قبلی است
|
|
||||||
objectIds.forEach((id) => {
|
|
||||||
const obj = state.objects.find((o) => o.id === id);
|
|
||||||
if (obj?.type === "group") {
|
|
||||||
// اگر این خود یک گروه است، همه آبجکتهای آن را اضافه کن
|
|
||||||
const oldGroupMembers = state.objects.filter((o) => o.groupId === id);
|
|
||||||
oldGroupMembers.forEach((member) => {
|
|
||||||
if (member.type !== "group") {
|
|
||||||
allObjectIdsToGroup.add(member.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// گروه قدیمی را برای حذف علامت بزن
|
|
||||||
oldGroupIdsToRemove.add(id);
|
|
||||||
} else if (obj?.groupId) {
|
|
||||||
// اگر این آبجکت در یک گروه است، همه آبجکتهای آن گروه را اضافه کن
|
|
||||||
const oldGroupMembers = state.objects.filter(
|
|
||||||
(o) => o.groupId === obj.groupId,
|
|
||||||
);
|
|
||||||
oldGroupMembers.forEach((member) => {
|
|
||||||
if (member.type !== "group") {
|
|
||||||
allObjectIdsToGroup.add(member.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// گروه قدیمی را برای حذف علامت بزن
|
|
||||||
if (obj.groupId) {
|
|
||||||
oldGroupIdsToRemove.add(obj.groupId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// اگر فقط یک آبجکت باقی ماند، گروهبندی انجام نده
|
|
||||||
if (allObjectIdsToGroup.size < 2) return;
|
|
||||||
|
|
||||||
const groupId = `group-${crypto.randomUUID()}`;
|
|
||||||
const objectsToGroup = state.objects.filter(
|
|
||||||
(obj) => allObjectIdsToGroup.has(obj.id) && obj.type !== "group",
|
|
||||||
);
|
|
||||||
|
|
||||||
if (objectsToGroup.length === 0) return;
|
|
||||||
|
|
||||||
// محاسبه bounds گروه با در نظر گرفتن rotation
|
|
||||||
let minX = Infinity;
|
|
||||||
let minY = Infinity;
|
|
||||||
let maxX = -Infinity;
|
|
||||||
let maxY = -Infinity;
|
|
||||||
|
|
||||||
objectsToGroup.forEach((obj) => {
|
|
||||||
// برای متنهایی که width و height ندارند، از مقادیر پیشفرض استفاده کن
|
|
||||||
let w = obj.width || 0;
|
|
||||||
let h = obj.height || 0;
|
|
||||||
|
|
||||||
if (obj.type === "text") {
|
|
||||||
// اگر متن width نداره، از fontSize و طول متن برای تخمین استفاده کن
|
|
||||||
if (!w) {
|
|
||||||
const textLength = (obj.text || "").length;
|
|
||||||
const fontSize = obj.fontSize || 24;
|
|
||||||
w = Math.max(textLength * fontSize * 0.6, 50);
|
|
||||||
}
|
|
||||||
// اگر height نداره، از fontSize استفاده کن
|
|
||||||
if (!h) {
|
|
||||||
h = obj.fontSize || 24;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// برای دایره، x و y مرکز دایره است و bounding box همیشه یک مربع است
|
|
||||||
if (obj.type === "rectangle" && obj.shapeType === "circle") {
|
|
||||||
const radius = w / 2;
|
|
||||||
const cx = obj.x || 0;
|
|
||||||
const cy = obj.y || 0;
|
|
||||||
// برای دایره، bounding box همیشه یک مربع با ضلع برابر با قطر است
|
|
||||||
minX = Math.min(minX, cx - radius);
|
|
||||||
minY = Math.min(minY, cy - radius);
|
|
||||||
maxX = Math.max(maxX, cx + radius);
|
|
||||||
maxY = Math.max(maxY, cy + radius);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// برای مثلث و abstract، x و y در store گوشه بالا-چپ است اما در render مرکز استفاده میشود
|
|
||||||
if (
|
|
||||||
obj.type === "rectangle" &&
|
|
||||||
(obj.shapeType === "triangle" || obj.shapeType === "abstract")
|
|
||||||
) {
|
|
||||||
// محاسبه مرکز از گوشه بالا-چپ
|
|
||||||
const cx = (obj.x || 0) + w / 2;
|
|
||||||
const cy = (obj.y || 0) + h / 2;
|
|
||||||
const rot = ((obj.rotation || 0) * Math.PI) / 180;
|
|
||||||
|
|
||||||
// برای abstract، از radius واقعی استفاده کن (0.85 * baseRadius)
|
|
||||||
// برای triangle، از radius واقعی استفاده کن (min(width, height) / 2)
|
|
||||||
let actualRadius: number;
|
|
||||||
if (obj.shapeType === "abstract") {
|
|
||||||
const baseRadius = Math.min(w / 2, h / 2);
|
|
||||||
actualRadius = baseRadius * 0.85;
|
|
||||||
} else {
|
|
||||||
// triangle
|
|
||||||
actualRadius = Math.min(w / 2, h / 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// محاسبه چهار گوشه با استفاده از radius واقعی
|
|
||||||
const corners = [
|
|
||||||
{ x: -actualRadius, y: -actualRadius },
|
|
||||||
{ x: actualRadius, y: -actualRadius },
|
|
||||||
{ x: actualRadius, y: actualRadius },
|
|
||||||
{ x: -actualRadius, y: actualRadius },
|
|
||||||
];
|
|
||||||
|
|
||||||
corners.forEach((corner) => {
|
|
||||||
const rotatedX =
|
|
||||||
corner.x * Math.cos(rot) - corner.y * Math.sin(rot);
|
|
||||||
const rotatedY =
|
|
||||||
corner.x * Math.sin(rot) + corner.y * Math.cos(rot);
|
|
||||||
const finalX = cx + rotatedX;
|
|
||||||
const finalY = cy + rotatedY;
|
|
||||||
minX = Math.min(minX, finalX);
|
|
||||||
minY = Math.min(minY, finalY);
|
|
||||||
maxX = Math.max(maxX, finalX);
|
|
||||||
maxY = Math.max(maxY, finalY);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// برای arrow، x و y نقطه شروع است و width/height نقطه پایان است
|
|
||||||
if (obj.type === "arrow" || obj.type === "line") {
|
|
||||||
const startX = obj.x || 0;
|
|
||||||
const startY = obj.y || 0;
|
|
||||||
const endX = obj.width || 0;
|
|
||||||
const endY = obj.height || 0;
|
|
||||||
|
|
||||||
// برای arrow و line، bounding box از نقطه شروع تا نقطه پایان است
|
|
||||||
minX = Math.min(minX, startX, endX);
|
|
||||||
minY = Math.min(minY, startY, endY);
|
|
||||||
maxX = Math.max(maxX, startX, endX);
|
|
||||||
maxY = Math.max(maxY, startY, endY);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// برای سایر اشکال، x و y گوشه بالا-چپ است
|
|
||||||
const cx = (obj.x || 0) + w / 2;
|
|
||||||
const cy = (obj.y || 0) + h / 2;
|
|
||||||
const rot = ((obj.rotation || 0) * Math.PI) / 180;
|
|
||||||
|
|
||||||
// محاسبه چهار گوشه rectangle با rotation
|
|
||||||
const corners = [
|
|
||||||
{ x: -w / 2, y: -h / 2 },
|
|
||||||
{ x: w / 2, y: -h / 2 },
|
|
||||||
{ x: w / 2, y: h / 2 },
|
|
||||||
{ x: -w / 2, y: h / 2 },
|
|
||||||
];
|
|
||||||
|
|
||||||
corners.forEach((corner) => {
|
|
||||||
const rotatedX = corner.x * Math.cos(rot) - corner.y * Math.sin(rot);
|
|
||||||
const rotatedY = corner.x * Math.sin(rot) + corner.y * Math.cos(rot);
|
|
||||||
const finalX = cx + rotatedX;
|
|
||||||
const finalY = cy + rotatedY;
|
|
||||||
minX = Math.min(minX, finalX);
|
|
||||||
minY = Math.min(minY, finalY);
|
|
||||||
maxX = Math.max(maxX, finalX);
|
|
||||||
maxY = Math.max(maxY, finalY);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const groupWidth = maxX - minX;
|
|
||||||
const groupHeight = maxY - minY;
|
|
||||||
|
|
||||||
// ایجاد group object
|
|
||||||
const groupObject: EditorObject = {
|
|
||||||
id: groupId,
|
|
||||||
type: "group",
|
|
||||||
x: minX,
|
|
||||||
y: minY,
|
|
||||||
width: groupWidth,
|
|
||||||
height: groupHeight,
|
|
||||||
};
|
|
||||||
|
|
||||||
// اضافه کردن groupId به objectهای داخل گروه و حذف گروههای قدیمی
|
|
||||||
const updateObjects = (objects: EditorObject[]) =>
|
|
||||||
objects
|
|
||||||
.map((obj) => {
|
|
||||||
// اگر این آبجکت باید در گروه جدید باشد
|
|
||||||
if (allObjectIdsToGroup.has(obj.id) && obj.type !== "group") {
|
|
||||||
return { ...obj, groupId };
|
|
||||||
}
|
|
||||||
// اگر این آبجکت در یک گروه قدیمی است که باید حذف شود
|
|
||||||
if (obj.groupId && oldGroupIdsToRemove.has(obj.groupId)) {
|
|
||||||
return { ...obj, groupId: undefined };
|
|
||||||
}
|
|
||||||
return obj;
|
|
||||||
})
|
|
||||||
// حذف گروههای قدیمی
|
|
||||||
.filter((obj) => !oldGroupIdsToRemove.has(obj.id));
|
|
||||||
|
|
||||||
if (state.currentPageId) {
|
if (state.currentPageId) {
|
||||||
|
const pageToGroup = state.pages.find((p) => p.id === state.currentPageId);
|
||||||
|
if (!pageToGroup) return;
|
||||||
|
const pageResult = buildGroupResult(
|
||||||
|
pageToGroup.objects,
|
||||||
|
objectIds,
|
||||||
|
result.groupId,
|
||||||
|
);
|
||||||
|
if (!pageResult) return;
|
||||||
|
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
objects: [...updateObjects(state.objects), groupObject],
|
objects: result.nextObjects,
|
||||||
pages: state.pages.map((p) =>
|
pages: state.pages.map((p) =>
|
||||||
p.id === state.currentPageId
|
p.id === state.currentPageId
|
||||||
? { ...p, objects: [...updateObjects(p.objects), groupObject] }
|
? { ...p, objects: pageResult.nextObjects }
|
||||||
: p,
|
: p,
|
||||||
),
|
),
|
||||||
selectedObjectId: groupId,
|
selectedObjectId: result.groupId,
|
||||||
selectedObjectIds: [groupId],
|
selectedObjectIds: [result.groupId],
|
||||||
}));
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
set((state) => ({
|
set(() => ({
|
||||||
objects: [...updateObjects(state.objects), groupObject],
|
objects: result.nextObjects,
|
||||||
selectedObjectId: groupId,
|
selectedObjectId: result.groupId,
|
||||||
selectedObjectIds: [groupId],
|
selectedObjectIds: [result.groupId],
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
ungroupObjects: (groupId) => {
|
ungroupObjects: (groupId) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
// حذف groupId از objectهای داخل گروه و حذف group object
|
|
||||||
const updateObjects = (objects: EditorObject[]) =>
|
|
||||||
objects
|
|
||||||
.map((obj) =>
|
|
||||||
obj.groupId === groupId ? { ...obj, groupId: undefined } : obj,
|
|
||||||
)
|
|
||||||
.filter((obj) => obj.id !== groupId); // حذف group object
|
|
||||||
|
|
||||||
if (state.currentPageId) {
|
if (state.currentPageId) {
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
objects: updateObjects(state.objects),
|
objects: ungroupById(state.objects, groupId),
|
||||||
pages: state.pages.map((p) =>
|
pages: state.pages.map((p) =>
|
||||||
p.id === state.currentPageId
|
p.id === state.currentPageId
|
||||||
? { ...p, objects: updateObjects(p.objects) }
|
? { ...p, objects: ungroupById(p.objects, groupId) }
|
||||||
: p,
|
: p,
|
||||||
),
|
),
|
||||||
selectedObjectId: null,
|
selectedObjectId: null,
|
||||||
@@ -1015,7 +705,7 @@ export const useEditorStore = create<EditorStoreType>((set, get) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
objects: updateObjects(state.objects),
|
objects: ungroupById(state.objects, groupId),
|
||||||
selectedObjectId: null,
|
selectedObjectId: null,
|
||||||
selectedObjectIds: [],
|
selectedObjectIds: [],
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type { ShapeType } from "./shapeStore";
|
||||||
|
|
||||||
|
export type ToolType =
|
||||||
|
| "select"
|
||||||
|
| "rectangle"
|
||||||
|
| "text"
|
||||||
|
| "image"
|
||||||
|
| "line"
|
||||||
|
| "arrow"
|
||||||
|
| "sticker"
|
||||||
|
| "document"
|
||||||
|
| "video"
|
||||||
|
| "link"
|
||||||
|
| "grid"
|
||||||
|
| "group";
|
||||||
|
|
||||||
|
export type TableCell = {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
background: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TableData = {
|
||||||
|
id: string;
|
||||||
|
rows: number;
|
||||||
|
cols: number;
|
||||||
|
cellWidth: number;
|
||||||
|
cellHeight: number;
|
||||||
|
cells: Record<string, TableCell>;
|
||||||
|
stroke?: string;
|
||||||
|
strokeWidth?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EditorObject = {
|
||||||
|
id: string;
|
||||||
|
type: ToolType;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
fill?: string;
|
||||||
|
stroke?: string;
|
||||||
|
strokeWidth?: number;
|
||||||
|
text?: string;
|
||||||
|
fontSize?: number;
|
||||||
|
fontFamily?: string;
|
||||||
|
fontWeight?: string;
|
||||||
|
letterSpacing?: number;
|
||||||
|
wordSpacing?: number;
|
||||||
|
opacity?: number;
|
||||||
|
imageUrl?: string;
|
||||||
|
videoUrl?: string;
|
||||||
|
linkUrl?: string;
|
||||||
|
rotation?: number;
|
||||||
|
scaleX?: number;
|
||||||
|
scaleY?: number;
|
||||||
|
shapeType?: ShapeType;
|
||||||
|
tableData?: TableData;
|
||||||
|
visible?: boolean;
|
||||||
|
isMask?: boolean;
|
||||||
|
showMaskGuide?: boolean;
|
||||||
|
maskId?: string;
|
||||||
|
maskInvert?: boolean;
|
||||||
|
groupId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PageGuide = {
|
||||||
|
id: string;
|
||||||
|
orientation: "vertical" | "horizontal";
|
||||||
|
position: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Page = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
objects: EditorObject[];
|
||||||
|
guides: PageGuide[];
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user