import { useEffect, useRef, useState } from "react"; import { Stage, Layer, Rect, Image as KonvaImage } from "react-konva"; import useImage from "use-image"; import Konva from "konva"; import { useEditorStore } from "../store/editorStore"; import { useDrawingHandlers } from "./canvas/useDrawingHandlers"; import { useStageSize } from "./canvas/useStageSize"; import { useKeyboardMovement } from "./canvas/useKeyboardMovement"; import { useSelectionHandlers } from "./canvas/useSelectionHandlers"; import { useTransformHandlers } from "./canvas/useTransformHandlers"; import { useObjectHandlers } from "./canvas/useObjectHandlers"; import { useMaskGroupState } from "./canvas/useMaskGroupState"; import { useCustomShapeDrawing } from "./canvas/useCustomShapeDrawing"; import { usePencilDrawing } from "./canvas/usePencilDrawing"; import ObjectsLayer from "./canvas/ObjectsLayer"; import GuidesLayer from "./canvas/GuidesLayer"; import BlurBackdropLayer from "./canvas/BlurBackdropLayer"; import CustomShapeDrawingLayer from "./canvas/CustomShapeDrawingLayer"; import PencilDrawingLayer from "./canvas/PencilDrawingLayer"; import { clearSmartGuides, drawSmartGuides, 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"; import { getKonvaGradientProps } from "../utils/gradient"; import { getColorWithOpacity } from "../utils/colorOpacity"; import { STICKER_DRAG_MIME } from "../types/IconTypes"; import { GALLERY_DRAG_MIME } from "../types/GalleryTypes"; import { createStickerObject, scaleStickerDimensions, } from "../utils/stickerUtils"; import { createImageObject, scaleImageDimensions, } from "../utils/imageUtils"; type EditorCanvasProps = { catalogSize?: string; }; const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => { const stageRef = useRef(null); const layerRef = useRef(null); const smartGuidesLayerRef = useRef(null); const activeGuideIdsRef = useRef([]); const stageWrapRef = useRef(null); const { tool, objects, guides, documentSettings, pages, currentPageId, selectedObjectId, selectedObjectIds, selectedCellId, editingCell, setStageRef, setLayerRef, setGuides, zoom, addObject, setSelectedObjectId, setTool, } = useEditorStore(); const isSmartGuideEnabled = documentSettings.smartGuide; const currentPage = pages.find((page) => page.id === currentPageId); const bgColor = currentPage?.backgroundType === 'color' ? getColorWithOpacity( currentPage.backgroundColor, currentPage.backgroundOpacity ?? 100, ) : currentPage?.backgroundType === 'gradient' ? currentPage.backgroundColor : '#ffffff'; const bgGradient = currentPage?.backgroundGradient; const hasVideoBackground = currentPage?.backgroundType === "video" && Boolean(currentPage.backgroundVideoUrl); const [bgImage] = useImage( currentPage?.backgroundType === 'image' ? (currentPage.backgroundImageUrl || '') : '', ); const stageSize = useStageSize(catalogSize); const bgImageCrop = (() => { if (!bgImage) return null; const containerRatio = stageSize.width / stageSize.height; const imageRatio = bgImage.width / bgImage.height; if (imageRatio > containerRatio) { const cropWidth = bgImage.height * containerRatio; const cropX = (bgImage.width - cropWidth) / 2; return { x: cropX, y: 0, width: cropWidth, height: bgImage.height }; } const cropHeight = bgImage.width / containerRatio; const cropY = (bgImage.height - cropHeight) / 2; return { x: 0, y: cropY, width: bgImage.width, height: cropHeight }; })(); const { transformerRef, handleStageMouseDown, handleStageMouseMove, handleStageMouseUp } = useDrawingHandlers(); const { drawingState, handleStageClick: handleCustomShapeClick, handleStageMouseMove: handleCustomShapeMouseMove } = useCustomShapeDrawing(); const { drawingState: pencilDrawingState, handleStagePointerDown: handlePencilPointerDown, handleStagePointerMove: handlePencilPointerMove, handleStagePointerUp: handlePencilPointerUp, } = usePencilDrawing(stageRef); useKeyboardMovement(); const { handleSelect, handleCellClick, handleCellDblClick } = useSelectionHandlers(); const { handleObjectUpdate, handleGroupWithMask, handleUngroupFromMask, handleCellEditorSave, handleCellEditorCancel } = useObjectHandlers(); const { isGroupedWithMask, maskObject, canGroup, canUngroup, selectedObject } = useMaskGroupState(selectedObjectId); useTransformHandlers(transformerRef, layerRef); const [activeGuideIds, setActiveGuideIds] = useState([]); const [selectedGuideId, setSelectedGuideId] = useState(null); const [draftGuide, setDraftGuide] = useState<{ orientation: Guide["orientation"]; position: number; } | null>(null); const cleanupRulerDragRef = useRef<(() => void) | null>(null); useEffect(() => { if (stageRef.current) { setStageRef(stageRef); } if (layerRef.current) { setLayerRef(layerRef); } }, [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 = prev.length !== nextActiveGuideIds.length || prev.some((id, index) => id !== nextActiveGuideIds[index]); if (changed) { activeGuideIdsRef.current = nextActiveGuideIds; setActiveGuideIds(nextActiveGuideIds); } }; const handleGuidePositionChange = (id: string, position: number) => { setGuides( guides.map((guide) => guide.id === id ? { ...guide, position, } : guide, ), ); }; const addGuide = (orientation: Guide["orientation"], position: number) => { const id = createScopedId(`guide-${orientation}`); setGuides([...guides, { id, orientation, position }]); setSelectedGuideId(id); }; const removeGuide = (id: string) => { setGuides(guides.filter((guide) => guide.id !== id)); setActiveGuideIds((prev) => prev.filter((activeId) => activeId !== id)); setSelectedGuideId((prev) => (prev === id ? null : prev)); }; const handleGuideDragEnd = (id: string, position: number, shouldRemove: boolean) => { if (shouldRemove) { removeGuide(id); return; } handleGuidePositionChange(id, position); }; useEffect(() => { const handleDeleteGuide = (e: KeyboardEvent) => { if (!selectedGuideId) return; const target = e.target as HTMLElement; if ( target?.tagName === "INPUT" || target?.tagName === "TEXTAREA" || target?.isContentEditable ) { return; } if (e.key === "Delete" || e.key === "Backspace") { e.preventDefault(); removeGuide(selectedGuideId); } }; window.addEventListener("keydown", handleDeleteGuide); return () => { window.removeEventListener("keydown", handleDeleteGuide); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedGuideId]); useEffect(() => { return () => { cleanupRulerDragRef.current?.(); cleanupRulerDragRef.current = null; }; }, []); const startRulerGuideDrag = ( orientation: Guide["orientation"], pointerId: number | null, ) => { cleanupRulerDragRef.current?.(); const updateFromPointer = (event: PointerEvent) => { if (pointerId !== null && event.pointerId !== pointerId) return null; const rect = stageWrapRef.current?.getBoundingClientRect(); if (!rect) return null; const rawPos = orientation === "vertical" ? (event.clientX - rect.left) / finalScale : (event.clientY - rect.top) / finalScale; return rawPos; }; const updateDraft = (event: PointerEvent) => { const pointerPos = updateFromPointer(event); if (pointerPos === null) return; const boundedPos = orientation === "vertical" ? Math.max(0, Math.min(stageSize.width, pointerPos)) : Math.max(0, Math.min(stageSize.height, pointerPos)); setDraftGuide({ orientation, position: boundedPos }); }; const handlePointerMove = (event: PointerEvent) => { updateDraft(event); }; const cleanup = () => { window.removeEventListener("pointermove", handlePointerMove); window.removeEventListener("pointerup", handlePointerUp); window.removeEventListener("pointercancel", handlePointerCancel); window.removeEventListener("blur", handleBlur); cleanupRulerDragRef.current = null; }; const handlePointerUp = (event: PointerEvent) => { if (pointerId !== null && event.pointerId !== pointerId) return; const rect = stageWrapRef.current?.getBoundingClientRect(); if (!rect) { setDraftGuide(null); cleanup(); return; } const isInside = event.clientX >= rect.left && event.clientX <= rect.right && event.clientY >= rect.top && event.clientY <= rect.bottom; if (isInside) { const pointerPos = updateFromPointer(event); if (pointerPos !== null) { const boundedPos = orientation === "vertical" ? Math.max(0, Math.min(stageSize.width, pointerPos)) : Math.max(0, Math.min(stageSize.height, pointerPos)); addGuide(orientation, boundedPos); } } setDraftGuide(null); cleanup(); }; const handlePointerCancel = (event: PointerEvent) => { if (pointerId !== null && event.pointerId !== pointerId) return; setDraftGuide(null); cleanup(); }; const handleBlur = () => { setDraftGuide(null); cleanup(); }; window.addEventListener("pointermove", handlePointerMove); window.addEventListener("pointerup", handlePointerUp); window.addEventListener("pointercancel", handlePointerCancel); window.addEventListener("blur", handleBlur); cleanupRulerDragRef.current = cleanup; }; const handleDragMove = (e: Konva.KonvaEventObject) => { const node = e.target; if (!node || node.name() === "guide-line") return; if (!isSmartGuideEnabled) { updateActiveGuides([]); clearSmartGuides(smartGuidesLayerRef.current); return; } const snapped = getSnappedPosition(node, guides, finalScale); if (node.x() !== snapped.position.x || node.y() !== snapped.position.y) { node.position(snapped.position); node.getLayer()?.batchDraw(); } updateActiveGuides(snapped.activeGuideIds); // گروه فقط باکس انتخاب را درگ می‌کند؛ اعضا با مختصات مطلق جدا رسم می‌شوند — در حین drag باید استور هم‌گام شود if (node.name() === "group-container") { handleObjectUpdate(node.id(), { x: snapped.position.x, y: snapped.position.y, }); } drawSmartGuides( smartGuidesLayerRef.current, snapped.smartGuides, stageSize.width, stageSize.height, ); }; const handleDragEnd = () => { updateActiveGuides([]); clearSmartGuides(smartGuidesLayerRef.current); }; useEffect(() => { if (isSmartGuideEnabled) return; updateActiveGuides([]); clearSmartGuides(smartGuidesLayerRef.current); }, [isSmartGuideEnabled]); const handleStageMouseDownWithGuideReset = (e: Konva.KonvaEventObject) => { if (e.target?.name() !== "guide-line") { setSelectedGuideId(null); } if (tool === "pencil") return; handleStageMouseDown(e); }; const handleStagePointerDownWithGuideReset = (e: Konva.KonvaEventObject) => { if (e.target?.name() !== "guide-line") { setSelectedGuideId(null); } if (tool === "pencil") { handlePencilPointerDown(e); } }; const handleCombinedMouseMove = (e: Konva.KonvaEventObject) => { handleStageMouseMove(e); handleCustomShapeMouseMove(e); }; const handleCombinedPointerMove = (e: Konva.KonvaEventObject) => { handlePencilPointerMove(e); }; const handleCombinedPointerUp = (e: Konva.KonvaEventObject) => { handlePencilPointerUp(e); }; const scaledW = stageSize.width * finalScale; const scaledH = stageSize.height * finalScale; const SIDEBAR_DRAG_MIMES = [STICKER_DRAG_MIME, GALLERY_DRAG_MIME]; const handleSidebarDragOver = (e: React.DragEvent) => { if (!SIDEBAR_DRAG_MIMES.some((mime) => e.dataTransfer.types.includes(mime))) return; e.preventDefault(); e.dataTransfer.dropEffect = "copy"; }; const placeObjectAt = ( imageUrl: string, stageX: number, stageY: number, width: number, height: number, type: "sticker" | "image", ) => { const object = type === "sticker" ? createStickerObject( imageUrl, stageX, stageY, width, height, stageSize.width, stageSize.height, ) : createImageObject( imageUrl, stageX, stageY, width, height, stageSize.width, stageSize.height, ); addObject(object); setSelectedObjectId(object.id); setTool("select"); }; const handleSidebarDrop = (e: React.DragEvent) => { const stickerUrl = e.dataTransfer.getData(STICKER_DRAG_MIME); const galleryUrl = e.dataTransfer.getData(GALLERY_DRAG_MIME); const imageUrl = stickerUrl || galleryUrl; const dropType = stickerUrl ? "sticker" : galleryUrl ? "image" : null; if (!imageUrl || !dropType || !stageWrapRef.current) return; e.preventDefault(); const rect = stageWrapRef.current.getBoundingClientRect(); const stageX = (e.clientX - rect.left) / finalScale; const stageY = (e.clientY - rect.top) / finalScale; const scaleFn = dropType === "sticker" ? scaleStickerDimensions : scaleImageDimensions; const img = new Image(); img.onload = () => { const { width, height } = scaleFn(img.naturalWidth, img.naturalHeight); placeObjectAt(imageUrl, stageX, stageY, width, height, dropType); }; img.onerror = () => { placeObjectAt(imageUrl, stageX, stageY, 100, 100, dropType); }; img.src = imageUrl; }; const backgroundGradientProps = getKonvaGradientProps( currentPage?.backgroundType === "gradient" ? "gradient" : "solid", bgGradient, stageSize.width, stageSize.height, "rect", ); return (
{ e.preventDefault(); startRulerGuideDrag("vertical", e.pointerId); }} title="Drag to create vertical guide" />
{ e.preventDefault(); startRulerGuideDrag("horizontal", e.pointerId); }} title="Drag to create horizontal guide" />
{hasVideoBackground && (
{editingCell && ( )}
); }; export default EditorCanvas;