function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); } function stem(name) { return name.replace(/\.[^.]+$/, "") || "image"; } export async function prepareImageFile(file) { if (!file.type.startsWith("image/")) return file; const url = URL.createObjectURL(file); const image = new Image(); image.src = url; await image.decode(); const dialog = document.createElement("dialog"); dialog.className = "image-editor-dialog"; dialog.innerHTML = `

Adjust image

Keep the whole image or choose a crop, then select output size.

`; document.body.append(dialog); const canvas = dialog.querySelector("canvas"), ctx = canvas.getContext("2d"), stage = dialog.querySelector(".image-crop-stage"), zoomInput = dialog.querySelector("[data-zoom]"), aspectSelect = dialog.querySelector("[data-aspect]"), sizeSelect = dialog.querySelector("[data-size]"); let offsetX = 0, offsetY = 0, dragging = false, lastX = 0, lastY = 0, accepted = false; function cropBox() { const rect = stage.getBoundingClientRect(); let width = Math.max(280, rect.width), height = Math.min(520, Math.max(260, rect.height)); const aspect = aspectSelect.value === "original" ? image.naturalWidth / image.naturalHeight : aspectSelect.value === "free" ? width / height : Number(aspectSelect.value); if (width / height > aspect) width = height * aspect; else height = width / aspect; return { width: Math.round(width), height: Math.round(height) }; } function draw() { const box = cropBox(), dpr = Math.min(devicePixelRatio || 1, 2); canvas.width = Math.round(box.width * dpr); canvas.height = Math.round(box.height * dpr); canvas.style.width = `${box.width}px`; canvas.style.height = `${box.height}px`; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, box.width, box.height); const wholeImage = aspectSelect.value === "original"; zoomInput.disabled = wholeImage; const base = wholeImage ? Math.min(box.width / image.naturalWidth, box.height / image.naturalHeight) : Math.max(box.width / image.naturalWidth, box.height / image.naturalHeight), scale = base * (wholeImage ? 1 : Number(zoomInput.value)); const drawW = image.naturalWidth * scale, drawH = image.naturalHeight * scale; const maxX = Math.max(0, (drawW - box.width) / 2), maxY = Math.max(0, (drawH - box.height) / 2); offsetX = clamp(offsetX, -maxX, maxX); offsetY = clamp(offsetY, -maxY, maxY); ctx.drawImage(image, (box.width - drawW) / 2 + offsetX, (box.height - drawH) / 2 + offsetY, drawW, drawH); } function point(event) { const p = event.touches?.[0] || event; return { x: p.clientX, y: p.clientY }; } canvas.addEventListener("pointerdown", event => { dragging = true; canvas.setPointerCapture(event.pointerId); ({ x: lastX, y: lastY } = point(event)); }); canvas.addEventListener("pointermove", event => { if (!dragging) return; const p = point(event); offsetX += p.x - lastX; offsetY += p.y - lastY; lastX = p.x; lastY = p.y; draw(); }); canvas.addEventListener("pointerup", () => dragging = false); canvas.addEventListener("pointercancel", () => dragging = false); zoomInput.addEventListener("input", draw); aspectSelect.addEventListener("change", () => { offsetX = 0; offsetY = 0; draw(); }); window.addEventListener("resize", draw, { signal: (() => { const c = new AbortController(); dialog.addEventListener("close", () => c.abort(), { once: true }); return c.signal; })() }); const result = new Promise(resolve => { dialog.addEventListener("close", () => { URL.revokeObjectURL(url); dialog.remove(); resolve(accepted); }, { once: true }); dialog.querySelector("[data-apply]").addEventListener("click", async () => { const box = cropBox(), maxSize = Number(sizeSelect.value), out = document.createElement("canvas"); if (aspectSelect.value === "original") { const ratio = Math.min(1, maxSize ? maxSize / Math.max(image.naturalWidth, image.naturalHeight) : 1); out.width = Math.max(1, Math.round(image.naturalWidth * ratio)); out.height = Math.max(1, Math.round(image.naturalHeight * ratio)); out.getContext("2d").drawImage(image, 0, 0, out.width, out.height); } else { const ratio = Math.min(1, maxSize ? maxSize / Math.max(box.width, box.height) : 1); out.width = Math.max(1, Math.round(box.width * ratio)); out.height = Math.max(1, Math.round(box.height * ratio)); out.getContext("2d").drawImage(canvas, 0, 0, out.width, out.height); } const mime = file.type === "image/png" ? "image/png" : "image/jpeg"; const blob = await new Promise(r => out.toBlob(r, mime, mime === "image/jpeg" ? .88 : undefined)); const ext = mime === "image/png" ? "png" : "jpg"; accepted = new File([blob], `${stem(file.name)}-edited.${ext}`, { type: mime, lastModified: Date.now() }); dialog.close(); }); }); dialog.showModal(); requestAnimationFrame(draw); return result; }