diff --git a/src/index.html b/src/index.html index 95ede83..5b59269 100644 --- a/src/index.html +++ b/src/index.html @@ -15131,6 +15131,79 @@ return canvas; } + // Slide / Custom Art / Pixel Art standalone: one flat rectangle of + // layers on a background colour. Slides share the customart payload + // shape under `state.customArt`. + async function renderCustomArtToExportCanvas(dpi) { + const ca = state.customArt || { width: 1920, height: 1080, background: '#ffffff' }; + const wIn = (Number(ca.width) || 1920) / 96; + const hIn = (Number(ca.height) || 1080) / 96; + const canvas = document.createElement('canvas'); + const ctx = setupExportCanvas(canvas, wIn, hIn, dpi); + const bg = ca.background || '#ffffff'; + if (bg && bg.toLowerCase() !== '#ffffff' && bg.toLowerCase() !== '#fff') { + ctx.fillStyle = bg; + ctx.fillRect(0, 0, canvas.width, canvas.height); + } + const layers = (state.layers || []) + .filter(l => l.target === 'canvas' && !l.hidden && !l._generatedDiscLayer) + .slice() + .sort((a, b) => (a.z || 0) - (b.z || 0)); + for (const layer of layers) await drawLayerToCanvas(ctx, layer, dpi); + return canvas; + } + + // Pick the right rasterizer for the current artboard, render, encode as + // JPEG. Returns one of: + // { ok: true, blob } — direct JPEG ready to download + // { ok: false, kind: 'unsupported' } — caller should fall back + // { ok: false, kind: 'cors', urls } — same; this PDF route handles it + // { ok: false, kind: 'taint' } — canvas was tainted (last-resort fallback) + async function exportArtboardAsJpegBlob(opts = {}) { + const dpi = Math.max(72, Math.min(600, opts.dpi | 0 || 150)); + const quality = typeof opts.quality === 'number' ? opts.quality : 0.92; + _exportFailedImages.clear(); + const mode = state.templateMode; + let canvas = null; + try { + if (state.appMode === 'designer') { + canvas = await renderDiscDesignToExportCanvas(dpi); + } else if (mode === 'cover') { + canvas = await renderCoverToExportCanvas(dpi); + } else if (mode === 'disc') { + canvas = await renderDiscSheetToExportCanvas(dpi); + } else if (mode === 'jewel') { + canvas = await renderJewelSheetToExportCanvas(dpi); + } else if (mode === 'customart' || mode === 'slide') { + canvas = await renderCustomArtToExportCanvas(dpi); + } else { + // pixelart standalone, deck — not supported by the direct rasterizer. + // (Pixel art has its own PNG export, deck has the multi-page PDF.) + return { ok: false, kind: 'unsupported' }; + } + } catch (e) { + return { ok: false, kind: 'unsupported', err: String(e) }; + } + // Any image we couldn't load (CORS, timeout, 404) means the JPEG + // would be missing content the user would expect. Route to the PDF + // path instead — the browser's print engine has a separate image + // pipeline that handles cross-origin embeds the canvas can't touch. + if (_exportFailedImages.size > 0) { + return { ok: false, kind: 'cors', urls: Array.from(_exportFailedImages) }; + } + try { + const blob = await new Promise((resolve, reject) => { + canvas.toBlob((b) => b ? resolve(b) : reject(new Error('toBlob returned null')), 'image/jpeg', quality); + }); + return { ok: true, blob }; + } catch (e) { + // toBlob throws SecurityError on a tainted canvas. Shouldn't happen + // because loadImageWithCors rejects before drawImage on a CORS- + // blocked image, but kept as a defensive fallback. + return { ok: false, kind: 'taint', err: String(e) }; + } + } + function jpegFilenameStem() { const parts = [ filenamePart(state.meta?.title) || 'design', @@ -15203,7 +15276,43 @@ m.setAttribute('aria-hidden', 'true'); } - function exportArtboardAsJpeg() { + // The Export-JPEG entry point. Tries the direct canvas rasterizer first + // (one click, no print dialog, no re-upload). Falls back to the existing + // print-and-pick-PDF modal for canvas types that aren't covered yet + // (pixelart, deck) or designs with CORS-blocked remote images. + async function exportArtboardAsJpeg(btn) { + const restoreBtn = (text) => { if (btn) { btn.disabled = false; if (text != null) btn.textContent = text; } }; + const prevText = btn?.textContent; + if (btn) { btn.disabled = true; btn.textContent = 'Rendering…'; } + let result; + try { + result = await exportArtboardAsJpegBlob({ dpi: 150, quality: 0.92 }); + } catch (e) { + toastError && toastError('JPEG render failed — falling back to PDF flow.'); + restoreBtn(prevText); + openPdfJpegModal(); + return; + } + if (result.ok) { + const url = URL.createObjectURL(result.blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${jpegFilenameStem()}.jpg`; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + toastSuccess && toastSuccess('JPEG saved.'); + restoreBtn(prevText); + return; + } + // Fall back to the existing print-to-PDF + re-pick flow. + restoreBtn(prevText); + if (result.kind === 'cors') { + toastError && toastError(`${result.urls.length} image(s) couldn't be embedded directly — switching to the PDF route.`); + } else if (result.kind === 'unsupported') { + toastError && toastError('Direct JPEG not supported for this template — using the PDF route.'); + } openPdfJpegModal(); }