RSVP Card Studio

Design personalised RSVP & invitation cards. Batch-print one per guest household or export as PDF.

Live Preview
Loading…
`; const win = window.open('', '_blank'); if (!win) { alert('Allow pop-ups for the RSVP Card Studio print preview.'); return; } win.document.open(); win.document.write(pageHtml); win.document.close(); win.focus(); } function sanitizeFilePart(v){ return String(v || 'invite').trim().replace(/[^a-z0-9]+/gi, '_').replace(/^_+|_+$/g, '') || 'invite'; } function loadImageMaybe(src){ return new Promise((resolve) => { if (!src) { resolve(null); return; } const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => resolve(img); img.onerror = () => resolve(null); img.src = src; }); } function hexToRgba(hex, alpha){ const safe = String(hex || '#000000').replace('#',''); const n = safe.length === 3 ? safe.split('').map(c => c + c).join('') : safe.padEnd(6,'0').slice(0,6); const r = parseInt(n.slice(0,2), 16) || 0, g = parseInt(n.slice(2,4), 16) || 0, b = parseInt(n.slice(4,6), 16) || 0; return `rgba(${r},${g},${b},${alpha})`; } function wrapText(ctx, text, maxWidth){ const words = String(text || '').split(/\s+/); const lines = []; let line = ''; words.forEach(word => { const test = line ? line + ' ' + word : word; if (ctx.measureText(test).width > maxWidth && line) { lines.push(line); line = word; } else line = test; }); if (line) lines.push(line); return lines; } async function renderCardCanvasBlob(template, ctxData){ const [w,h] = cardSizes[template.size][template.orientation]; const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d'); ctx.fillStyle = template.bgColor || '#111111'; ctx.fillRect(0,0,w,h); const bgImg = await loadImageMaybe(template.bgImage); if (bgImg) { ctx.drawImage(bgImg, 0, 0, w, h); const overlayAlpha = Number(template.overlay || 0) / 100; if (overlayAlpha > 0) { ctx.fillStyle = hexToRgba('#000000', overlayAlpha); ctx.fillRect(0,0,w,h); } } const mainImg = await loadImageMaybe(template.mainPhoto); if (mainImg) { const x = Number(template.imageLayer.x || 0), y = Number(template.imageLayer.y || 0), iw = Number(template.imageLayer.w || 100), ih = Number(template.imageLayer.h || 100); ctx.save(); const radius = 14; ctx.beginPath(); ctx.moveTo(x+radius,y); ctx.arcTo(x+iw,y,x+iw,y+ih,radius); ctx.arcTo(x+iw,y+ih,x,y+ih,radius); ctx.arcTo(x,y+ih,x,y,radius); ctx.arcTo(x,y,x+iw,y,radius); ctx.closePath(); ctx.clip(); const boxRatio = iw / ih; const imgRatio = mainImg.width / Math.max(1, mainImg.height); let sx = 0, sy = 0, sw = mainImg.width, sh = mainImg.height; if (imgRatio > boxRatio) { sw = Math.round(mainImg.height * boxRatio); sx = Math.round((mainImg.width - sw) / 2); } else { sh = Math.round(mainImg.width / boxRatio); sy = Math.round((mainImg.height - sh) / 2); } ctx.drawImage(mainImg, sx, sy, sw, sh, x, y, iw, ih); ctx.restore(); } (template.layers || []).forEach(layer => { const textValue = applyMerge(layer.text, ctxData); const fontSize = Number(layer.fontSize || 24); ctx.fillStyle = layer.color || template.textColor || '#ffffff'; ctx.textBaseline = 'top'; ctx.font = `${fontSize}px ${layer.fontFamily || 'Arial, sans-serif'}`; const lines = wrapText(ctx, textValue, Number(layer.w || 200)); const lineHeight = fontSize * 1.2; lines.forEach((line, idx) => { let x = Number(layer.x || 0); if ((layer.align || 'left') === 'center') { const mw = ctx.measureText(line).width; x = Number(layer.x || 0) + (Number(layer.w || 200) - mw) / 2; } else if ((layer.align || 'left') === 'right') { const mw = ctx.measureText(line).width; x = Number(layer.x || 0) + Number(layer.w || 200) - mw; } ctx.fillText(line, x, Number(layer.y || 0) + idx * lineHeight); }); }); return await new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', 0.92)); } async function downloadBatchJpegs(){ const batch = buildBatchPreview(); if (!batch.length) { alert('No invitation groups are in the current batch.'); return; } const template = JSON.parse(JSON.stringify(currentTemplate)); for (let i = 0; i < batch.length; i += 1) { const item = batch[i]; const blob = await renderCardCanvasBlob(template, { guest_name: item.guest_name, primary_guest_name: item.primary_guest_name, rsvp_code: item.rsvp_code, guest_allotment_text: item.guest_allotment_text, event_date: item.event_date, event_location: item.event_location, mailing_name: item.mailing_name || item.primary_guest_name || item.guest_name, mailing_address: item.mailing_address || 'Mailing address to be added later' }); if (!blob) continue; const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; const batchBase = sanitizeFilePart(els.printRunNameInput.value.trim() || 'rsvp_batch'); a.download = `${batchBase}_${String(i+1).padStart(3,'0')}_${sanitizeFilePart(item.guest_name)}_${sanitizeFilePart(item.rsvp_code)}.jpg`; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 5000); await new Promise(r => setTimeout(r, 180)); } } [els.layerX,els.layerY,els.layerW,els.layerH].forEach(input => input.addEventListener('input',()=>{ const layer = selectedLayer(); layer.x=Number(els.layerX.value||0); layer.y=Number(els.layerY.value||0); layer.w=Number(els.layerW.value||0); layer.h=Number(els.layerH.value||0); renderCanvas(); saveWholeState(); })); els.layerText.addEventListener('input',()=>{ const layer=selectedLayer(); if(selectedLayerId==='mainImage') return; layer.text=els.layerText.value; renderCanvas(); markStudioDirty(); saveWholeState(); }); els.fontFamilySelect.addEventListener('change',()=>{ const layer=selectedLayer(); layer.fontFamily=els.fontFamilySelect.value; renderCanvas(); markStudioDirty(); saveWholeState(); }); els.fontSizeInput.addEventListener('input',()=>{ const layer=selectedLayer(); layer.fontSize=Number(els.fontSizeInput.value||18); renderCanvas(); markStudioDirty(); saveWholeState(); }); els.fontColorInput.addEventListener('input',()=>{ const layer=selectedLayer(); layer.color=els.fontColorInput.value; renderCanvas(); markStudioDirty(); saveWholeState(); }); els.textAlignSelect.addEventListener('change',()=>{ const layer=selectedLayer(); layer.align=els.textAlignSelect.value; renderCanvas(); markStudioDirty(); saveWholeState(); }); els.cardSizeSelect.addEventListener('change',()=>{ currentTemplate.size=els.cardSizeSelect.value; renderCanvas(); markStudioDirty(); saveWholeState(); }); els.orientationSelect.addEventListener('change',()=>{ currentTemplate.orientation=els.orientationSelect.value; renderCanvas(); markStudioDirty(); saveWholeState(); }); els.bgColorInput.addEventListener('input',()=>{ currentTemplate.bgColor=els.bgColorInput.value; renderCanvas(); markStudioDirty(); saveWholeState(); }); els.textColorInput.addEventListener('input',()=>{ currentTemplate.textColor=els.textColorInput.value; currentTemplate.layers.forEach(l=>{ if(!l.color) l.color=currentTemplate.textColor; }); renderCanvas(); markStudioDirty(); saveWholeState(); }); els.accentColorInput.addEventListener('input',()=>{ currentTemplate.accentColor=els.accentColorInput.value; markStudioDirty(); saveWholeState(); }); els.overlayInput.addEventListener('input',()=>{ currentTemplate.overlay=Number(els.overlayInput.value||0); renderCanvas(); markStudioDirty(); saveWholeState(); }); function _rsvpReadFile(file){return new Promise(res=>{const fr=new FileReader();fr.onload=e=>res(e.target.result);fr.readAsDataURL(file);});} function _rsvpShowThumb(thumbId,wrapId,src){const th=document.getElementById(thumbId),wr=document.getElementById(wrapId);if(src&&th&&wr){th.src=src;wr.style.display='';}else if(th&&wr){th.src='';wr.style.display='none';}} els.mainPhotoUpload.addEventListener('change',async()=>{ const file=els.mainPhotoUpload.files&&els.mainPhotoUpload.files[0]; if(!file) return; currentTemplate.mainPhoto=await _rsvpReadFile(file); _rsvpShowThumb('mainPhotoPreviewThumb','mainPhotoPreviewWrap',currentTemplate.mainPhoto); renderCanvas(); markStudioDirty(); saveWholeState(); }); document.getElementById('mainPhotoClearBtn').addEventListener('click',()=>{ currentTemplate.mainPhoto=''; els.mainPhotoUpload.value=''; _rsvpShowThumb('mainPhotoPreviewThumb','mainPhotoPreviewWrap',''); renderCanvas(); markStudioDirty(); saveWholeState(); }); document.getElementById('bgImageUpload').addEventListener('change',async()=>{ const el2=document.getElementById('bgImageUpload'); const file=el2.files&&el2.files[0]; if(!file) return; currentTemplate.bgImage=await _rsvpReadFile(file); _rsvpShowThumb('bgImagePreviewThumb','bgImagePreviewWrap',currentTemplate.bgImage); renderCanvas(); markStudioDirty(); saveWholeState(); }); document.getElementById('bgImageClearBtn').addEventListener('click',()=>{ currentTemplate.bgImage=''; document.getElementById('bgImageUpload').value=''; _rsvpShowThumb('bgImagePreviewThumb','bgImagePreviewWrap',''); renderCanvas(); markStudioDirty(); saveWholeState(); }); els.previewGuestSelect.addEventListener('change',()=>{ buildBatchPreview(); renderCanvas(); }); els.batchScopeSelect.addEventListener('change',()=>{ buildBatchPreview(); renderCanvas(); }); els.printModeSelect.addEventListener('change',()=>{ buildBatchPreview(); }); qs('#addTextLayerBtn').addEventListener('click',()=>{ const layer={ id:uid(), type:'text', x:120, y:120, w:500, h:80, text:'New text block', fontFamily:'Georgia, serif', fontSize:36, color:currentTemplate.textColor, align:'center' }; currentTemplate.layers.push(layer); selectedLayerId=layer.id; renderCanvas(); markStudioDirty(); saveWholeState(); }); qs('#duplicateLayerBtn').addEventListener('click',()=>{ if(selectedLayerId==='mainImage') return; const layer=selectedLayer(); if(!layer) return; const dup=JSON.parse(JSON.stringify(layer)); dup.id=uid(); dup.x+=30; dup.y+=30; currentTemplate.layers.push(dup); selectedLayerId=dup.id; renderCanvas(); markStudioDirty(); saveWholeState(); }); qs('#deleteLayerBtn').addEventListener('click',()=>{ if(selectedLayerId==='mainImage') return; currentTemplate.layers=currentTemplate.layers.filter(l=>l.id!==selectedLayerId); selectedLayerId=currentTemplate.layers[0]?.id || 'mainImage'; renderCanvas(); markStudioDirty(); saveWholeState(); }); qsa('.merge-btn').forEach(btn=>btn.addEventListener('click',()=>{ if(selectedLayerId==='mainImage') return; const layer=selectedLayer(); layer.text = (layer.text || '') + ' ' + btn.dataset.merge; renderCanvas(); saveWholeState(); })); if (els.backTitleInput) els.backTitleInput.addEventListener('input',()=>{ currentTemplate.backSide = currentTemplate.backSide || {}; currentTemplate.backSide.title = els.backTitleInput.value; markStudioDirty(); saveWholeState(); }); if (els.backMessageInput) els.backMessageInput.addEventListener('input',()=>{ currentTemplate.backSide = currentTemplate.backSide || {}; currentTemplate.backSide.message = els.backMessageInput.value; markStudioDirty(); saveWholeState(); }); if (els.returnAddressInput) els.returnAddressInput.addEventListener('input',()=>{ currentTemplate.backSide = currentTemplate.backSide || {}; currentTemplate.backSide.returnAddress = els.returnAddressInput.value; markStudioDirty(); saveWholeState(); }); qs('#saveTemplateBtn').addEventListener('click',()=>{ saveCurrentTemplateAndPersist(true); }); qs('#saveAsNewBtn').addEventListener('click',()=>{ const clone=JSON.parse(JSON.stringify(currentTemplate)); clone.id='template_'+Date.now(); clone.name=(els.templateNameInput.value.trim() || currentTemplate.name || 'RSVP Card') + ' copy'; studioState.templates.push(clone); currentTemplate=clone; studioState.currentTemplateId=clone.id; populateForm(); renderCanvas(); markStudioDirty(); saveWholeState(); }); qs('#deleteTemplateBtn').addEventListener('click',()=>{ if(studioState.templates.length===1){ alert('Keep at least one template in the studio.'); return; } const idx=studioState.templates.findIndex(t=>t.id===currentTemplate.id); if(idx>=0) studioState.templates.splice(idx,1); currentTemplate=JSON.parse(JSON.stringify(studioState.templates[0])); studioState.currentTemplateId=currentTemplate.id; selectedLayerId=currentTemplate.layers[0]?.id || 'mainImage'; populateForm(); renderCanvas(); markStudioDirty(); saveWholeState(); }); els.buildBatchBtn.addEventListener('click',()=>{ buildBatchPreview(); alert('Batch preview rebuilt.'); }); els.savePrintRunBtn.addEventListener('click', savePrintRun); els.printBatchBtn.addEventListener('click', printBatch); /* ── PDF export (uses same renderCardCanvasBlob as JPEG) ── */ async function downloadBatchPdf() { const batch = buildBatchPreview(); if (!batch.length) { alert('No invitation groups in current batch.'); return; } if (typeof window.jspdf === 'undefined' && typeof window.jsPDF === 'undefined') { alert('PDF library not loaded yet — try again in a moment.'); return; } const { jsPDF } = window.jspdf || { jsPDF: window.jsPDF }; const t = JSON.parse(JSON.stringify(currentTemplate)); const sizes = { '4x6':{portrait:[1200,1800],landscape:[1800,1200]}, '5x7':{portrait:[1500,2100],landscape:[2100,1500]}, '3x5':{portrait:[900,1500],landscape:[1500,900]} }; const [cardW, cardH] = sizes[t.size][t.orientation]; const inW = cardW / 300, inH = cardH / 300; const pdf = new jsPDF({ orientation: inW > inH ? 'l' : 'p', unit: 'in', format: [inW, inH] }); for (let i = 0; i < batch.length; i++) { if (i > 0) pdf.addPage([inW, inH], inW > inH ? 'l' : 'p'); const item = batch[i]; const blob = await renderCardCanvasBlob(t, { guest_name: item.guest_name, primary_guest_name: item.primary_guest_name, rsvp_code: item.rsvp_code, guest_allotment_text: item.guest_allotment_text, event_date: item.event_date, event_location: item.event_location, mailing_name: item.mailing_name || item.primary_guest_name, mailing_address: item.mailing_address || '' }); if (!blob) continue; const url = URL.createObjectURL(blob); await new Promise(res => { const img = new Image(); img.onload = () => { pdf.addImage(img, 'JPEG', 0, 0, inW, inH); URL.revokeObjectURL(url); res(); }; img.src = url; }); } const base = sanitizeFilePart(els.printRunNameInput.value.trim() || 'rsvp_cards'); pdf.save(base + '.pdf'); } els.downloadJpegsBtn.addEventListener('click', downloadBatchJpegs); document.getElementById('downloadPdfBtn').addEventListener('click', downloadBatchPdf); function initStudio(){ const rich = pickRichestState(); if (rich.rsvpCardStudio && Array.isArray(rich.rsvpCardStudio.templates) && rich.rsvpCardStudio.templates.length) { studioState.templates = JSON.parse(JSON.stringify(rich.rsvpCardStudio.templates)); studioState.currentTemplateId = rich.rsvpCardStudio.currentTemplateId || studioState.currentTemplateId; studioState.savedPrintRuns = Array.isArray(rich.rsvpCardStudio.savedPrintRuns) ? JSON.parse(JSON.stringify(rich.rsvpCardStudio.savedPrintRuns)) : studioState.savedPrintRuns; currentTemplate = JSON.parse(JSON.stringify(studioState.templates.find(t => t.id === studioState.currentTemplateId) || studioState.templates[0] || createDefaultTemplate())); selectedLayerId = currentTemplate.layers[0]?.id || 'mainImage'; } populateGuests(); populateForm(); attachCardEvents(); buildBatchPreview(); renderPrintRunList(); renderTemplateList(); renderCanvas(); [400, 1200, 2600].forEach(delay => setTimeout(() => { const changed = populateGuests(); if (changed) { buildBatchPreview(); renderCanvas(); renderTemplateList(); } }, delay)); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => setTimeout(initStudio, 120)); else setTimeout(initStudio, 120); })(); // Self-contained hamburger init function initStudioHamburger() { document.querySelectorAll('[data-menu-toggle]').forEach(function(btn) { btn.onclick = function(e) { e.preventDefault(); e.stopPropagation(); var wrap = btn.closest('.planner-hamburger-wrap'); if (!wrap) return; var menu = wrap.querySelector('.planner-hamburger-menu'); if (!menu) return; var isOpen = menu.classList.contains('open'); document.querySelectorAll('.planner-hamburger-menu.open').forEach(function(m) { m.classList.remove('open'); }); if (!isOpen) menu.classList.add('open'); }; }); document.addEventListener('click', function(e) { if (!e.target.closest('.planner-hamburger-wrap')) document.querySelectorAll('.planner-hamburger-menu.open').forEach(function(m) { m.classList.remove('open'); }); }); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initStudioHamburger); } else { initStudioHamburger(); }