cleaned editorstore

This commit is contained in:
hamid zarghami
2026-04-29 09:56:27 +03:30
parent b252682509
commit 4b2c6e8a9b
3 changed files with 398 additions and 364 deletions
@@ -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);
+54 -364
View File
@@ -1,85 +1,32 @@
import { create } from "zustand";
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 =
| "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; // 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;
};
export type {
EditorObject,
Page,
PageGuide,
TableCell,
TableData,
ToolType,
} from "./editorStore.types";
type EditorStoreType = {
tool: ToolType;
@@ -159,61 +106,6 @@ type EditorStoreType = {
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) => {
const initialPage = createInitialPage("جلد");
return {
@@ -358,25 +250,16 @@ export const useEditorStore = create<EditorStoreType>((set, get) => {
reorderObjects: (draggedId, targetId) => {
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();
if (state.currentPageId) {
set((state) => ({
objects: reorder(state.objects),
objects: reorderByIds(state.objects, draggedId, targetId),
pages: state.pages.map((page) =>
page.id === state.currentPageId
? { ...page, objects: reorder(page.objects) }
? {
...page,
objects: reorderByIds(page.objects, draggedId, targetId),
}
: page,
),
}));
@@ -384,7 +267,7 @@ export const useEditorStore = create<EditorStoreType>((set, get) => {
}
set((state) => ({
objects: reorder(state.objects),
objects: reorderByIds(state.objects, draggedId, targetId),
}));
},
selectedObjectId: null,
@@ -774,239 +657,46 @@ export const useEditorStore = create<EditorStoreType>((set, get) => {
},
groupObjects: (objectIds) => {
const state = get();
if (objectIds.length < 2) 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));
const result = buildGroupResult(state.objects, objectIds);
if (!result) return;
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) => ({
objects: [...updateObjects(state.objects), groupObject],
objects: result.nextObjects,
pages: state.pages.map((p) =>
p.id === state.currentPageId
? { ...p, objects: [...updateObjects(p.objects), groupObject] }
? { ...p, objects: pageResult.nextObjects }
: p,
),
selectedObjectId: groupId,
selectedObjectIds: [groupId],
selectedObjectId: result.groupId,
selectedObjectIds: [result.groupId],
}));
return;
}
set((state) => ({
objects: [...updateObjects(state.objects), groupObject],
selectedObjectId: groupId,
selectedObjectIds: [groupId],
set(() => ({
objects: result.nextObjects,
selectedObjectId: result.groupId,
selectedObjectIds: [result.groupId],
}));
},
ungroupObjects: (groupId) => {
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) {
set((state) => ({
objects: updateObjects(state.objects),
objects: ungroupById(state.objects, groupId),
pages: state.pages.map((p) =>
p.id === state.currentPageId
? { ...p, objects: updateObjects(p.objects) }
? { ...p, objects: ungroupById(p.objects, groupId) }
: p,
),
selectedObjectId: null,
@@ -1015,7 +705,7 @@ export const useEditorStore = create<EditorStoreType>((set, get) => {
return;
}
set((state) => ({
objects: updateObjects(state.objects),
objects: ungroupById(state.objects, groupId),
selectedObjectId: null,
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[];
};