diff --git a/src/components/UploadBoxDraggble.tsx b/src/components/UploadBoxDraggble.tsx index ba2210b..b3f249a 100644 --- a/src/components/UploadBoxDraggble.tsx +++ b/src/components/UploadBoxDraggble.tsx @@ -11,7 +11,12 @@ type Props = { preview?: string[], onChangePreview?: (preview: string[]) => void, getCover?: (url: string) => void, - coverUrl?: string + coverUrl?: string, + /** نمایش وضعیت بارگذاری داخل باکس آپلود */ + isLoading?: boolean, + loadingLabel?: string, + /** بدون پیش‌نمایش زیر باکس (مثلاً گالری جداگانه دارد) */ + hidePreview?: boolean, } const UploadBoxDraggble: FC = (props: Props) => { @@ -21,6 +26,10 @@ const UploadBoxDraggble: FC = (props: Props) => { const [cover, setCover] = useState(props.coverUrl ?? ""); const onDrop = useCallback((acceptedFiles: File[]) => { + if (props.hidePreview) { + props.onChange(acceptedFiles); + return; + } // وقتی parent با preview و onChangePreview مدیریت می‌کند، فقط onChange بزن تا فقط یک ردیف (perviews) نمایش داده شود if (props.preview !== undefined && props.onChangePreview) { props.onChange(acceptedFiles); @@ -36,7 +45,10 @@ const UploadBoxDraggble: FC = (props: Props) => { } // eslint-disable-next-line react-hooks/exhaustive-deps }, [files]); - const { getRootProps, getInputProps } = useDropzone({ onDrop }) + const { getRootProps, getInputProps } = useDropzone({ + onDrop, + disabled: props.isLoading, + }) const handleDelete = (index: number) => { const array = [...files] @@ -67,27 +79,44 @@ const UploadBoxDraggble: FC = (props: Props) => { return (
-
+
- -
- {props.label} -
+ {props.isLoading ? ( + <> + +
+ {props.loadingLabel ?? 'در حال آپلود...'} +
+ + ) : ( + <> + +
+ {props.label} +
-
- -
آپلود
-
+
+ +
آپلود
+
+ + )}
{ - files.length > 0 || perviews.length > 0 ? ( + !props.hidePreview && (files.length > 0 || perviews.length > 0) ? (
{ perviews && perviews.map((item, index) => { diff --git a/src/pages/editor/Editor.tsx b/src/pages/editor/Editor.tsx index d2166df..8e0adfc 100644 --- a/src/pages/editor/Editor.tsx +++ b/src/pages/editor/Editor.tsx @@ -25,7 +25,7 @@ const Editor: FC = () => { const [isInitialContentReady, setIsInitialContentReady] = useState(false) const loadedCatalogIdRef = useRef(null) const skipNextAutosaveRef = useRef(true) - const { loadPages, resetEditor, pages, documentSettings, objects, currentPageId } = useEditorStore() + const { loadPages, resetEditor, pages, documentSettings, gallery, objects, currentPageId } = useEditorStore() const { data, isFetched } = useGetCatalogById(id!) const { scheduleUpdate, cancelPendingUpdate, isSaving } = useUpdateCatalog() @@ -50,7 +50,7 @@ const Editor: FC = () => { if (Array.isArray(json)) { loadPages(json) } else if (json?.pages) { - loadPages(json.pages, json.documentSettings) + loadPages(json.pages, json.documentSettings, json.gallery) } else { console.error('Invalid catalog content JSON: missing pages') return @@ -85,11 +85,11 @@ const Editor: FC = () => { scheduleUpdate({ id, - content: JSON.stringify({ pages: pagesToSave, documentSettings }), + content: JSON.stringify({ pages: pagesToSave, documentSettings, gallery }), }) // عمداً scheduleUpdate را در وابستگی‌ها نیاوردیم تا از لوپ ذخیره‌سازی جلوگیری شود // eslint-disable-next-line react-hooks/exhaustive-deps - }, [id, isInitialContentReady, pages, documentSettings, objects, currentPageId]) + }, [id, isInitialContentReady, pages, documentSettings, gallery, objects, currentPageId]) return ( diff --git a/src/pages/editor/components/EditorCanvas.tsx b/src/pages/editor/components/EditorCanvas.tsx index d5a4f07..3ff21b3 100644 --- a/src/pages/editor/components/EditorCanvas.tsx +++ b/src/pages/editor/components/EditorCanvas.tsx @@ -24,10 +24,15 @@ import EditorCanvasToolbar from "./EditorCanvasToolbar"; import { createScopedId } from "../store/editorStore.helpers"; import { getKonvaGradientProps } from "../utils/gradient"; 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; @@ -369,35 +374,69 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => { const scaledW = stageSize.width * finalScale; const scaledH = stageSize.height * finalScale; - const handleStickerDragOver = (e: React.DragEvent) => { - if (!e.dataTransfer.types.includes(STICKER_DRAG_MIME)) return; + 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 placeStickerAt = (imageUrl: string, stageX: number, stageY: number, width: number, height: number) => { - const sticker = createStickerObject(imageUrl, stageX, stageY, width, height); - addObject(sticker); - setSelectedObjectId(sticker.id); + 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 handleStickerDrop = (e: React.DragEvent) => { - const imageUrl = e.dataTransfer.getData(STICKER_DRAG_MIME); - if (!imageUrl || !stageWrapRef.current) return; + 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 } = scaleStickerDimensions(img.naturalWidth, img.naturalHeight); - placeStickerAt(imageUrl, stageX, stageY, width, height); + const { width, height } = scaleFn(img.naturalWidth, img.naturalHeight); + placeObjectAt(imageUrl, stageX, stageY, width, height, dropType); }; img.onerror = () => { - placeStickerAt(imageUrl, stageX, stageY, 100, 100); + placeObjectAt(imageUrl, stageX, stageY, 100, 100, dropType); }; img.src = imageUrl; }; @@ -454,8 +493,8 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
{ onCellDblClick={handleCellDblClick} onGroup={handleGroupWithMask} onUngroup={handleUngroupFromMask} + stageWidth={stageSize.width} + stageHeight={stageSize.height} /> void; selectedCellId?: string | null; allObjects?: EditorObject[]; + stageWidth?: number; + stageHeight?: number; }; const ObjectRenderer = ({ @@ -42,6 +44,8 @@ const ObjectRenderer = ({ onCellDblClick, selectedCellId, allObjects = [], + stageWidth, + stageHeight, }: ObjectRendererProps) => { const groupRef = useRef(null); @@ -110,6 +114,8 @@ const ObjectRenderer = ({ onUpdate, // اگر mask اعمال شده، shape را غیر draggable کن (Group drag می‌شود) draggable: shouldApplyMask ? false : draggable, + stageWidth, + stageHeight, }; const renderMaskShape = (mask: EditorObject) => { diff --git a/src/pages/editor/components/canvas/ObjectsLayer.tsx b/src/pages/editor/components/canvas/ObjectsLayer.tsx index e8ef668..294651b 100644 --- a/src/pages/editor/components/canvas/ObjectsLayer.tsx +++ b/src/pages/editor/components/canvas/ObjectsLayer.tsx @@ -31,6 +31,8 @@ interface ObjectsLayerProps { ) => void; onGroup: (objectId: string, maskId: string) => void; onUngroup: (groupId: string) => void; + stageWidth: number; + stageHeight: number; } const ObjectsLayer = ({ @@ -51,6 +53,8 @@ const ObjectsLayer = ({ onCellDblClick, onGroup, onUngroup, + stageWidth, + stageHeight, }: ObjectsLayerProps) => { return ( @@ -71,6 +75,8 @@ const ObjectsLayer = ({ onCellDblClick={onCellDblClick} selectedCellId={selectedCellId} allObjects={objects} + stageWidth={stageWidth} + stageHeight={stageHeight} /> ))} {/* Render non-grouped objects */} @@ -90,6 +96,8 @@ const ObjectsLayer = ({ onCellDblClick={onCellDblClick} selectedCellId={selectedCellId} allObjects={objects} + stageWidth={stageWidth} + stageHeight={stageHeight} /> ))} {/* Render objects inside groups - they should not be interactive */} @@ -115,6 +123,8 @@ const ObjectsLayer = ({ onCellDblClick={onCellDblClick} selectedCellId={selectedCellId} allObjects={objects} + stageWidth={stageWidth} + stageHeight={stageHeight} /> ); diff --git a/src/pages/editor/components/canvas/useSelectionHandlers.ts b/src/pages/editor/components/canvas/useSelectionHandlers.ts index 5e0964d..93839a6 100644 --- a/src/pages/editor/components/canvas/useSelectionHandlers.ts +++ b/src/pages/editor/components/canvas/useSelectionHandlers.ts @@ -10,9 +10,15 @@ export const useSelectionHandlers = () => { removeFromSelection, setSelectedTableId, setSelectedCellId, + tool, + setTool, } = useEditorStore(); const handleSelect = (id: string, _node: Konva.Node, e?: Konva.KonvaEventObject) => { + if (tool !== "select") { + setTool("select"); + } + const obj = objects.find((o) => o.id === id); const isMultiSelect = e?.evt?.metaKey || e?.evt?.ctrlKey || e?.evt?.shiftKey; diff --git a/src/pages/editor/components/sidebar/ToolInstructions.tsx b/src/pages/editor/components/sidebar/ToolInstructions.tsx index 3c91d01..c063d26 100644 --- a/src/pages/editor/components/sidebar/ToolInstructions.tsx +++ b/src/pages/editor/components/sidebar/ToolInstructions.tsx @@ -1,7 +1,6 @@ -import { useEditorStore, type ToolType } from "@/pages/editor/store/editorStore"; -import { useSingleUpload } from "@/pages/uploader/hooks/useUploaderData"; +import { type ToolType } from "@/pages/editor/store/editorStore"; import { - ImageUpload, + ImageGallery, VideoInput, LinkInput, SelectInstruction, @@ -18,39 +17,8 @@ type ToolInstructionsProps = { }; const ToolInstructions = ({ tool }: ToolInstructionsProps) => { - const { addObject, setSelectedObjectId, setTool } = useEditorStore(); - const { mutateAsync: uploadFile } = useSingleUpload(); - - const handleImageUpload = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - try { - const result = await uploadFile(file); - const imageUrl = result?.data?.url; - if (!imageUrl) return; - const img = new Image(); - img.onload = () => { - const newImage = { - id: `image-${Date.now()}`, - type: "image" as ToolType, - x: 100, - y: 100, - width: img.width, - height: img.height, - imageUrl, - }; - addObject(newImage); - setSelectedObjectId(newImage.id); - setTool("select"); - }; - img.src = imageUrl; - } catch { - // TODO: show error toast - } - }; - if (tool === "image") { - return ; + return ; } if (tool === "video") { diff --git a/src/pages/editor/components/sidebar/ToolsBar.tsx b/src/pages/editor/components/sidebar/ToolsBar.tsx index 63bea6e..b8538a0 100644 --- a/src/pages/editor/components/sidebar/ToolsBar.tsx +++ b/src/pages/editor/components/sidebar/ToolsBar.tsx @@ -18,7 +18,7 @@ const tools: Array<{ icon: (color: string, variant?: "Bold" | "Outline" | "Broke { icon: (color, variant) => , tool: "rectangle", label: "مستطیل" }, { icon: (color, variant) => , tool: "text", label: "متن" }, { icon: (color, variant) => , tool: "grid", label: "گرید" }, - { icon: (color, variant) => , tool: "image", label: "تصویر" }, + { icon: (color, variant) => , tool: "image", label: "گالری" }, { icon: (color, variant) => , tool: "link", label: "لینک" }, { icon: (color, variant) => , tool: "video", label: "ویدیو" }, { icon: (color, variant) => , tool: "arrow", label: "پیکان" }, diff --git a/src/pages/editor/components/sidebar/instructions/ImageGallery.tsx b/src/pages/editor/components/sidebar/instructions/ImageGallery.tsx new file mode 100644 index 0000000..3e87717 --- /dev/null +++ b/src/pages/editor/components/sidebar/instructions/ImageGallery.tsx @@ -0,0 +1,105 @@ +import { type FC, useState } from "react"; +import { Trash } from "iconsax-react"; +import UploadBoxDraggble from "@/components/UploadBoxDraggble"; +import { useEditorStore } from "@/pages/editor/store/editorStore"; +import { useSingleUpload } from "@/pages/uploader/hooks/useUploaderData"; +import { createScopedId } from "@/pages/editor/store/editorStore.helpers"; +import { GALLERY_DRAG_MIME } from "@/pages/editor/types/GalleryTypes"; + +const ImageGallery: FC = () => { + const { gallery, addGalleryItem, removeGalleryItem } = useEditorStore(); + const { mutateAsync: uploadFile } = useSingleUpload(); + const [isUploading, setIsUploading] = useState(false); + + const handleFileChange = async (files: File[]) => { + if (files.length === 0) return; + + setIsUploading(true); + try { + for (const file of files) { + const result = await uploadFile(file); + const imageUrl = result?.data?.url; + if (!imageUrl) continue; + + const dimensions = await loadImageDimensions(imageUrl); + addGalleryItem({ + id: createScopedId("gallery"), + url: imageUrl, + width: dimensions.width, + height: dimensions.height, + }); + } + } catch { + // TODO: show error toast + } finally { + setIsUploading(false); + } + }; + + const handleDragStart = (e: React.DragEvent, url: string) => { + e.dataTransfer.setData(GALLERY_DRAG_MIME, url); + e.dataTransfer.effectAllowed = "copy"; + }; + + return ( +
+
گالری
+

+ تصویر را آپلود کنید، سپس از گالری بکشید و روی صفحه رها کنید +

+ + + + {gallery.length === 0 && !isUploading && ( +

گالری خالی است

+ )} + + {gallery.length > 0 && ( +
+ {gallery.map((item) => ( +
+ handleDragStart(e, item.url)} + className="max-h-full max-w-full object-contain cursor-grab active:cursor-grabbing" + /> + +
+ ))} +
+ )} +
+ ); +}; + +const loadImageDimensions = (url: string): Promise<{ width: number; height: number }> => + new Promise((resolve) => { + const img = new Image(); + img.onload = () => { + resolve({ width: img.naturalWidth, height: img.naturalHeight }); + }; + img.onerror = () => { + resolve({ width: 100, height: 100 }); + }; + img.src = url; + }); + +export default ImageGallery; diff --git a/src/pages/editor/components/sidebar/instructions/ImageUpload.tsx b/src/pages/editor/components/sidebar/instructions/ImageUpload.tsx index 54193d3..91303ba 100644 --- a/src/pages/editor/components/sidebar/instructions/ImageUpload.tsx +++ b/src/pages/editor/components/sidebar/instructions/ImageUpload.tsx @@ -1,67 +1 @@ -import { type FC, useState, useRef } from "react"; -import UploadBoxDraggble from "@/components/UploadBoxDraggble"; - -type ImageUploadProps = { - onImageUpload: (e: React.ChangeEvent) => void; -}; - -const ImageUpload: FC = ({ onImageUpload }) => { - const [uploadedImages, setUploadedImages] = useState([]); - const previousFilesCountRef = useRef(0); - - const handleFileChange = (files: File[]) => { - // Update ref to track current files count - if (files.length > previousFilesCountRef.current) { - // Process only new files - const newFiles = files.slice(previousFilesCountRef.current); - - newFiles.forEach((file) => { - const reader = new FileReader(); - reader.onload = (event) => { - const imageUrl = event.target?.result as string; - setUploadedImages((prev) => [...prev, imageUrl]); - - // Create a synthetic event for onImageUpload - const syntheticEvent = { - target: { - files: [file], - }, - } as unknown as React.ChangeEvent; - - onImageUpload(syntheticEvent); - }; - reader.readAsDataURL(file); - }); - } - - // Always update ref to track current files count - previousFilesCountRef.current = files.length; - }; - - const handlePreviewChange = (previews: string[]) => { - setUploadedImages(previews); - // Update ref when previews change (e.g., when files are deleted) - previousFilesCountRef.current = previews.length; - }; - - return ( -
-
تصویر
- - {uploadedImages.length > 0 && ( -
- تصویر های آپلود شده -
- )} -
- ); -}; - -export default ImageUpload; - +export { default } from "./ImageGallery"; diff --git a/src/pages/editor/components/sidebar/instructions/index.ts b/src/pages/editor/components/sidebar/instructions/index.ts index 246d41e..6d5ff69 100644 --- a/src/pages/editor/components/sidebar/instructions/index.ts +++ b/src/pages/editor/components/sidebar/instructions/index.ts @@ -1,3 +1,4 @@ +export { default as ImageGallery } from "./ImageGallery"; export { default as ImageUpload } from "./ImageUpload"; export { default as DocumentUpload } from "./DocumentUpload"; export { default as VideoInput } from "./VideoInput"; diff --git a/src/pages/editor/components/tools/ImageObject.tsx b/src/pages/editor/components/tools/ImageObject.tsx index 9e63e6a..7a6ed14 100644 --- a/src/pages/editor/components/tools/ImageObject.tsx +++ b/src/pages/editor/components/tools/ImageObject.tsx @@ -3,6 +3,7 @@ import { Image as KonvaImage } from "react-konva"; import Konva from "konva"; import useImage from "use-image"; import type { ShapeProps } from "./types"; +import { clampPositionToStage } from "../../utils/stageBounds"; const ImageObject = ({ obj, @@ -10,6 +11,8 @@ const ImageObject = ({ onSelect, onUpdate, draggable, + stageWidth, + stageHeight, }: ShapeProps) => { const [image] = useImage(obj.imageUrl || ""); const shapeRef = useRef(null); @@ -18,32 +21,58 @@ const ImageObject = ({ if (!image) return null; + const width = obj.width || image.width; + const height = obj.height || image.height; + const hasStageBounds = stageWidth != null && stageHeight != null; + return ( { - // فقط وقتی mask اعمال نشده onClick handler را فعال کن + dragBoundFunc={ + draggable && hasStageBounds + ? (pos) => + clampPositionToStage( + pos.x, + pos.y, + width, + height, + stageWidth!, + stageHeight!, + ) + : undefined + } + onClick={(e) => { if (shapeRef.current) { onSelect(obj.id, shapeRef.current, e); } - } : undefined} + }} onDragEnd={(e) => { const node = e.target; - onUpdate(obj.id, { - x: node.x(), - y: node.y(), - }); + let x = node.x(); + let y = node.y(); + if (hasStageBounds) { + ({ x, y } = clampPositionToStage( + x, + y, + width, + height, + stageWidth!, + stageHeight!, + )); + node.position({ x, y }); + } + onUpdate(obj.id, { x, y }); }} /> ); diff --git a/src/pages/editor/components/tools/types.ts b/src/pages/editor/components/tools/types.ts index b24496a..7932010 100644 --- a/src/pages/editor/components/tools/types.ts +++ b/src/pages/editor/components/tools/types.ts @@ -8,6 +8,8 @@ export type ShapeProps = { onSelect: (id: string, node: Konva.Node, e?: Konva.KonvaEventObject) => void; onUpdate: (id: string, updates: Partial) => void; draggable: boolean; + stageWidth?: number; + stageHeight?: number; }; export type TextShapeProps = ShapeProps & { diff --git a/src/pages/editor/store/editorStore.ts b/src/pages/editor/store/editorStore.ts index 24c24ba..001ca68 100644 --- a/src/pages/editor/store/editorStore.ts +++ b/src/pages/editor/store/editorStore.ts @@ -19,6 +19,7 @@ import type { TableData, ToolType, DocumentSettings, + GalleryItem, } from "./editorStore.types"; const MAX_OBJECT_HISTORY = 50; @@ -56,6 +57,7 @@ export type { TableData, ToolType, DocumentSettings, + GalleryItem, DisplayStyle, BackgroundType, EntranceAnimationType, @@ -149,6 +151,7 @@ type EditorStoreType = { loadPages: ( pages: Page[], documentSettings?: Partial, + gallery?: GalleryItem[], ) => void; resetEditor: () => void; clipboardObjects: EditorObject[]; @@ -166,6 +169,10 @@ type EditorStoreType = { updateCurrentPageBackground: ( updates: Partial, ) => void; + gallery: GalleryItem[]; + addGalleryItem: (item: GalleryItem) => void; + removeGalleryItem: (id: string) => void; + setGallery: (items: GalleryItem[]) => void; }; const DEFAULT_DOCUMENT_SETTINGS: DocumentSettings = { @@ -245,6 +252,14 @@ export const useEditorStore = create((set, get) => { ), }; }), + gallery: [], + addGalleryItem: (item) => + set((state) => ({ gallery: [...state.gallery, item] })), + removeGalleryItem: (id) => + set((state) => ({ + gallery: state.gallery.filter((item) => item.id !== id), + })), + setGallery: (items) => set({ gallery: items }), tool: "select", setTool: (tool) => set((state) => ({ @@ -999,9 +1014,10 @@ export const useEditorStore = create((set, get) => { objectHistoryPast: [], objectHistoryFuture: [], documentSettings: { ...DEFAULT_DOCUMENT_SETTINGS }, + gallery: [], }); }, - loadPages: (pages, documentSettings) => { + loadPages: (pages, documentSettings, gallery) => { if (pages.length === 0) return; const fallbackSettings = documentSettings ?? {}; const normalizedPages = pages.map((page) => ({ @@ -1036,6 +1052,7 @@ export const useEditorStore = create((set, get) => { ...(documentSettings && { documentSettings: { ...currentSettings, ...documentSettings }, }), + ...(gallery !== undefined && { gallery }), }); }, }; diff --git a/src/pages/editor/store/editorStore.types.ts b/src/pages/editor/store/editorStore.types.ts index f3d6380..ff2ae17 100644 --- a/src/pages/editor/store/editorStore.types.ts +++ b/src/pages/editor/store/editorStore.types.ts @@ -120,3 +120,11 @@ export type DocumentSettings = { backgroundGradient?: LinearGradient; backgroundImageUrl: string; }; + +/** تصویر آپلودشده در گالری کاتالوگ (مشترک بین همهٔ صفحات) */ +export type GalleryItem = { + id: string; + url: string; + width?: number; + height?: number; +}; diff --git a/src/pages/editor/types/GalleryTypes.ts b/src/pages/editor/types/GalleryTypes.ts new file mode 100644 index 0000000..c6b002c --- /dev/null +++ b/src/pages/editor/types/GalleryTypes.ts @@ -0,0 +1 @@ +export const GALLERY_DRAG_MIME = "application/x-dpage-gallery"; diff --git a/src/pages/editor/utils/imageUtils.ts b/src/pages/editor/utils/imageUtils.ts new file mode 100644 index 0000000..5564a99 --- /dev/null +++ b/src/pages/editor/utils/imageUtils.ts @@ -0,0 +1,51 @@ +import type { ToolType } from "../store/editorStore.types"; +import { clampPositionToStage, fitSizeToStage } from "./stageBounds"; + +const IMAGE_MAX_SIZE = 400; + +export const scaleImageDimensions = ( + naturalWidth: number, + naturalHeight: number, + maxSize = IMAGE_MAX_SIZE, +) => { + let width = naturalWidth; + let height = naturalHeight; + + if (width > maxSize || height > maxSize) { + const ratio = Math.min(maxSize / width, maxSize / height); + width = Math.round(width * ratio); + height = Math.round(height * ratio); + } + + return { width, height }; +}; + +export const createImageObject = ( + imageUrl: string, + centerX: number, + centerY: number, + width: number, + height: number, + stageWidth?: number, + stageHeight?: number, +) => { + let w = width; + let h = height; + if (stageWidth && stageHeight) { + ({ width: w, height: h } = fitSizeToStage(w, h, stageWidth, stageHeight)); + } + let x = Math.round(centerX - w / 2); + let y = Math.round(centerY - h / 2); + if (stageWidth && stageHeight) { + ({ x, y } = clampPositionToStage(x, y, w, h, stageWidth, stageHeight)); + } + return { + id: `image-${Date.now()}`, + type: "image" as ToolType, + x, + y, + width: w, + height: h, + imageUrl, + }; +}; diff --git a/src/pages/editor/utils/stageBounds.ts b/src/pages/editor/utils/stageBounds.ts new file mode 100644 index 0000000..8b3866c --- /dev/null +++ b/src/pages/editor/utils/stageBounds.ts @@ -0,0 +1,27 @@ +export const fitSizeToStage = ( + width: number, + height: number, + stageWidth: number, + stageHeight: number, +) => { + if (width <= stageWidth && height <= stageHeight) { + return { width, height }; + } + const ratio = Math.min(stageWidth / width, stageHeight / height); + return { + width: Math.round(width * ratio), + height: Math.round(height * ratio), + }; +}; + +export const clampPositionToStage = ( + x: number, + y: number, + width: number, + height: number, + stageWidth: number, + stageHeight: number, +) => ({ + x: Math.max(0, Math.min(stageWidth - width, Math.round(x))), + y: Math.max(0, Math.min(stageHeight - height, Math.round(y))), +}); diff --git a/src/pages/editor/utils/stickerUtils.ts b/src/pages/editor/utils/stickerUtils.ts index 03da14a..f368037 100644 --- a/src/pages/editor/utils/stickerUtils.ts +++ b/src/pages/editor/utils/stickerUtils.ts @@ -1,4 +1,5 @@ import type { ToolType } from "../store/editorStore.types"; +import { clampPositionToStage, fitSizeToStage } from "./stageBounds"; const STICKER_MAX_SIZE = 120; @@ -21,16 +22,30 @@ export const scaleStickerDimensions = ( export const createStickerObject = ( imageUrl: string, - x: number, - y: number, + centerX: number, + centerY: number, width: number, height: number, -) => ({ - id: `sticker-${Date.now()}`, - type: "sticker" as ToolType, - x: Math.round(x - width / 2), - y: Math.round(y - height / 2), - width, - height, - imageUrl, -}); + stageWidth?: number, + stageHeight?: number, +) => { + let w = width; + let h = height; + if (stageWidth && stageHeight) { + ({ width: w, height: h } = fitSizeToStage(w, h, stageWidth, stageHeight)); + } + let x = Math.round(centerX - w / 2); + let y = Math.round(centerY - h / 2); + if (stageWidth && stageHeight) { + ({ x, y } = clampPositionToStage(x, y, w, h, stageWidth, stageHeight)); + } + return { + id: `sticker-${Date.now()}`, + type: "sticker" as ToolType, + x, + y, + width: w, + height: h, + imageUrl, + }; +};