diff --git a/src/pages/editor/components/EditorCanvas.tsx b/src/pages/editor/components/EditorCanvas.tsx index 500999c..c13d5be 100644 --- a/src/pages/editor/components/EditorCanvas.tsx +++ b/src/pages/editor/components/EditorCanvas.tsx @@ -14,6 +14,7 @@ import GuidesLayer from "./canvas/GuidesLayer"; import { getSnappedPosition, type Guide } from "./canvas/useSnap"; import CellEditor from "@/components/CellEditor"; import ZoomControls from "./ZoomControls"; +import EditorCanvasToolbar from "./EditorCanvasToolbar"; import { createScopedId } from "../store/editorStore.helpers"; type EditorCanvasProps = { @@ -69,8 +70,37 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => { } }, [setStageRef, setLayerRef]); - const finalScale = stageSize.scale * zoom; + + useEffect(() => { + let cleanup: (() => void) | undefined; + let raf = 0; + + const begin = () => useEditorStore.getState().beginObjectGesture(); + const end = () => useEditorStore.getState().endObjectGesture(); + + const attach = () => { + const stage = stageRef.current; + if (!stage || cleanup) return; + stage.on("dragstart", begin); + stage.on("dragend", end); + cleanup = () => { + stage.off("dragstart", begin); + stage.off("dragend", end); + }; + }; + + attach(); + if (!cleanup) { + raf = window.requestAnimationFrame(attach); + } + + return () => { + if (raf) window.cancelAnimationFrame(raf); + cleanup?.(); + }; + }, [finalScale, stageSize.width, stageSize.height]); + const updateActiveGuides = (nextActiveGuideIds: string[]) => { const prev = activeGuideIdsRef.current; const changed = @@ -268,16 +298,32 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => { handleStageMouseDown(e); }; + const scaledW = stageSize.width * finalScale; + const scaledH = stageSize.height * finalScale; + return ( -
-
+
+
+ +
+
+
+ +
+
{
+ {editingCell && ( FA_DIGITS[parseInt(d, 10)]!); +} + +type EditorCanvasToolbarProps = { + barWidthPx: number; +}; + +const iconHistory = { + size: 22, + variant: "Linear" as const, + color: "currentColor", +}; + +const EditorCanvasToolbar = ({ barWidthPx }: EditorCanvasToolbarProps) => { + const { + pages, + currentPageId, + objectHistoryPast, + objectHistoryFuture, + undoObjects, + redoObjects, + } = useEditorStore(); + + const canUndo = objectHistoryPast.length > 0; + const canRedo = objectHistoryFuture.length > 0; + + const pageIndexOneBased = currentPageId + ? pages.findIndex((p) => p.id === currentPageId) + 1 + : 0; + + const pageLabel = + pages.length > 0 + ? `${toPersianDigits(pageIndexOneBased)}\u200e / ${toPersianDigits(pages.length)}` + : ""; + + const historyBtn = + "flex size-10 items-center justify-center rounded-xl text-[#4a5568] transition-colors hover:bg-black/[0.04] active:bg-black/[0.06] disabled:pointer-events-none disabled:opacity-[0.32] outline-none"; + + return ( +
+
+
+ + +
+
+ + + {pageLabel} + +
+ ); +}; + +export default EditorCanvasToolbar; diff --git a/src/pages/editor/components/canvas/useDrawingHandlers.ts b/src/pages/editor/components/canvas/useDrawingHandlers.ts index ea7c532..d61d572 100644 --- a/src/pages/editor/components/canvas/useDrawingHandlers.ts +++ b/src/pages/editor/components/canvas/useDrawingHandlers.ts @@ -81,12 +81,14 @@ export const useDrawingHandlers = () => { width: 150, height: defaults.fontSize || 24, }; + useEditorStore.getState().commitObjectHistoryBeforeChange(); addObject(newText); setSelectedObjectId(newText.id); setTool("select"); } if (tool === "grid") { + useEditorStore.getState().commitObjectHistoryBeforeChange(); addTable(realPos.x, realPos.y, 4, 5); setSelectedTableId(null); setTool("select"); @@ -114,6 +116,7 @@ export const useDrawingHandlers = () => { if (!newObject) return; if (!tempObject) { + useEditorStore.getState().commitObjectHistoryBeforeChange(); addObject(newObject); setTempObject(newObject); } else { diff --git a/src/pages/editor/components/canvas/useKeyboardMovement.ts b/src/pages/editor/components/canvas/useKeyboardMovement.ts index 7408c6e..ccf44fd 100644 --- a/src/pages/editor/components/canvas/useKeyboardMovement.ts +++ b/src/pages/editor/components/canvas/useKeyboardMovement.ts @@ -13,6 +13,8 @@ export const useKeyboardMovement = () => { layerRef, groupObjects, ungroupObjects, + undoObjects, + redoObjects, } = useEditorStore(); useEffect(() => { @@ -34,7 +36,10 @@ export const useKeyboardMovement = () => { if (e.key === "Delete" || e.key === "Backspace") { if (selectedObjectIds.length > 0) { e.preventDefault(); - selectedObjectIds.forEach((id) => deleteObject(id)); + useEditorStore.getState().commitObjectHistoryBeforeChange(); + selectedObjectIds.forEach((id) => + deleteObject(id, { skipHistory: true }), + ); return; } } @@ -73,6 +78,22 @@ export const useKeyboardMovement = () => { return; } + if ((e.metaKey || e.ctrlKey) && (e.key === "z" || e.key === "Z")) { + e.preventDefault(); + if (e.shiftKey) { + redoObjects(); + } else { + undoObjects(); + } + return; + } + + if ((e.metaKey || e.ctrlKey) && (e.key === "y" || e.key === "Y")) { + e.preventDefault(); + redoObjects(); + return; + } + if (selectedObjectIds.length === 0) { return; } @@ -102,6 +123,8 @@ export const useKeyboardMovement = () => { return; } + useEditorStore.getState().commitObjectHistoryBeforeChange(); + // Move all selected objects selectedObjectIds.forEach((id) => { const selectedObject = objects.find((obj) => obj.id === id); @@ -144,5 +167,7 @@ export const useKeyboardMovement = () => { layerRef, groupObjects, ungroupObjects, + undoObjects, + redoObjects, ]); }; diff --git a/src/pages/editor/components/canvas/useTransformHandlers.ts b/src/pages/editor/components/canvas/useTransformHandlers.ts index 8548e45..dcec0d2 100644 --- a/src/pages/editor/components/canvas/useTransformHandlers.ts +++ b/src/pages/editor/components/canvas/useTransformHandlers.ts @@ -142,6 +142,7 @@ export const useTransformHandlers = ( }; const handleTransformEnd = () => { + useEditorStore.getState().commitObjectHistoryBeforeChange(); const nodes = transformer.nodes(); if (nodes.length === 0) return; diff --git a/src/pages/editor/components/sidebar/instructions/TextInstruction.tsx b/src/pages/editor/components/sidebar/instructions/TextInstruction.tsx index ee3f133..fd83c45 100644 --- a/src/pages/editor/components/sidebar/instructions/TextInstruction.tsx +++ b/src/pages/editor/components/sidebar/instructions/TextInstruction.tsx @@ -64,6 +64,7 @@ const TextInstruction: FC = () => { // اعمال تغییرات به صورت لایو const newState = { ...formState, [field]: value }; setFormState(newState); + useEditorStore.getState().commitObjectHistoryBeforeChange(); updateObject(selectedObject.id, { [field]: value }); } else { // text فقط برای حالت ویرایش است و نباید در defaults ذخیره شود diff --git a/src/pages/editor/components/sidebar/settings/AlignmentSettings.tsx b/src/pages/editor/components/sidebar/settings/AlignmentSettings.tsx index 734f44a..b243d71 100644 --- a/src/pages/editor/components/sidebar/settings/AlignmentSettings.tsx +++ b/src/pages/editor/components/sidebar/settings/AlignmentSettings.tsx @@ -38,6 +38,7 @@ const AlignmentSettings = ({ // const alignToSelection = selectedObjectIds.length === 2; const applyAlign = (kind: AlignKind) => { + useEditorStore.getState().commitObjectHistoryBeforeChange(); const { objects: objs, selectedObjectIds: ids, layerRef } = useEditorStore.getState(); const layer = layerRef?.current ?? null; diff --git a/src/pages/editor/components/sidebar/settings/MaskSettings.tsx b/src/pages/editor/components/sidebar/settings/MaskSettings.tsx index d1bbec4..fa0d509 100644 --- a/src/pages/editor/components/sidebar/settings/MaskSettings.tsx +++ b/src/pages/editor/components/sidebar/settings/MaskSettings.tsx @@ -30,6 +30,7 @@ const MaskSettings = ({ objectId }: MaskSettingsProps) => { ); const handleToggleMask = (checked: boolean) => { + useEditorStore.getState().commitObjectHistoryBeforeChange(); updateObject(objectId, { isMask: checked, showMaskGuide: checked ? true : undefined, @@ -42,14 +43,17 @@ const MaskSettings = ({ objectId }: MaskSettingsProps) => { }; const handleToggleGuide = (checked: boolean) => { + useEditorStore.getState().commitObjectHistoryBeforeChange(); updateObject(objectId, { showMaskGuide: checked }); }; const handleSelectMask = (maskId: string) => { + useEditorStore.getState().commitObjectHistoryBeforeChange(); updateObject(objectId, { maskId: maskId || undefined }); }; const handleToggleMaskInvert = (checked: boolean) => { + useEditorStore.getState().commitObjectHistoryBeforeChange(); updateObject(objectId, { maskInvert: checked }); }; diff --git a/src/pages/editor/store/editorStore.ts b/src/pages/editor/store/editorStore.ts index 7d08678..013891f 100644 --- a/src/pages/editor/store/editorStore.ts +++ b/src/pages/editor/store/editorStore.ts @@ -20,6 +20,17 @@ import type { ToolType, } from "./editorStore.types"; +const MAX_OBJECT_HISTORY = 50; + +/** اسنپ‌شات آرایٔهٔ اشیا برای undo؛ از انتشار مرج مشترک جلوگیری می‌شود */ +const cloneObjectList = (objects: EditorObject[]): EditorObject[] => + structuredClone(objects); + +const objectListsEqual = (a: EditorObject[], b: EditorObject[]) => + JSON.stringify(a) === JSON.stringify(b); + +let objectGestureSnapshot: EditorObject[] | null = null; + export type { EditorObject, Page, @@ -37,7 +48,7 @@ type EditorStoreType = { addObject: (object: EditorObject) => void; setGuides: (guides: PageGuide[]) => void; updateObject: (id: string, updates: Partial) => void; - deleteObject: (id: string) => void; + deleteObject: (id: string, options?: { skipHistory?: boolean }) => void; duplicateObject: (id: string) => void; copySelectedObjects: () => void; pasteCopiedObjects: () => void; @@ -108,6 +119,15 @@ type EditorStoreType = { ungroupObjects: (groupId: string) => void; loadPages: (pages: Page[]) => void; clipboardObjects: EditorObject[]; + objectHistoryPast: EditorObject[][]; + objectHistoryFuture: EditorObject[][]; + commitObjectHistoryBeforeChange: () => void; + beginObjectGesture: () => void; + endObjectGesture: () => void; + undoObjects: () => void; + redoObjects: () => void; + bringObjectForward: (id: string) => void; + sendObjectBackward: (id: string) => void; }; export const useEditorStore = create((set, get) => { @@ -161,6 +181,135 @@ export const useEditorStore = create((set, get) => { objects: [], guides: [], clipboardObjects: [], + objectHistoryPast: [], + objectHistoryFuture: [], + commitObjectHistoryBeforeChange: () => { + const s = get(); + const snap = cloneObjectList(s.objects); + set({ + objectHistoryPast: [...s.objectHistoryPast, snap].slice( + -MAX_OBJECT_HISTORY, + ), + objectHistoryFuture: [], + }); + }, + beginObjectGesture: () => { + if (objectGestureSnapshot !== null) return; + objectGestureSnapshot = cloneObjectList(get().objects); + }, + endObjectGesture: () => { + const base = objectGestureSnapshot; + objectGestureSnapshot = null; + if (!base) return; + if (objectListsEqual(base, get().objects)) return; + const s = get(); + set({ + objectHistoryPast: [...s.objectHistoryPast, base].slice( + -MAX_OBJECT_HISTORY, + ), + objectHistoryFuture: [], + }); + }, + undoObjects: () => { + const s = get(); + if (s.objectHistoryPast.length === 0) return; + const previous = s.objectHistoryPast[s.objectHistoryPast.length - 1]!; + const newPast = s.objectHistoryPast.slice(0, -1); + const currentSnap = cloneObjectList(s.objects); + const next = cloneObjectList(previous); + const idSet = new Set(next.map((o) => o.id)); + const nextSelIds = s.selectedObjectIds.filter((id) => idSet.has(id)); + const nextTable = + s.selectedTableId && idSet.has(s.selectedTableId) + ? s.selectedTableId + : null; + set({ + objects: next, + objectHistoryPast: newPast, + objectHistoryFuture: [currentSnap, ...s.objectHistoryFuture].slice( + -MAX_OBJECT_HISTORY, + ), + pages: + s.currentPageId != null + ? s.pages.map((p) => + p.id === s.currentPageId ? { ...p, objects: next } : p, + ) + : s.pages, + selectedObjectIds: nextSelIds, + selectedObjectId: nextSelIds[0] ?? null, + selectedTableId: nextTable, + selectedCellId: null, + editingCell: null, + }); + }, + redoObjects: () => { + const s = get(); + if (s.objectHistoryFuture.length === 0) return; + const forward = s.objectHistoryFuture[0]!; + const newFuture = s.objectHistoryFuture.slice(1); + const currentSnap = cloneObjectList(s.objects); + const next = cloneObjectList(forward); + const idSet = new Set(next.map((o) => o.id)); + const nextSelIds = s.selectedObjectIds.filter((id) => idSet.has(id)); + const nextTable = + s.selectedTableId && idSet.has(s.selectedTableId) + ? s.selectedTableId + : null; + set({ + objects: next, + objectHistoryPast: [...s.objectHistoryPast, currentSnap].slice( + -MAX_OBJECT_HISTORY, + ), + objectHistoryFuture: newFuture, + pages: + s.currentPageId != null + ? s.pages.map((p) => + p.id === s.currentPageId ? { ...p, objects: next } : p, + ) + : s.pages, + selectedObjectIds: nextSelIds, + selectedObjectId: nextSelIds[0] ?? null, + selectedTableId: nextTable, + selectedCellId: null, + editingCell: null, + }); + }, + bringObjectForward: (id) => { + get().commitObjectHistoryBeforeChange(); + const state = get(); + const objs = [...state.objects]; + const i = objs.findIndex((o) => o.id === id); + if (i === -1 || i >= objs.length - 1) return; + [objs[i], objs[i + 1]] = [objs[i + 1]!, objs[i]!]; + if (state.currentPageId) { + set({ + objects: objs, + pages: state.pages.map((p) => + p.id === state.currentPageId ? { ...p, objects: objs } : p, + ), + }); + return; + } + set({ objects: objs }); + }, + sendObjectBackward: (id) => { + get().commitObjectHistoryBeforeChange(); + const state = get(); + const objs = [...state.objects]; + const i = objs.findIndex((o) => o.id === id); + if (i <= 0) return; + [objs[i], objs[i - 1]] = [objs[i - 1]!, objs[i]!]; + if (state.currentPageId) { + set({ + objects: objs, + pages: state.pages.map((p) => + p.id === state.currentPageId ? { ...p, objects: objs } : p, + ), + }); + return; + } + set({ objects: objs }); + }, addObject: (object) => { const state = get(); if (state.currentPageId) { @@ -220,7 +369,10 @@ export const useEditorStore = create((set, get) => { ), })); }, - deleteObject: (id) => { + deleteObject: (id, options) => { + if (!options?.skipHistory) { + get().commitObjectHistoryBeforeChange(); + } const state = get(); if (state.currentPageId) { set((state) => ({ @@ -246,6 +398,7 @@ export const useEditorStore = create((set, get) => { })); }, duplicateObject: (id) => { + get().commitObjectHistoryBeforeChange(); const state = get(); const objectToDuplicate = state.objects.find((obj) => obj.id === id); if (!objectToDuplicate) return; @@ -297,6 +450,7 @@ export const useEditorStore = create((set, get) => { const state = get(); if (state.clipboardObjects.length === 0) return; + get().commitObjectHistoryBeforeChange(); const pastedObjects = cloneObjectsForPaste(state.clipboardObjects); const pastedIds = pastedObjects.map((obj) => obj.id); @@ -323,6 +477,7 @@ export const useEditorStore = create((set, get) => { reorderObjects: (draggedId, targetId) => { if (draggedId === targetId) return; + get().commitObjectHistoryBeforeChange(); const state = get(); if (state.currentPageId) { set((state) => ({ @@ -411,6 +566,7 @@ export const useEditorStore = create((set, get) => { get().setSelectedTableId(tableId); }, addRow: (tableId) => { + get().commitObjectHistoryBeforeChange(); const state = get(); const obj = state.objects.find((o) => o.id === tableId); if (!obj || !obj.tableData) return; @@ -440,6 +596,7 @@ export const useEditorStore = create((set, get) => { }); }, addColumn: (tableId) => { + get().commitObjectHistoryBeforeChange(); const state = get(); const obj = state.objects.find((o) => o.id === tableId); if (!obj || !obj.tableData) return; @@ -469,6 +626,7 @@ export const useEditorStore = create((set, get) => { }); }, removeRow: (tableId) => { + get().commitObjectHistoryBeforeChange(); const state = get(); const obj = state.objects.find((o) => o.id === tableId); if (!obj || !obj.tableData) return; @@ -492,6 +650,7 @@ export const useEditorStore = create((set, get) => { }); }, removeColumn: (tableId) => { + get().commitObjectHistoryBeforeChange(); const state = get(); const obj = state.objects.find((o) => o.id === tableId); if (!obj || !obj.tableData) return; @@ -515,6 +674,7 @@ export const useEditorStore = create((set, get) => { }); }, updateCellValue: (tableId, cellId, text) => { + get().commitObjectHistoryBeforeChange(); const state = get(); const obj = state.objects.find((o) => o.id === tableId); if (!obj || !obj.tableData) return; @@ -536,6 +696,7 @@ export const useEditorStore = create((set, get) => { }); }, changeCellBackground: (tableId, cellId, color) => { + get().commitObjectHistoryBeforeChange(); const state = get(); const obj = state.objects.find((o) => o.id === tableId); if (!obj || !obj.tableData) return; @@ -617,6 +778,8 @@ export const useEditorStore = create((set, get) => { objects: [], guides: [], selectedObjectId: null, + objectHistoryPast: [], + objectHistoryFuture: [], })); }, duplicatePage: (pageId) => { @@ -658,6 +821,8 @@ export const useEditorStore = create((set, get) => { objects: newCurrentPage?.objects || [], guides: newCurrentPage?.guides || [], selectedObjectId: null, + objectHistoryPast: [], + objectHistoryFuture: [], }); }, setCurrentPage: (pageId) => { @@ -683,6 +848,8 @@ export const useEditorStore = create((set, get) => { objects: targetPageFromUpdated.objects, guides: targetPageFromUpdated.guides || [], selectedObjectId: null, + objectHistoryPast: [], + objectHistoryFuture: [], }); }, updatePageName: (pageId, name) => { @@ -691,6 +858,7 @@ export const useEditorStore = create((set, get) => { })); }, toggleObjectVisibility: (id) => { + get().commitObjectHistoryBeforeChange(); const state = get(); const obj = state.objects.find((o) => o.id === id); if (!obj) return; @@ -729,6 +897,7 @@ export const useEditorStore = create((set, get) => { })); }, groupObjects: (objectIds) => { + get().commitObjectHistoryBeforeChange(); const state = get(); const result = buildGroupResult(state.objects, objectIds); if (!result) return; @@ -762,6 +931,7 @@ export const useEditorStore = create((set, get) => { })); }, ungroupObjects: (groupId) => { + get().commitObjectHistoryBeforeChange(); const state = get(); if (state.currentPageId) { @@ -803,6 +973,8 @@ export const useEditorStore = create((set, get) => { guides: firstPage.guides || [], selectedObjectId: null, selectedObjectIds: [], + objectHistoryPast: [], + objectHistoryFuture: [], }); }, };