From 0edd9fedf235d3db4a97f8b66c58dedeb685282f Mon Sep 17 00:00:00 2001 From: Michelle Zan Date: Thu, 8 Oct 2026 13:12:42 +0800 Subject: [PATCH] Fix cover crop image vanishing on zoom. Fit the image only after the modal is visible, and guard zoom against zero scale. Co-authored-by: Cursor --- server/public/admin.html | 86 +++++++++++++++++++++++++--------------- 1 file changed, 54 insertions(+), 32 deletions(-) diff --git a/server/public/admin.html b/server/public/admin.html index b782f82..70f5df0 100644 --- a/server/public/admin.html +++ b/server/public/admin.html @@ -52,7 +52,8 @@ } .crop-stage img { position: absolute; left: 0; top: 0; - max-width: none; cursor: grab; + max-width: none; max-height: none; width: auto; height: auto; + cursor: grab; will-change: left, top, width, height; } .crop-mask { position: absolute; left: 0; top: 0; pointer-events: none; @@ -169,23 +170,26 @@ function maskSize() { const stage = document.getElementById('cropStage') - const sw = stage.clientWidth - const sh = stage.clientHeight - let maskW = sw - 32 + const sw = Math.max(stage.clientWidth, 1) + const sh = Math.max(stage.clientHeight, 1) + let maskW = Math.max(sw - 32, 1) let maskH = maskW / COVER_RATIO if (maskH > sh - 32) { - maskH = sh - 32 + maskH = Math.max(sh - 32, 1) maskW = maskH * COVER_RATIO } return { maskW: maskW, maskH: maskH, maskLeft: (sw - maskW) / 2, - maskTop: (sh - maskH) / 2 + maskTop: (sh - maskH) / 2, + stageW: sw, + stageH: sh } } function minCropScale() { + if (!cropState.naturalW || !cropState.naturalH) return 1 const size = maskSize() return Math.max(size.maskW / cropState.naturalW, size.maskH / cropState.naturalH) } @@ -194,40 +198,51 @@ const stage = document.getElementById('cropStage') const img = document.getElementById('cropImage') const mask = document.getElementById('cropMask') - if (!stage || !img.naturalWidth || !cropState.naturalW) return + if (!stage || !cropState.naturalW || !cropState.scale) return const size = maskSize() - mask.style.right = 'auto' - mask.style.bottom = 'auto' mask.style.width = `${size.maskW}px` mask.style.height = `${size.maskH}px` mask.style.left = `${size.maskLeft}px` mask.style.top = `${size.maskTop}px` - img.style.width = `${cropState.naturalW * cropState.scale}px` - img.style.height = `${cropState.naturalH * cropState.scale}px` - img.style.transform = `translate(${cropState.x}px, ${cropState.y}px)` + const w = cropState.naturalW * cropState.scale + const h = cropState.naturalH * cropState.scale + img.style.width = `${w}px` + img.style.height = `${h}px` + img.style.left = `${cropState.x}px` + img.style.top = `${cropState.y}px` + img.style.transform = 'none' } function fitCropImage() { - const stage = document.getElementById('cropStage') + const size = maskSize() + // Modal must be visible first, otherwise stage size is 0 and scale collapses. + if (size.stageW < 40 || size.stageH < 40) return false const scale = minCropScale() cropState.scale = scale - cropState.x = (stage.clientWidth - cropState.naturalW * scale) / 2 - cropState.y = (stage.clientHeight - cropState.naturalH * scale) / 2 + cropState.x = (size.stageW - cropState.naturalW * scale) / 2 + cropState.y = (size.stageH - cropState.naturalH * scale) / 2 layoutCrop() + return true } function zoomCrop(factor) { if (!cropState.naturalW) return const size = maskSize() + if (size.stageW < 40 || size.stageH < 40) return + if (!cropState.scale || !Number.isFinite(cropState.scale)) { + fitCropImage() + return + } + const before = cropState.scale + const minScale = minCropScale() + const next = Math.min(Math.max(before * factor, minScale), minScale * 8) + if (!Number.isFinite(next) || Math.abs(next - before) < 0.0001) return const cx = size.maskLeft + size.maskW / 2 const cy = size.maskTop + size.maskH / 2 - const before = cropState.scale - const next = Math.min(Math.max(before * factor, minCropScale()), minCropScale() * 8) - if (Math.abs(next - before) < 0.0001) return // Keep the point under the crop center stable while zooming. - cropState.x = cx - ((cx - cropState.x) / before) * next - cropState.y = cy - ((cy - cropState.y) / before) * next + cropState.x = cx - ((cx - cropState.x) * next) / before + cropState.y = cy - ((cy - cropState.y) * next) / before cropState.scale = next layoutCrop() } @@ -237,13 +252,20 @@ const img = document.getElementById('cropImage') cropState.targetInput = targetInput cropState.hint = hint + cropState.scale = 0 + cropState.x = 0 + cropState.y = 0 const url = URL.createObjectURL(file) img.onload = () => { cropState.naturalW = img.naturalWidth cropState.naturalH = img.naturalHeight URL.revokeObjectURL(url) - fitCropImage() modal.classList.add('on') + requestAnimationFrame(() => { + if (!fitCropImage()) { + setTimeout(() => fitCropImage(), 50) + } + }) } img.src = url } @@ -252,20 +274,16 @@ document.getElementById('cropModal').classList.remove('on') document.getElementById('cropImage').removeAttribute('src') cropState.targetInput = null + cropState.scale = 0 } function exportCroppedFile() { - const stage = document.getElementById('cropStage') - const mask = document.getElementById('cropMask') const img = document.getElementById('cropImage') - const maskRect = mask.getBoundingClientRect() - const stageRect = stage.getBoundingClientRect() - const maskLeft = maskRect.left - stageRect.left - const maskTop = maskRect.top - stageRect.top - const sx = (maskLeft - cropState.x) / cropState.scale - const sy = (maskTop - cropState.y) / cropState.scale - const sw = maskRect.width / cropState.scale - const sh = maskRect.height / cropState.scale + const size = maskSize() + const sx = (size.maskLeft - cropState.x) / cropState.scale + const sy = (size.maskTop - cropState.y) / cropState.scale + const sw = size.maskW / cropState.scale + const sh = size.maskH / cropState.scale const outW = 1500 const outH = Math.round(outW / COVER_RATIO) const canvas = document.createElement('canvas') @@ -275,8 +293,12 @@ ctx.fillStyle = '#111827' ctx.fillRect(0, 0, outW, outH) ctx.drawImage(img, sx, sy, sw, sh, 0, 0, outW, outH) - return new Promise((resolve) => { + return new Promise((resolve, reject) => { canvas.toBlob((blob) => { + if (!blob) { + reject(new Error('裁剪失败')) + return + } resolve(new File([blob], `cover-${Date.now()}.jpg`, { type: 'image/jpeg' })) }, 'image/jpeg', 0.92) })