diff --git a/src/pages/editor/store/editorStore.helpers.ts b/src/pages/editor/store/editorStore.helpers.ts new file mode 100644 index 0000000..38e6db9 --- /dev/null +++ b/src/pages/editor/store/editorStore.helpers.ts @@ -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 => { + const cells: Record = {}; + 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(objectIds); + const oldGroupIdsToRemove = new Set(); + + 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); diff --git a/src/pages/editor/store/editorStore.ts b/src/pages/editor/store/editorStore.ts index 463fc4d..5241543 100644 --- a/src/pages/editor/store/editorStore.ts +++ b/src/pages/editor/store/editorStore.ts @@ -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; - 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 => { - const cells: Record = {}; - 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((set, get) => { const initialPage = createInitialPage("جلد"); return { @@ -358,25 +250,16 @@ export const useEditorStore = create((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((set, get) => { } set((state) => ({ - objects: reorder(state.objects), + objects: reorderByIds(state.objects, draggedId, targetId), })); }, selectedObjectId: null, @@ -774,239 +657,46 @@ export const useEditorStore = create((set, get) => { }, groupObjects: (objectIds) => { const state = get(); - if (objectIds.length < 2) return; - - // پیدا کردن همه آبجکت‌هایی که باید در گروه جدید باشند - // شامل آبجکت‌های انتخاب شده + آبجکت‌های داخل گروه‌های قبلی آن‌ها - const allObjectIdsToGroup = new Set(objectIds); - const oldGroupIdsToRemove = new Set(); - - // بررسی اینکه آیا یکی از آبجکت‌های انتخاب شده در یک گروه قبلی است - 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((set, get) => { return; } set((state) => ({ - objects: updateObjects(state.objects), + objects: ungroupById(state.objects, groupId), selectedObjectId: null, selectedObjectIds: [], })); diff --git a/src/pages/editor/store/editorStore.types.ts b/src/pages/editor/store/editorStore.types.ts new file mode 100644 index 0000000..db4bb17 --- /dev/null +++ b/src/pages/editor/store/editorStore.types.ts @@ -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; + 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[]; +};