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 <cursoragent@cursor.com>
This commit is contained in:
+54
-32
@@ -52,7 +52,8 @@
|
|||||||
}
|
}
|
||||||
.crop-stage img {
|
.crop-stage img {
|
||||||
position: absolute; left: 0; top: 0;
|
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 {
|
.crop-mask {
|
||||||
position: absolute; left: 0; top: 0; pointer-events: none;
|
position: absolute; left: 0; top: 0; pointer-events: none;
|
||||||
@@ -169,23 +170,26 @@
|
|||||||
|
|
||||||
function maskSize() {
|
function maskSize() {
|
||||||
const stage = document.getElementById('cropStage')
|
const stage = document.getElementById('cropStage')
|
||||||
const sw = stage.clientWidth
|
const sw = Math.max(stage.clientWidth, 1)
|
||||||
const sh = stage.clientHeight
|
const sh = Math.max(stage.clientHeight, 1)
|
||||||
let maskW = sw - 32
|
let maskW = Math.max(sw - 32, 1)
|
||||||
let maskH = maskW / COVER_RATIO
|
let maskH = maskW / COVER_RATIO
|
||||||
if (maskH > sh - 32) {
|
if (maskH > sh - 32) {
|
||||||
maskH = sh - 32
|
maskH = Math.max(sh - 32, 1)
|
||||||
maskW = maskH * COVER_RATIO
|
maskW = maskH * COVER_RATIO
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
maskW: maskW,
|
maskW: maskW,
|
||||||
maskH: maskH,
|
maskH: maskH,
|
||||||
maskLeft: (sw - maskW) / 2,
|
maskLeft: (sw - maskW) / 2,
|
||||||
maskTop: (sh - maskH) / 2
|
maskTop: (sh - maskH) / 2,
|
||||||
|
stageW: sw,
|
||||||
|
stageH: sh
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function minCropScale() {
|
function minCropScale() {
|
||||||
|
if (!cropState.naturalW || !cropState.naturalH) return 1
|
||||||
const size = maskSize()
|
const size = maskSize()
|
||||||
return Math.max(size.maskW / cropState.naturalW, size.maskH / cropState.naturalH)
|
return Math.max(size.maskW / cropState.naturalW, size.maskH / cropState.naturalH)
|
||||||
}
|
}
|
||||||
@@ -194,40 +198,51 @@
|
|||||||
const stage = document.getElementById('cropStage')
|
const stage = document.getElementById('cropStage')
|
||||||
const img = document.getElementById('cropImage')
|
const img = document.getElementById('cropImage')
|
||||||
const mask = document.getElementById('cropMask')
|
const mask = document.getElementById('cropMask')
|
||||||
if (!stage || !img.naturalWidth || !cropState.naturalW) return
|
if (!stage || !cropState.naturalW || !cropState.scale) return
|
||||||
const size = maskSize()
|
const size = maskSize()
|
||||||
mask.style.right = 'auto'
|
|
||||||
mask.style.bottom = 'auto'
|
|
||||||
mask.style.width = `${size.maskW}px`
|
mask.style.width = `${size.maskW}px`
|
||||||
mask.style.height = `${size.maskH}px`
|
mask.style.height = `${size.maskH}px`
|
||||||
mask.style.left = `${size.maskLeft}px`
|
mask.style.left = `${size.maskLeft}px`
|
||||||
mask.style.top = `${size.maskTop}px`
|
mask.style.top = `${size.maskTop}px`
|
||||||
|
|
||||||
img.style.width = `${cropState.naturalW * cropState.scale}px`
|
const w = cropState.naturalW * cropState.scale
|
||||||
img.style.height = `${cropState.naturalH * cropState.scale}px`
|
const h = cropState.naturalH * cropState.scale
|
||||||
img.style.transform = `translate(${cropState.x}px, ${cropState.y}px)`
|
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() {
|
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()
|
const scale = minCropScale()
|
||||||
cropState.scale = scale
|
cropState.scale = scale
|
||||||
cropState.x = (stage.clientWidth - cropState.naturalW * scale) / 2
|
cropState.x = (size.stageW - cropState.naturalW * scale) / 2
|
||||||
cropState.y = (stage.clientHeight - cropState.naturalH * scale) / 2
|
cropState.y = (size.stageH - cropState.naturalH * scale) / 2
|
||||||
layoutCrop()
|
layoutCrop()
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
function zoomCrop(factor) {
|
function zoomCrop(factor) {
|
||||||
if (!cropState.naturalW) return
|
if (!cropState.naturalW) return
|
||||||
const size = maskSize()
|
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 cx = size.maskLeft + size.maskW / 2
|
||||||
const cy = size.maskTop + size.maskH / 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.
|
// Keep the point under the crop center stable while zooming.
|
||||||
cropState.x = cx - ((cx - cropState.x) / before) * next
|
cropState.x = cx - ((cx - cropState.x) * next) / before
|
||||||
cropState.y = cy - ((cy - cropState.y) / before) * next
|
cropState.y = cy - ((cy - cropState.y) * next) / before
|
||||||
cropState.scale = next
|
cropState.scale = next
|
||||||
layoutCrop()
|
layoutCrop()
|
||||||
}
|
}
|
||||||
@@ -237,13 +252,20 @@
|
|||||||
const img = document.getElementById('cropImage')
|
const img = document.getElementById('cropImage')
|
||||||
cropState.targetInput = targetInput
|
cropState.targetInput = targetInput
|
||||||
cropState.hint = hint
|
cropState.hint = hint
|
||||||
|
cropState.scale = 0
|
||||||
|
cropState.x = 0
|
||||||
|
cropState.y = 0
|
||||||
const url = URL.createObjectURL(file)
|
const url = URL.createObjectURL(file)
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
cropState.naturalW = img.naturalWidth
|
cropState.naturalW = img.naturalWidth
|
||||||
cropState.naturalH = img.naturalHeight
|
cropState.naturalH = img.naturalHeight
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
fitCropImage()
|
|
||||||
modal.classList.add('on')
|
modal.classList.add('on')
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!fitCropImage()) {
|
||||||
|
setTimeout(() => fitCropImage(), 50)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
img.src = url
|
img.src = url
|
||||||
}
|
}
|
||||||
@@ -252,20 +274,16 @@
|
|||||||
document.getElementById('cropModal').classList.remove('on')
|
document.getElementById('cropModal').classList.remove('on')
|
||||||
document.getElementById('cropImage').removeAttribute('src')
|
document.getElementById('cropImage').removeAttribute('src')
|
||||||
cropState.targetInput = null
|
cropState.targetInput = null
|
||||||
|
cropState.scale = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportCroppedFile() {
|
function exportCroppedFile() {
|
||||||
const stage = document.getElementById('cropStage')
|
|
||||||
const mask = document.getElementById('cropMask')
|
|
||||||
const img = document.getElementById('cropImage')
|
const img = document.getElementById('cropImage')
|
||||||
const maskRect = mask.getBoundingClientRect()
|
const size = maskSize()
|
||||||
const stageRect = stage.getBoundingClientRect()
|
const sx = (size.maskLeft - cropState.x) / cropState.scale
|
||||||
const maskLeft = maskRect.left - stageRect.left
|
const sy = (size.maskTop - cropState.y) / cropState.scale
|
||||||
const maskTop = maskRect.top - stageRect.top
|
const sw = size.maskW / cropState.scale
|
||||||
const sx = (maskLeft - cropState.x) / cropState.scale
|
const sh = size.maskH / cropState.scale
|
||||||
const sy = (maskTop - cropState.y) / cropState.scale
|
|
||||||
const sw = maskRect.width / cropState.scale
|
|
||||||
const sh = maskRect.height / cropState.scale
|
|
||||||
const outW = 1500
|
const outW = 1500
|
||||||
const outH = Math.round(outW / COVER_RATIO)
|
const outH = Math.round(outW / COVER_RATIO)
|
||||||
const canvas = document.createElement('canvas')
|
const canvas = document.createElement('canvas')
|
||||||
@@ -275,8 +293,12 @@
|
|||||||
ctx.fillStyle = '#111827'
|
ctx.fillStyle = '#111827'
|
||||||
ctx.fillRect(0, 0, outW, outH)
|
ctx.fillRect(0, 0, outW, outH)
|
||||||
ctx.drawImage(img, sx, sy, sw, sh, 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) => {
|
canvas.toBlob((blob) => {
|
||||||
|
if (!blob) {
|
||||||
|
reject(new Error('裁剪失败'))
|
||||||
|
return
|
||||||
|
}
|
||||||
resolve(new File([blob], `cover-${Date.now()}.jpg`, { type: 'image/jpeg' }))
|
resolve(new File([blob], `cover-${Date.now()}.jpg`, { type: 'image/jpeg' }))
|
||||||
}, 'image/jpeg', 0.92)
|
}, 'image/jpeg', 0.92)
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user