From 4df6dcc39f5b244f7f3ac8d676a8df2d3a4e9534 Mon Sep 17 00:00:00 2001 From: EruzaDev Date: Thu, 24 Sep 2026 23:05:05 +0800 Subject: [PATCH] feat: add per-photo move and resize in framing --- README.md | 1 + .../FrameCompositor/FrameEditor.jsx | 101 +++++++++++++++++- src/utils/canvasRenderer.js | 5 +- src/utils/smartCrop.js | 27 ++++- src/utils/zipExporter.js | 1 + 5 files changed, 127 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 2f164e0..235d62c 100644 --- a/README.md +++ b/README.md @@ -48,4 +48,5 @@ The output will be bundled into the `dist/` directory ready for static productio ### 🖼️ Automate Framing - **Smart Center-Crop Algorithm**: Scales and centers batch photos to fill transparent PNG frame boundaries without aspect ratio distortion. - **Per-Photo Color Adjustments**: Adjust brightness, contrast, hue, saturation, exposure, highlights, shadows, whites, blacks, color balance, levels, and tone curves above the output preview. Each photo keeps its own settings for the preview and ZIP export. +- **Per-Photo Placement**: Drag and resize the selected photo inside the shared frame or custom photo slot. Each photo keeps its own position and size in the batch export. - **Batch Download**: Export framed documentation photo batches into a single organized ZIP archive. diff --git a/src/components/FrameCompositor/FrameEditor.jsx b/src/components/FrameCompositor/FrameEditor.jsx index 14ef44e..06a13cd 100644 --- a/src/components/FrameCompositor/FrameEditor.jsx +++ b/src/components/FrameCompositor/FrameEditor.jsx @@ -18,12 +18,15 @@ import { EyeOff, BoxSelect, Sparkles, - Maximize2 + Maximize2, + Move, + RotateCcw } from 'lucide-react'; import { renderCanvasElement, loadImage, createCompressedThumbnail } from '../../utils/canvasRenderer'; import { exportBatchToZip } from '../../utils/zipExporter'; import { DEFAULT_PHOTO_ADJUSTMENTS } from '../../utils/photoAdjustments'; import PhotoAdjustmentsPanel from './PhotoAdjustmentsPanel'; +import { calculatePhotoPlacement, DEFAULT_PHOTO_TRANSFORM } from '../../utils/smartCrop'; export default function FrameEditor({ onStartExport, setExportStatus, onProgressChange, onRegisterCancel }) { const [frameSrc, setFrameSrc] = useState(null); @@ -37,6 +40,8 @@ export default function FrameEditor({ onStartExport, setExportStatus, onProgress const [activeDocImgObj, setActiveDocImgObj] = useState(null); const activeDoc = docImages[activeDocIdx] || null; const photoAdjustments = activeDoc?.photoAdjustments || DEFAULT_PHOTO_ADJUSTMENTS; + const photoTransform = activeDoc?.photoTransform || DEFAULT_PHOTO_TRANSFORM; + const [isEditingPhoto, setIsEditingPhoto] = useState(false); const updateActivePhotoAdjustments = (next) => { if (!activeDoc) return; setDocImages((previous) => previous.map((doc) => @@ -61,6 +66,7 @@ export default function FrameEditor({ onStartExport, setExportStatus, onProgress const [snapLines, setSnapLines] = useState({ showX: false, showY: false, xPos: 0, yPos: 0 }); const stageContainerRef = useRef(null); + const photoInteractionRef = useRef(null); // Export Quality & Safe Memory Settings const [exportResolution, setExportResolution] = useState(0); // Default 0 = Original (100% Native Quality) @@ -103,6 +109,49 @@ export default function FrameEditor({ onStartExport, setExportStatus, onProgress const previewWidth = Math.max(1, Math.round(canvasSize.width * previewScale)); const previewHeight = Math.max(1, Math.round(canvasSize.height * previewScale)); + const updateActivePhotoTransform = (next) => { + if (!activeDoc || !activeDocImgObj || activeDocImgObj.src !== activeDoc.src) return; + const placement = calculatePhotoPlacement( + activeDocImgObj.naturalWidth, activeDocImgObj.naturalHeight, + activeCropArea.width, activeCropArea.height, docAlignment, next + ); + const constrained = { scale: placement.scale, x: placement.x, y: placement.y }; + setDocImages((previous) => previous.map((doc) => + doc.id === activeDoc.id ? { ...doc, photoTransform: constrained } : doc + )); + }; + + const handlePhotoPointerDown = (event, resize = false) => { + if (!activeDoc || !activeDocImgObj || event.button > 0) return; + event.preventDefault(); + event.stopPropagation(); + const rect = photoInteractionRef.current?.getBoundingClientRect(); + if (!rect?.width || !rect?.height) return; + const startX = event.clientX; + const startY = event.clientY; + const startTransform = { ...photoTransform }; + const pointerId = event.pointerId; + const onMove = (moveEvent) => { + if (moveEvent.pointerId !== pointerId) return; + const dx = (moveEvent.clientX - startX) / rect.width; + const dy = (moveEvent.clientY - startY) / rect.height; + if (resize) { + updateActivePhotoTransform({ ...startTransform, scale: Math.max(1, Math.min(3, startTransform.scale + dx + dy)) }); + } else { + updateActivePhotoTransform({ ...startTransform, x: startTransform.x + dx, y: startTransform.y + dy }); + } + }; + const onEnd = (endEvent) => { + if (endEvent.pointerId !== pointerId) return; + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onEnd); + window.removeEventListener('pointercancel', onEnd); + }; + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onEnd); + window.addEventListener('pointercancel', onEnd); + }; + // Magnetic Snapping helper for custom photo slot const applySnapping = (xPct, yPct, wPct, hPct, displayW, displayH) => { const snapThreshold = 0.015; // 1.5% magnetic snap zone @@ -462,11 +511,12 @@ export default function FrameEditor({ onStartExport, setExportStatus, onProgress docImage: activeDocImgObj?.src === activeDoc?.src ? activeDocImgObj : null, docCropArea: activeCropArea, docAlignment, + docTransform: photoTransform, photoAdjustments }); }); return () => cancelAnimationFrame(frame); - }, [frameImgObj, frameOpacity, activeDocImgObj, activeDoc?.src, canvasSize, previewWidth, previewHeight, docAlignment, activeCropArea, photoAdjustments]); + }, [frameImgObj, frameOpacity, activeDocImgObj, activeDoc?.src, canvasSize, previewWidth, previewHeight, docAlignment, activeCropArea, photoTransform, photoAdjustments]); const handleFrameUpload = (e) => { const file = e.target.files?.[0]; @@ -564,6 +614,7 @@ export default function FrameEditor({ onStartExport, setExportStatus, onProgress name: doc.name.replace(/\.[^/.]+$/, ''), _docImageSrc: doc.src, _photoAdjustments: doc.photoAdjustments || DEFAULT_PHOTO_ADJUSTMENTS, + _docTransform: doc.photoTransform || DEFAULT_PHOTO_TRANSFORM, id: doc.id })); @@ -841,6 +892,7 @@ export default function FrameEditor({ onStartExport, setExportStatus, onProgress Active Slot )} +

This slot is shared by all photos. Move or resize a selected photo in the preview.

{/* Mode Selection Cards */}
@@ -1403,6 +1455,28 @@ export default function FrameEditor({ onStartExport, setExportStatus, onProgress onChange={updateActivePhotoAdjustments} /> +
+ + + +
+ {/* Stage Canvas Area */}
+ {isEditingPhoto && activeDocImgObj?.src === activeDoc?.src && ( +
handlePhotoPointerDown(event)} + className="absolute z-40 border-2 border-dashed border-cyan-400/80 bg-cyan-500/5 cursor-move touch-none select-none" + style={{ + left: `${activeCropArea.x / canvasSize.width * 100}%`, + top: `${activeCropArea.y / canvasSize.height * 100}%`, + width: `${activeCropArea.width / canvasSize.width * 100}%`, + height: `${activeCropArea.height / canvasSize.height * 100}%` + }} + aria-label="Drag photo to reposition it" + title="Drag to move the photo" + > + + Drag photo · resize from corner + +
+ )} + {/* Magnetic Snap Guidelines */} {photoSlotMode === 'custom' && snapLines.showX && (