/** Controlled image export dialog and canvas encoding helpers. */ const FORMAT_META = { png: { mime: 'image/png', extension: 'png', quality: undefined }, jpeg: { mime: 'image/jpeg', extension: 'jpg', quality: 0.9 }, webp: { mime: 'image/webp', extension: 'webp', quality: 0.9 }, }; function clampNumber(value, min, max, fallback) { const parsed = Number(value); return Number.isFinite(parsed) ? Math.min(max, Math.max(min, parsed)) : fallback; } export function normalizeExportSettings(settings, sourceWidth, sourceHeight) { const format = FORMAT_META[settings?.format] ? settings.format : 'png'; return { format, width: Math.round(clampNumber(settings?.width, 1, 16384, sourceWidth)), height: Math.round(clampNumber(settings?.height, 1, 16384, sourceHeight)), quality: clampNumber(settings?.quality, 0.01, 1, FORMAT_META[format].quality ?? 1), transparency: format !== 'jpeg' && settings?.transparency !== false, matte: /^#[0-9a-f]{6}$/i.test(settings?.matte || '') ? settings.matte : '#ffffff', filename: String(settings?.filename || 'edited-image').trim() || 'edited-image', }; } export function prepareExportCanvas(sourceCanvas, settings) { const normalized = normalizeExportSettings(settings, sourceCanvas.width, sourceCanvas.height); const canvas = document.createElement('canvas'); canvas.width = normalized.width; canvas.height = normalized.height; const ctx = canvas.getContext('2d'); ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; if (!normalized.transparency) { ctx.fillStyle = normalized.matte; ctx.fillRect(0, 0, canvas.width, canvas.height); } ctx.drawImage(sourceCanvas, 0, 0, canvas.width, canvas.height); return canvas; } export function encodeExportCanvas(sourceCanvas, settings) { const normalized = normalizeExportSettings(settings, sourceCanvas.width, sourceCanvas.height); const output = prepareExportCanvas(sourceCanvas, normalized); const meta = FORMAT_META[normalized.format]; return new Promise((resolve, reject) => { output.toBlob( blob => blob ? resolve({ blob, settings: normalized, extension: meta.extension }) : reject(new Error('Image encoding failed')), meta.mime, meta.quality == null ? undefined : normalized.quality, ); }); } function formatBytes(bytes) { if (!Number.isFinite(bytes) || bytes < 0) return 'Estimating...'; if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; return `${(bytes / 1024 / 1024).toFixed(1)} MB`; } export function openExportDialog({ sourceCanvas, defaultName = 'edited-image', title = 'Export Image', submitLabel = 'Export', attachColorPicker = null, returnFocus = null, }) { return new Promise(resolve => { const previouslyFocused = returnFocus || document.activeElement; const overlay = document.createElement('div'); overlay.className = 'ge-export-overlay'; overlay.innerHTML = ` `; document.body.appendChild(overlay); const dialog = overlay.querySelector('.ge-export-dialog'); const preview = overlay.querySelector('.ge-export-preview'); const filename = overlay.querySelector('#ge-export-filename'); const widthInput = overlay.querySelector('#ge-export-width'); const heightInput = overlay.querySelector('#ge-export-height'); const qualityInput = overlay.querySelector('#ge-export-quality'); const qualityField = overlay.querySelector('.ge-export-quality'); const qualityOutput = qualityField.querySelector('output'); const transparency = overlay.querySelector('#ge-export-transparency'); const matte = overlay.querySelector('#ge-export-matte'); const matteField = overlay.querySelector('.ge-export-matte'); const estimate = overlay.querySelector('.ge-export-estimate'); const link = overlay.querySelector('.ge-export-link'); const ratio = sourceCanvas.width / sourceCanvas.height; let format = 'png'; let ratioLocked = true; let estimateTimer = null; let estimateVersion = 0; try { attachColorPicker?.(matte); } catch {} const settings = () => normalizeExportSettings({ format, width: widthInput.value, height: heightInput.value, quality: Number(qualityInput.value) / 100, transparency: transparency.checked, matte: matte.value, filename: filename.value, }, sourceCanvas.width, sourceCanvas.height); const renderPreview = () => { const current = settings(); const max = 420; const scale = Math.min(max / current.width, max / current.height, 1); preview.width = Math.max(1, Math.round(current.width * scale)); preview.height = Math.max(1, Math.round(current.height * scale)); const ctx = preview.getContext('2d'); // Settings can change without changing dimensions, so do not let the // previous matte or alpha state bleed into the next preview. ctx.clearRect(0, 0, preview.width, preview.height); if (!current.transparency) { ctx.fillStyle = current.matte; ctx.fillRect(0, 0, preview.width, preview.height); } ctx.drawImage(sourceCanvas, 0, 0, preview.width, preview.height); }; const update = () => { const lossy = format !== 'png'; qualityField.hidden = !lossy; transparency.disabled = format === 'jpeg'; if (format === 'jpeg') transparency.checked = false; matteField.classList.toggle('disabled', transparency.checked && format !== 'jpeg'); qualityOutput.textContent = `${qualityInput.value}%`; renderPreview(); estimate.textContent = 'Estimating...'; clearTimeout(estimateTimer); const version = ++estimateVersion; estimateTimer = setTimeout(async () => { try { const encoded = await encodeExportCanvas(sourceCanvas, settings()); if (version === estimateVersion) estimate.textContent = formatBytes(encoded.blob.size); } catch { if (version === estimateVersion) estimate.textContent = 'Estimate unavailable'; } }, 180); }; overlay.querySelectorAll('[data-format]').forEach(button => { button.addEventListener('click', () => { format = button.dataset.format; overlay.querySelectorAll('[data-format]').forEach(candidate => { candidate.classList.toggle('active', candidate.dataset.format === format); }); update(); }); }); link.addEventListener('click', () => { ratioLocked = !ratioLocked; link.classList.toggle('active', ratioLocked); link.setAttribute('aria-pressed', ratioLocked ? 'true' : 'false'); }); widthInput.addEventListener('input', () => { if (ratioLocked) heightInput.value = String(Math.max(1, Math.round(Number(widthInput.value || 1) / ratio))); update(); }); heightInput.addEventListener('input', () => { if (ratioLocked) widthInput.value = String(Math.max(1, Math.round(Number(heightInput.value || 1) * ratio))); update(); }); [qualityInput, transparency, matte].forEach(input => input.addEventListener('input', update)); const close = result => { clearTimeout(estimateTimer); document.removeEventListener('keydown', onKey, true); overlay.remove(); if (previouslyFocused && previouslyFocused.isConnected && typeof previouslyFocused.focus === 'function') { previouslyFocused.focus({ preventScroll: true }); } resolve(result); }; const onKey = event => { if (event.key === 'Escape') { event.preventDefault(); close(null); } }; document.addEventListener('keydown', onKey, true); overlay.querySelector('.ge-export-close').addEventListener('click', () => close(null)); overlay.querySelector('.ge-export-cancel').addEventListener('click', () => close(null)); overlay.addEventListener('click', event => { if (event.target === overlay) close(null); }); dialog.addEventListener('submit', event => { event.preventDefault(); close(settings()); }); update(); filename.select(); }); }