Features: quarter-fold and flat 4-up layouts, 8 themes, 12 presets, font picker, text alignment, text size sliders, background/accent/text colour pickers, message page (left/right) toggle, A4/Letter paper size, tabbed sidebar UI, responsive mobile layout, localStorage persistence, and Beach Party Balloons branding support. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
641 lines
37 KiB
JavaScript
641 lines
37 KiB
JavaScript
const PX = 96; // px per inch at screen
|
||
const LOGO = "https://beachpartyballoons.com/assets/logo/BeachPartyBalloons-logo.webp";
|
||
const FOLD = { w: 11*PX, h: 8.5*PX };
|
||
const FLAT = { w: 8.5*PX, h: 11*PX };
|
||
const MM = 96 / 25.4; // px per mm at screen
|
||
const PAPER_DIMS = {
|
||
letter: FLAT,
|
||
a4: { w: Math.round(210*MM), h: Math.round(297*MM) }
|
||
};
|
||
|
||
/* ---- Theme decorations (inline SVG, scale to fill their face) ---- */
|
||
function deco(theme, variant){
|
||
switch(theme.id){
|
||
case 'confetti':{
|
||
const c=['#FF5A7A','#FFC94D','#4DB6FF','#7BD389','#B07CFF'];
|
||
let s='';
|
||
const pts=[[8,10,0],[20,22,1],[33,8,2],[46,18,3],[60,9,4],[72,20,0],[86,12,1],[92,28,2],
|
||
[6,80,3],[18,90,4],[30,78,0],[44,88,1],[58,82,2],[70,92,3],[84,84,4],[94,76,0]];
|
||
pts.forEach((p,i)=>{
|
||
const col=c[p[2]];
|
||
if(i%3===0) s+=`<rect x="${p[0]}" y="${p[1]}" width="2.4" height="2.4" rx=".5" transform="rotate(${i*25} ${p[0]} ${p[1]})" fill="${col}"/>`;
|
||
else if(i%3===1) s+=`<circle cx="${p[0]}" cy="${p[1]}" r="1.4" fill="${col}"/>`;
|
||
else s+=`<path d="M${p[0]} ${p[1]} l2 3 l-4 0 z" fill="${col}"/>`;
|
||
});
|
||
return svg(s);
|
||
}
|
||
case 'gold':{
|
||
const a=theme.vars['--c-accent'];
|
||
const fr=`<rect x="4" y="6" width="92" height="88" fill="none" stroke="${a}" stroke-width=".5"/>
|
||
<rect x="6" y="9" width="88" height="82" fill="none" stroke="${a}" stroke-width="1.1"/>`;
|
||
const fl=(x,y,r)=>`<g transform="translate(${x} ${y}) rotate(${r})" fill="none" stroke="${a}" stroke-width=".7">
|
||
<path d="M0 0 q7 0 9 6 q-6 -1 -9 -6"/><path d="M0 0 q0 7 6 9 q-1 -6 -6 -9"/><circle cx="0" cy="0" r=".9" fill="${a}"/></g>`;
|
||
return svg(fr+fl(12,15,0)+fl(88,15,90)+fl(12,85,-90)+fl(88,85,180));
|
||
}
|
||
case 'botanical':{
|
||
const a=theme.vars['--c-accent'];
|
||
// leaves-outline-with-vines-svgrepo-com.svg path (viewBox 0 0 136.492 136.492)
|
||
const vp=`M121.036,78.928c-13.526-14.684-30.95-14.207-47.129-12.3c-0.375-0.216-0.802-0.34-1.266-0.292c-4.219,0.458-9.566-0.227-11.18-6.275c-0.937-3.562,1.187-7.235,4.725-8.174c2.624-0.709,5.332,0.878,6.027,3.493c0.491,1.867-0.622,3.789-2.476,4.282c-0.681,0.171-1.35,0.026-1.891-0.324c0.562-1.081,0.304-2.426-0.701-3.169c-1.118-0.849-2.7-0.612-3.536,0.511c-0.01,0.016-0.01,0.04-0.021,0.056c-0.768,0.496-1.179,1.419-0.937,2.354c0.947,3.541,4.622,5.69,8.182,4.757c4.158-1.103,6.643-5.39,5.545-9.563c-1.308-4.915-6.381-7.852-11.295-6.576c-5.843,1.553-9.336,7.586-7.796,13.461c0.793,2.987,2.386,5.323,4.588,6.966c-1.965,0.227-3.913,0.433-5.838,0.572c-19.478,1.506-30.855,1.822-38.333-9.207c-2.354-3.473-3.483-7.528-3.369-11.687c1.782,4.688,5.679,8.248,11.625,10.563c2.742,1.071,5.276,1.592,7.744,1.592l0,0c9.671,0,17.133-7.992,26.757-21.808c4.422-6.357,10.167-11.962,23.838-18.686c0.143-0.069,0.227-0.209,0.349-0.312c0.079-0.066,0.152-0.124,0.227-0.2c0.132-0.129,0.248-0.259,0.337-0.427c0.158-0.269,0.285-0.567,0.296-0.917c0.011-0.356-0.09-0.691-0.232-1c-0.037-0.097-0.1-0.15-0.143-0.232c-0.142-0.232-0.332-0.422-0.554-0.577c-0.053-0.037-0.068-0.098-0.115-0.114C83.399,15.051,58.057,0,35.935,0C21.926,0,12.332,5.957,7.417,17.708c-1.155,2.777-1.139,7.863,1.179,11.826c1.339,2.299,3.298,3.899,5.695,4.822c-1.804,2.716-3.13,5.746-3.789,8.976c-1.345,6.555-0.056,13.165,3.639,18.592c9.255,13.634,24.542,12.458,42.245,11.093c3.995-0.316,8.092-0.834,12.229-1.382c17.494-2.265,35.588-4.609,49.272,10.22c2.352,2.562,4.155,5.416,5.437,8.438c-0.005,0.116-0.047,0.211-0.042,0.327c0.69,10.515-3.617,17.323-12.145,19.147c-6.603,1.439-13.157-2.8-14.57-9.402c-1.076-5.073,2.151-10.072,7.208-11.148c1.852-0.385,3.724-0.053,5.311,0.971c1.588,1.023,2.685,2.605,3.074,4.472c0.27,1.234,0.026,2.474-0.563,3.565c-0.228-0.338-0.448-0.686-0.786-0.949c-1.756-1.461-4.366-1.203-5.822,0.563c-1.45,1.772-1.196,4.372,0.575,5.827c0.459,0.369,0.98,0.623,1.524,0.781c0.501,0.506,1.223,0.785,1.972,0.633c2.5-0.544,4.625-2.015,6.007-4.166c1.382-2.131,1.851-4.684,1.312-7.178c-0.643-2.99-2.409-5.537-4.967-7.193c-2.548-1.65-5.585-2.188-8.554-1.555c-7.378,1.565-12.103,8.869-10.531,16.284c1.683,7.763,8.569,13.089,16.211,13.089c1.149,0,2.325-0.111,3.49-0.364c6.318-1.351,10.827-4.941,13.337-10.235c-0.881,8.849-5.864,17.254-13.711,23.087c-5.759,4.282-12.852,6.075-19.955,5.021c-7.093-1.045-13.373-4.794-17.655-10.558c-0.712-0.955-2.062-1.149-3.029-0.443c-0.957,0.718-1.157,2.067-0.448,3.027c4.976,6.687,12.258,11.048,20.5,12.266c1.535,0.222,3.075,0.332,4.609,0.332c6.66,0,13.109-2.125,18.558-6.175c8.88-6.598,14.512-16.189,15.456-26.325C130.538,94.806,127.469,85.904,121.036,78.928z M20.945,7.966c2.17-1.284,4.627-2.226,7.338-2.824L23.637,24.83c-1.748,1.031-3.33,2.159-4.653,3.436l2.02-19.672C21.03,8.372,20.982,8.171,20.945,7.966z M39.099,54.288L20.252,33.816c1.152-1.205,2.399-2.31,3.813-3.22c0.432-0.274,0.704-0.691,0.854-1.139l19.765,21.065c-1.936,1.745-3.76,3.024-5.532,3.847C39.131,54.346,39.125,54.314,39.099,54.288z M33.705,55.653c-1.917,0-3.937-0.427-6.173-1.289c-4.777-1.87-7.873-4.602-9.191-8.124c-1.113-2.964-0.81-6.041-0.243-8.385L34.411,55.58C34.179,55.59,33.942,55.653,33.705,55.653L33.705,55.653z M47.734,47.466L28.589,27.061c1.859-0.864,3.889-1.653,6.088-2.339l17.65,17.216C50.704,44.055,49.174,45.895,47.734,47.466z M46.632,5.487c2.305,0.448,4.591,0.988,6.842,1.627L44.99,17.858c-1.695,0.309-3.386,0.633-5.044,1.016l6.61-13.115C46.606,5.672,46.601,5.579,46.632,5.487z M63.982,10.676l-6.265,5.218c-0.103,0.087-0.156,0.197-0.243,0.306c-2.041,0.189-4.124,0.427-6.215,0.704l6.637-8.419C60.003,9.176,62.028,9.906,63.982,10.676z M74.524,15.312c-2.816,0.037-6.146,0.135-9.774,0.338l3.746-3.114C70.71,13.5,72.751,14.441,74.524,15.312z M68.702,23.723l-3.932-3.736c3.629-0.216,7.069-0.335,10.181-0.361C72.573,21.018,70.526,22.383,68.702,23.723z M61.324,30.209l-9.746-8.996c2.468-0.33,4.936-0.588,7.38-0.805l6.315,6.015C63.771,27.715,62.466,28.978,61.324,30.209z M54.927,38.449L39.502,23.406c2.133-0.52,4.343-0.968,6.61-1.358l12.411,11.449c-0.567,0.741-1.107,1.463-1.605,2.193C56.222,36.69,55.584,37.536,54.927,38.449z M42.237,4.733l-7.862,15.593c-1.999,0.598-3.905,1.273-5.714,2.025l4.213-17.874c0.997-0.077,1.991-0.161,3.045-0.161C37.997,4.316,40.109,4.467,42.237,4.733z M12.31,27.369c-1.622-2.781-1.577-6.426-0.917-7.992c1.263-3.024,2.943-5.537,4.952-7.652l-1.843,17.969C13.615,29.112,12.882,28.34,12.31,27.369z`;
|
||
const vine=(tx,ty,r,s,op=0.3)=>`<g transform="translate(${tx},${ty}) rotate(${r}) scale(${s})" fill="${a}" fill-opacity="${op}"><path d="${vp}"/></g>`;
|
||
// Bottom-left and top-right opposing corners (vine SVG is 136.5×136.5, scaled to ~55 units)
|
||
return svg(vine(0,45,0,0.4) + vine(100,55,180,0.4));
|
||
}
|
||
case 'watercolor':{
|
||
const cols = ['#A389D4', '#7DADE2', '#E27DA1', '#7DE2C9', '#B87DE2', '#7D96E2'];
|
||
const blob = (x, y, rx, ry, r, col, op) => `<ellipse cx="${x}" cy="${y}" rx="${rx}" ry="${ry}" transform="rotate(${r} ${x} ${y})" fill="${col}" fill-opacity="${op}"/>`;
|
||
let s = '';
|
||
// Top-Left Cluster
|
||
s += blob(5, 5, 35, 28, 30, cols[0], 0.15);
|
||
s += blob(18, 8, 25, 20, -15, cols[1], 0.12);
|
||
s += blob(10, 22, 28, 22, 45, cols[2], 0.1);
|
||
s += blob(25, 15, 18, 15, 10, cols[4], 0.08);
|
||
|
||
// Top-Right Cluster
|
||
s += blob(95, 5, 32, 25, -30, cols[3], 0.14);
|
||
s += blob(82, 12, 24, 20, 20, cols[5], 0.12);
|
||
s += blob(90, 25, 20, 18, -45, cols[0], 0.09);
|
||
|
||
// Bottom-Left Cluster
|
||
s += blob(5, 95, 38, 24, 15, cols[1], 0.16);
|
||
s += blob(22, 88, 28, 18, -10, cols[4], 0.11);
|
||
s += blob(12, 80, 22, 16, 35, cols[3], 0.13);
|
||
|
||
// Bottom-Right Cluster
|
||
s += blob(95, 95, 34, 28, 45, cols[2], 0.17);
|
||
s += blob(78, 88, 25, 22, -25, cols[5], 0.12);
|
||
s += blob(88, 75, 20, 18, 15, cols[1], 0.1);
|
||
s += blob(98, 82, 15, 12, -10, cols[0], 0.08);
|
||
|
||
// Edge and Middle Washes
|
||
s += blob(50, 5, 45, 14, 0, cols[4], 0.08);
|
||
s += blob(50, 95, 50, 18, 5, cols[0], 0.07);
|
||
s += blob(5, 50, 12, 35, 0, cols[2], 0.06);
|
||
s += blob(95, 50, 14, 40, 0, cols[3], 0.06);
|
||
|
||
return svg(s);
|
||
}
|
||
case 'balloon':{
|
||
const cols=['#5A9AFF','#7AB5FF','#A0CFFF'];
|
||
const bal=(x,y,col,sc,r)=>`<g transform="translate(${x} ${y}) rotate(${r}) scale(${sc})">
|
||
<path d="M0 12 Q 2 25 -2 35" stroke="${col}" stroke-width=".4" fill="none" opacity=".6"/>
|
||
<ellipse cx="0" cy="0" rx="7" ry="9" fill="${col}"/>
|
||
<path d="M0 9 l-1.5 2 h3 z" fill="${col}"/>
|
||
<ellipse cx="-2.5" cy="-3.5" rx="1.8" ry="2.5" fill="#fff" fill-opacity=".3" transform="rotate(-20 -2.5 -3.5)"/>
|
||
</g>`;
|
||
// Group of balloons tied together
|
||
const group=`<g transform="translate(50 65)">
|
||
${bal(-8,-12,cols[0],1.2,10)}${bal(8,-10,cols[1],1.1,-10)}${bal(0,-20,cols[2],1.3,0)}
|
||
<path d="M-5 18 Q 0 25 0 35 M 5 18 Q 0 25 0 35 M 0 10 Q 0 25 0 35" stroke="#ccc" stroke-width=".3" fill="none"/>
|
||
</g>`;
|
||
// Floating sparkles
|
||
let sparkles='';
|
||
for(let i=0;i<12;i++) sparkles+=`<circle cx="${Math.random()*100}" cy="${Math.random()*100}" r="${0.4+Math.random()*0.8}" fill="${cols[i%3]}" fill-opacity=".4"/>`;
|
||
return svg(sparkles + group);
|
||
}
|
||
case 'kraft':{
|
||
const a=theme.vars['--c-accent'];
|
||
const border=`<rect x="5" y="6" width="90" height="88" fill="none" stroke="${a}" stroke-width=".8" stroke-dasharray="2 1.6"/>`;
|
||
const sprig=`<g transform="translate(50 16)" stroke="${a}" stroke-width=".9" fill="none" stroke-linecap="round">
|
||
<path d="M-9 0 H9"/><path d="M-9 0 q-2 -3 -5 -3"/><path d="M9 0 q2 -3 5 -3"/><circle cx="0" cy="0" r="1.4" fill="${a}"/></g>`;
|
||
return svg(border+sprig);
|
||
}
|
||
case 'flowers':{
|
||
const a=theme.vars['--c-accent'];
|
||
const img=(href,x,y,w,h,op=1)=>`<image href="${href}" x="${x}" y="${y}" width="${w}" height="${h}" opacity="${op}"/>`;
|
||
const fg='assets/flower-green-svgrepo-com(1).svg';
|
||
const fl='assets/flower-leaf-2-svgrepo-com.svg';
|
||
const fb='assets/flower-bouquet-svgrepo-com(2).svg';
|
||
// Accent petals scattered near centre
|
||
let s='';
|
||
[[22,38],[78,35],[50,12],[50,86],[15,65],[85,62]].forEach(([x,y])=>{
|
||
s+=`<circle cx="${x}" cy="${y}" r="1.8" fill="${a}" opacity=".18"/>`;
|
||
});
|
||
s+=img(fb,8,18,84,72,0.07); // faint bouquet watermark behind text
|
||
s+=img(fg,-8,-8,52,52); // flower top-left
|
||
s+=img(fg,56,56,52,52); // flower bottom-right
|
||
s+=img(fl,54,-8,52,52,0.85); // leaf top-right
|
||
s+=img(fl,-8,56,52,52,0.85); // leaf bottom-left
|
||
return svg(s);
|
||
}
|
||
case 'retro':{
|
||
const a=theme.vars['--c-accent'];
|
||
const cy='#00EEFF';
|
||
let s='';
|
||
// Pixel stars
|
||
[[6,4],[19,7],[31,3],[47,9],[62,5],[77,8],[91,4],[12,15],[55,12],[83,14],
|
||
[3,25],[38,28],[70,23],[96,18],[25,35],[85,32]].forEach(([x,y],i)=>{
|
||
const col=i%3===0?cy:i%3===1?a:'#FFFFFF';
|
||
s+=`<rect x="${x}" y="${y}" width="${i%4===0?1.5:1}" height="${i%4===0?1.5:1}" fill="${col}" opacity=".6"/>`;
|
||
});
|
||
// Synthwave horizon sun
|
||
const hz=70;
|
||
s+=`<ellipse cx="50" cy="${hz}" rx="16" ry="8" fill="${a}" opacity=".2"/>`;
|
||
s+=`<ellipse cx="50" cy="${hz}" rx="11" ry="5.5" fill="${a}" opacity=".28"/>`;
|
||
[0,2.4,4.8].forEach(dy=>{ s+=`<rect x="34" y="${hz-1.2+dy}" width="32" height=".9" fill="#0D0A1E" opacity=".85"/>`; });
|
||
// Perspective grid lines
|
||
for(let i=0;i<=6;i++)
|
||
s+=`<line x1="50" y1="${hz}" x2="${(i/6)*100}" y2="100" stroke="${a}" stroke-width=".4" opacity=".4"/>`;
|
||
for(let i=1;i<=4;i++){
|
||
const t=i/4, y=hz+t*(100-hz), hw=t*50;
|
||
s+=`<line x1="${50-hw}" y1="${y}" x2="${50+hw}" y2="${y}" stroke="${a}" stroke-width=".4" opacity=".4"/>`;
|
||
}
|
||
// Space invader sprite (top-center, each pixel = 1.8 units)
|
||
const ps=1.8, ox=50-9*ps/2, oy=5;
|
||
[[0,0,1,0,0,0,1,0,0],
|
||
[0,0,0,1,1,1,0,0,0],
|
||
[0,1,1,1,1,1,1,1,0],
|
||
[1,1,0,1,1,1,0,1,1],
|
||
[1,1,1,1,1,1,1,1,1],
|
||
[0,1,0,0,0,0,0,1,0],
|
||
[1,0,1,0,0,0,1,0,1],
|
||
].forEach((row,r)=>row.forEach((on,c)=>{
|
||
if(on) s+=`<rect x="${ox+c*ps}" y="${oy+r*ps}" width="${ps-.2}" height="${ps-.2}" fill="${cy}"/>`;
|
||
}));
|
||
// Neon corner brackets
|
||
const BW=6, BT=1.2;
|
||
s+=`<rect x="3" y="3" width="${BW}" height="${BT}" fill="${cy}"/>`;
|
||
s+=`<rect x="3" y="3" width="${BT}" height="${BW}" fill="${cy}"/>`;
|
||
s+=`<rect x="${97-BW}" y="3" width="${BW}" height="${BT}" fill="${cy}"/>`;
|
||
s+=`<rect x="${97-BT}" y="3" width="${BT}" height="${BW}" fill="${cy}"/>`;
|
||
s+=`<rect x="3" y="${97-BT}" width="${BW}" height="${BT}" fill="${cy}"/>`;
|
||
s+=`<rect x="3" y="${97-BW}" width="${BT}" height="${BW}" fill="${cy}"/>`;
|
||
s+=`<rect x="${97-BW}" y="${97-BT}" width="${BW}" height="${BT}" fill="${cy}"/>`;
|
||
s+=`<rect x="${97-BT}" y="${97-BW}" width="${BT}" height="${BW}" fill="${cy}"/>`;
|
||
return svg(s);
|
||
}
|
||
}
|
||
return svg('');
|
||
}
|
||
|
||
function svg(inner){
|
||
return `<svg viewBox="0 0 100 100" preserveAspectRatio="none" width="100%" height="100%" style="position:absolute;inset:0">${inner}</svg>`;
|
||
}
|
||
|
||
/* ---- Themes ---- */
|
||
const THEMES = [
|
||
{ id:'confetti', name:'Confetti', vars:{ '--c-bg':'#FFFBF2','--c-ink':'#2A2438','--c-accent':'#FF5A7A', '--c-titleFont':"'Fredoka', sans-serif",'--c-titleWeight':'600', '--c-titleSize':'48px','--c-titleLS':'-.01em','--c-titleCase':'none', '--c-bodyFont':"'Quicksand', sans-serif",'--c-subSize':'17px', }, chipBg:'#FFFBF2', chipInk:'#FF5A7A', chipGlyph:'★' },
|
||
{ id:'gold', name:'Midnight Gold', vars:{ '--c-bg':'#12243F','--c-ink':'#EDE3CB','--c-accent':'#C9A24B', '--c-titleFont':"'Cinzel', serif",'--c-titleWeight':'700', '--c-titleSize':'40px','--c-titleLS':'.08em','--c-titleCase':'uppercase', '--c-bodyFont':"'Lora', serif",'--c-subSize':'14px', '--c-subLS':'.22em','--c-subCase':'uppercase','--c-subWeight':'500', }, chipBg:'#12243F', chipInk:'#C9A24B', chipGlyph:'❧' },
|
||
{ id:'botanical', name:'Botanical', vars:{ '--c-bg':'#FDFCF9','--c-ink':'#2D362E','--c-accent':'#6B8E6B', '--c-titleFont':"'Playfair Display', serif",'--c-titleWeight':'700', '--c-titleSize':'46px','--c-titleLS':'0.02em','--c-titleCase':'none', '--c-bodyFont':"'Lora', serif",'--c-subSize':'18px','--c-subStyle':'italic', }, chipBg:'#FDFCF9', chipInk:'#6B8E6B', chipGlyph:'❀' },
|
||
{ id:'watercolor', name:'Watercolor', vars:{ '--c-bg':'#FCF9FF','--c-ink':'#3D2B52','--c-accent':'#A389D4', '--c-titleFont':"'Dancing Script', cursive",'--c-titleWeight':'700', '--c-titleSize':'52px','--c-titleLS':'0','--c-titleCase':'none', '--c-bodyFont':"'Raleway', sans-serif",'--c-subSize':'16px', '--c-subLS':'.05em','--c-subWeight':'500', }, chipBg:'#FCF9FF', chipInk:'#A389D4', chipGlyph:'༄' },
|
||
{ id:'balloon', name:'Balloons', vars:{ '--c-bg':'#F7FBFF','--c-ink':'#2A4B6B','--c-accent':'#5A9AFF', '--c-titleFont':"'Fredoka', sans-serif",'--c-titleWeight':'600', '--c-titleSize':'48px','--c-titleLS':'-.01em', '--c-bodyFont':"'Quicksand', sans-serif",'--c-subSize':'17px', }, chipBg:'#F7FBFF', chipInk:'#5A9AFF', chipGlyph:'🎈' },
|
||
{ id:'kraft', name:'Kraft Note', vars:{ '--c-bg':'#D7C3A3','--c-ink':'#41301F','--c-accent':'#8A5A38', '--c-titleFont':"'Caveat', cursive",'--c-titleWeight':'700', '--c-titleSize':'42px','--c-titleLS':'0', '--c-bodyFont':"monospace",'--c-subSize':'16px','--c-subLS':'0.04em', }, chipBg:'#D7C3A3', chipInk:'#8A5A38', chipGlyph:'✶' },
|
||
{ id:'flowers', name:'Garden Blooms', vars:{ '--c-bg':'#FEFCF6','--c-ink':'#273A1C','--c-accent':'#D4517A', '--c-titleFont':"'Dancing Script', cursive",'--c-titleWeight':'700', '--c-titleSize':'56px','--c-titleLS':'0.01em','--c-titleCase':'none', '--c-bodyFont':"'Lora', serif",'--c-subSize':'17px','--c-subStyle':'italic', }, chipBg:'#FEFCF6', chipInk:'#D4517A', chipGlyph:'✿' },
|
||
{ id:'retro', name:'Retro Arcade', vars:{ '--c-bg':'#0D0A1E','--c-ink':'#00EEFF','--c-accent':'#FF1177', '--c-titleFont':"'Press Start 2P', monospace",'--c-titleWeight':'400', '--c-titleSize':'20px','--c-titleLS':'0.04em','--c-titleCase':'uppercase', '--c-bodyFont':"monospace",'--c-subSize':'10px','--c-subLS':'0.12em','--c-subCase':'uppercase','--c-subWeight':'400', }, chipBg:'#0D0A1E', chipInk:'#FF1177', chipGlyph:'👾' },
|
||
];
|
||
|
||
const PRESETS = [
|
||
{ name:"Birthday", title:"Happy Birthday", sub:"Wishing you a wonderful day", msg:"Hope your day is filled with joy, laughter, and a little extra cake.\n\nWith love,", font:"’Fredoka’, sans-serif" },
|
||
{ name:"Thank You", title:"Thank You", sub:"with heartfelt gratitude", msg:"Thank you so much for your kindness — it truly meant the world to me.\n\nWarmly,", font:"’Dancing Script’, cursive" },
|
||
{ name:"Congrats", title:"Congratulations!", sub:"on your wonderful news", msg:"So thrilled for you and everything you’ve accomplished. Here’s to what comes next!", font:"’Montserrat’, sans-serif" },
|
||
{ name:"Invitation", title:"You’re Invited", sub:"Saturday · 4:00 PM", msg:"Join us to celebrate!\n\nPlease RSVP by [date]\n[Address]", font:"’Cinzel’, serif" },
|
||
{ name:"Thinking of You", title:"Thinking of You", sub:"sending warm wishes", msg:"Just a little note to let you know you’re in my thoughts today.", font:"’Lora’, serif" },
|
||
{ name:"Get Well", title:"Get Well Soon", sub:"feel better", msg:"Wishing you rest, comfort, and a speedy recovery.", font:"’Caveat’, cursive" },
|
||
{ name:"Anniversary", title:"Happy Anniversary", sub:"celebrating you both", msg:"Wishing you another year filled with love, laughter, and beautiful memories together.\n\nWith love,", font:"’Playfair Display’, serif" },
|
||
{ name:"Baby Shower", title:"Baby Shower", sub:"a new little one is on the way!", msg:"Wishing you all the joy and wonder of welcoming your new arrival.\n\nWith love,", font:"’Quicksand’, sans-serif" },
|
||
{ name:"Sympathy", title:"With Deepest Sympathy", sub:"in your time of loss", msg:"Please know that you are in my thoughts and heart during this difficult time.\n\nWith love,", font:"’Lora’, serif" },
|
||
{ name:"Graduation", title:"Congratulations, Graduate!", sub:"the world is yours", msg:"So proud of everything you’ve accomplished. This is just the beginning of an incredible journey!\n\nWith love,", font:"’Montserrat’, sans-serif" },
|
||
{ name:"Valentine’s", title:"Happy Valentine’s Day", sub:"with all my love", msg:"Every day with you is a gift. Thank you for being my favorite person.\n\nAll my love,", font:"’Dancing Script’, cursive" },
|
||
{ name:"Holiday", title:"Happy Holidays", sub:"wishing you joy & peace", msg:"May this season bring warmth, laughter, and all the things that make your heart happy.\n\nWith love,", font:"’Cinzel’, serif" },
|
||
];
|
||
|
||
/* ---- State ---- */
|
||
const state = {
|
||
style:'quarter', paper:'letter', theme:THEMES[0], title:'Happy Birthday', sub:'Wishing you a wonderful day',
|
||
msg:'Hope your day is filled with joy, laughter, and a little extra cake.\n\nWith love,', back:'',
|
||
logo:true, logoPos:'back', inside:false, guide:true, accent:null, text:null, bg:null, fontOverride:'',
|
||
doubleSided: false, activeSide: 'outside',
|
||
titleScale: 1, bodyScale: 1, align: 'center', msgSide: 'right'
|
||
};
|
||
|
||
/* ---- State Persistence ---- */
|
||
function saveState(){
|
||
const data = { ...state, themeId: state.theme.id };
|
||
delete data.theme; // Don't store the whole object reference
|
||
localStorage.setItem('cardStudioState', JSON.stringify(data));
|
||
}
|
||
|
||
function loadState(){
|
||
const saved = localStorage.getItem('cardStudioState');
|
||
if(!saved) return;
|
||
try {
|
||
const data = JSON.parse(saved);
|
||
const theme = THEMES.find(t => t.id === data.themeId) || THEMES[0];
|
||
Object.assign(state, data, { theme });
|
||
syncUI();
|
||
} catch(e) { console.error("Failed to load state", e); }
|
||
}
|
||
|
||
function syncUI(){
|
||
$('#fTitle').value = state.title;
|
||
$('#fSub').value = state.sub;
|
||
$('#fMsg').value = state.msg;
|
||
$('#fBack').value = state.back;
|
||
$('#fFont').value = state.fontOverride;
|
||
const ts = Math.round((state.titleScale || 1) * 100);
|
||
const bs = Math.round((state.bodyScale || 1) * 100);
|
||
$('#fTitleScale').value = ts; $('#fTitleScaleVal').textContent = ts + '%';
|
||
$('#fBodyScale').value = bs; $('#fBodyScaleVal').textContent = bs + '%';
|
||
$('#cInside').checked = state.inside;
|
||
$('#cDouble').checked = state.doubleSided;
|
||
$('#cGuide').checked = state.guide;
|
||
$('#cLogo').checked = state.logo;
|
||
$('#cAccent').value = toHex(state.accent || state.theme.vars['--c-accent']);
|
||
$('#cText').value = toHex(state.text || state.theme.vars['--c-ink']);
|
||
|
||
// Update segments
|
||
document.querySelectorAll('#paperSeg button').forEach(b => b.setAttribute('aria-pressed', b.dataset.paper === (state.paper || 'letter')));
|
||
document.querySelectorAll('#styleSeg button').forEach(b => b.setAttribute('aria-pressed', b.dataset.style === state.style));
|
||
document.querySelectorAll('#logoSeg button').forEach(b => b.setAttribute('aria-pressed', b.dataset.pos === state.logoPos));
|
||
document.querySelectorAll('#sideSeg button').forEach(b => b.setAttribute('aria-pressed', b.dataset.side === state.activeSide));
|
||
document.querySelectorAll('#alignSeg button').forEach(b => b.setAttribute('aria-pressed', b.dataset.align === (state.align || 'center')));
|
||
document.querySelectorAll('#msgSideSeg button').forEach(b => b.setAttribute('aria-pressed', b.dataset.msgside === (state.msgSide || 'right')));
|
||
$('#msgSideWrap').style.display = state.style === 'quarter' ? '' : 'none';
|
||
document.querySelectorAll('.swatch').forEach(s => s.setAttribute('aria-pressed', s.dataset.id === state.theme.id));
|
||
|
||
// Visibility logic
|
||
const isFolded = (state.style === 'folded'); // Legacy support
|
||
if (state.style === 'folded') state.style = 'quarter'; // Migration
|
||
|
||
$('#optGroup').style.display = (state.style === 'folded') ? 'flex' : 'none'; // Only for legacy folded
|
||
$('#printGroup').style.display = (state.inside && state.style === 'folded') ? '' : 'none';
|
||
$('#sideSelector').style.display = state.doubleSided ? 'none' : '';
|
||
$('#logoPosWrap').style.display = (state.logo && state.style !== 'flat') ? '' : 'none';
|
||
document.body.classList.toggle('manual-flip', !state.doubleSided && state.inside && state.style === 'folded');
|
||
|
||
const notes={
|
||
quarter:'<b>Quarter-fold:</b> one portrait sheet, folded twice, makes a small 4.25 × 5.5" card — front, back, and inside message all print on this one sheet.',
|
||
flat:'<b>Flat:</b> four identical cards on one portrait sheet. Cut along the dashed lines — no folding. Great for invitations and thank-you cards.'
|
||
};
|
||
$('#styleNote').innerHTML = notes[state.style] || notes.quarter;
|
||
}
|
||
|
||
/* ---- Build controls ---- */
|
||
const $ = s=>document.querySelector(s);
|
||
const sheets = $('#sheets');
|
||
|
||
const swWrap=$('#swatches');
|
||
THEMES.forEach(t=>{
|
||
const b=document.createElement('button');
|
||
b.className='swatch';b.type='button';b.dataset.id=t.id;
|
||
b.setAttribute('aria-pressed', t.id===state.theme.id);
|
||
b.innerHTML=`<span class="chip" style="background:${t.chipBg}"><span style="color:${t.chipInk}">${t.chipGlyph}</span></span><span class="nm">${t.name}</span>`;
|
||
b.addEventListener('click',()=>{
|
||
state.theme=t; state.accent=null; state.text=null; syncColorInputs();
|
||
document.querySelectorAll('.swatch').forEach(s=>s.setAttribute('aria-pressed', s.dataset.id===t.id));
|
||
render();
|
||
});
|
||
swWrap.appendChild(b);
|
||
});
|
||
|
||
const pWrap=$('#presets');
|
||
PRESETS.forEach(p=>{
|
||
const b=document.createElement('button');
|
||
b.className='preset';b.type='button';b.textContent=p.name;
|
||
b.addEventListener('click',()=>{
|
||
state.title=p.title;state.sub=p.sub;state.msg=p.msg;
|
||
if(p.font) {
|
||
state.fontOverride=p.font;
|
||
$('#fFont').value=p.font;
|
||
}
|
||
$('#fTitle').value=p.title;$('#fSub').value=p.sub;$('#fMsg').value=p.msg;
|
||
render();
|
||
});
|
||
pWrap.appendChild(b);
|
||
});
|
||
|
||
$('#fTitle').addEventListener('input',e=>{state.title=e.target.value;render();});
|
||
$('#fSub').addEventListener('input',e=>{state.sub=e.target.value;render();});
|
||
$('#fMsg').addEventListener('input',e=>{state.msg=e.target.value;render();});
|
||
|
||
$('#msgSideSeg').addEventListener('click',e=>{
|
||
const b=e.target.closest('button');
|
||
if(!b) return;
|
||
state.msgSide=b.dataset.msgside;
|
||
document.querySelectorAll('#msgSideSeg button').forEach(x=>x.setAttribute('aria-pressed', x===b));
|
||
render();
|
||
});
|
||
$('#fBack').addEventListener('input',e=>{state.back=e.target.value;render();});
|
||
$('#fFont').addEventListener('change',e=>{state.fontOverride=e.target.value;render();});
|
||
|
||
$('#alignSeg').addEventListener('click',e=>{
|
||
const b=e.target.closest('button');
|
||
if(!b) return;
|
||
state.align=b.dataset.align;
|
||
document.querySelectorAll('#alignSeg button').forEach(x=>x.setAttribute('aria-pressed', x===b));
|
||
render();
|
||
});
|
||
|
||
$('#fTitleScale').addEventListener('input',e=>{
|
||
state.titleScale = e.target.value / 100;
|
||
$('#fTitleScaleVal').textContent = e.target.value + '%';
|
||
render();
|
||
});
|
||
$('#fBodyScale').addEventListener('input',e=>{
|
||
state.bodyScale = e.target.value / 100;
|
||
$('#fBodyScaleVal').textContent = e.target.value + '%';
|
||
render();
|
||
});
|
||
|
||
$('#cInside').addEventListener('change',e=>{
|
||
state.inside=e.target.checked;
|
||
$('#printGroup').style.display = (state.inside && state.style==='folded') ? '' : 'none';
|
||
render();
|
||
});
|
||
|
||
$('#cDouble').addEventListener('change',e=>{
|
||
state.doubleSided=e.target.checked;
|
||
$('#sideSelector').style.display = state.doubleSided ? 'none' : '';
|
||
document.body.classList.toggle('manual-flip', !state.doubleSided && state.inside);
|
||
render();
|
||
});
|
||
|
||
$('#sideSeg').addEventListener('click', e => {
|
||
const b = e.target.closest('button');
|
||
if(!b) return;
|
||
state.activeSide = b.dataset.side;
|
||
document.querySelectorAll('#sideSeg button').forEach(x => x.setAttribute('aria-pressed', x === b));
|
||
render();
|
||
});
|
||
|
||
$('#paperSeg').addEventListener('click',e=>{
|
||
const b=e.target.closest('button');
|
||
if(!b) return;
|
||
state.paper=b.dataset.paper;
|
||
document.querySelectorAll('#paperSeg button').forEach(x=>x.setAttribute('aria-pressed', x===b));
|
||
render();
|
||
});
|
||
|
||
$('#styleSeg').addEventListener('click',e=>{
|
||
const btn=e.target.closest('button');
|
||
if(!btn) return;
|
||
state.style=btn.dataset.style;
|
||
document.querySelectorAll('#styleSeg button').forEach(b=>b.setAttribute('aria-pressed', b===btn));
|
||
const s=state.style;
|
||
|
||
const notes={
|
||
quarter:'<b>Quarter-fold:</b> one portrait sheet, folded twice, makes a small 4.25 × 5.5" card — front, back, and inside message all print on this one sheet.',
|
||
flat:'<b>Flat:</b> four identical cards on one portrait sheet. Cut along the dashed lines — no folding. Great for invitations and thank-you cards.'
|
||
};
|
||
$('#styleNote').innerHTML = notes[s];
|
||
|
||
const isFolded = (s === 'folded');
|
||
$('#printGroup').style.display = (state.inside && isFolded) ? '' : 'none';
|
||
|
||
const cut = (s==='flat');
|
||
$('#guideOptLabel').firstChild.textContent = cut ? 'Show cut lines when printing' : 'Show fold lines when printing';
|
||
$('#guideOptSub').textContent = cut ? 'Faint dashed lines to trim along.' : 'Faint dashed creases to fold against.';
|
||
$('#msgLabel').textContent = (s==='flat') ? 'Card message' : 'Inside message';
|
||
$('#msgHint').textContent = s==='flat' ? 'Shown on each flat card.' : s==='quarter'? 'Printed inside the small card (same sheet).' : 'Used on the inside page (enable below) — and shown on flat cards.';
|
||
|
||
$('#msgSideWrap').style.display = s === 'quarter' ? '' : 'none';
|
||
syncLogoUI();
|
||
render();
|
||
});
|
||
|
||
$('#cGuide').addEventListener('change',e=>{state.guide=e.target.checked;render();});
|
||
|
||
function syncLogoUI(){
|
||
$('#logoPosWrap').style.display = (state.logo && state.style!=='flat') ? '' : 'none';
|
||
}
|
||
$('#cLogo').addEventListener('change',e=>{state.logo=e.target.checked;syncLogoUI();render();});
|
||
$('#logoSeg').addEventListener('click',e=>{
|
||
const b=e.target.closest('button');
|
||
if(!b) return;
|
||
state.logoPos=b.dataset.pos;
|
||
document.querySelectorAll('#logoSeg button').forEach(x=>x.setAttribute('aria-pressed',x===b));
|
||
render();
|
||
});
|
||
|
||
function syncColorInputs(){
|
||
const t=state.theme.vars;
|
||
$('#cAccent').value = toHex(state.accent || t['--c-accent']);
|
||
$('#cText').value = toHex(state.text || t['--c-ink']);
|
||
$('#cBg').value = toHex(state.bg || t['--c-bg']);
|
||
}
|
||
function toHex(c){
|
||
if(/^#([0-9a-f]{6})$/i.test(c)) return c;
|
||
return '#000000';
|
||
}
|
||
$('#cAccent').addEventListener('input',e=>{state.accent=e.target.value;render();});
|
||
$('#cText').addEventListener('input',e=>{state.text=e.target.value;render();});
|
||
$('#cBg').addEventListener('input',e=>{state.bg=e.target.value;render();});
|
||
$('#resetColors').addEventListener('click',()=>{state.accent=null;state.text=null;state.bg=null;syncColorInputs();render();});
|
||
|
||
$('#printBtn').addEventListener('click',()=>window.print());
|
||
window.addEventListener('beforeprint',()=>{
|
||
document.querySelectorAll('.sheet').forEach(s=>s.style.transform='none');
|
||
// Hide images still loading — Chrome hangs on "Preparing preview" if any image fetch is pending
|
||
document.querySelectorAll('.sheet img').forEach(img=>{if(!img.complete)img.style.display='none';});
|
||
});
|
||
window.addEventListener('afterprint',()=>{
|
||
document.querySelectorAll('.sheet img').forEach(img=>img.style.display='');
|
||
fit();
|
||
});
|
||
|
||
/* ---- Rendering ---- */
|
||
function faceVars(){
|
||
const v={...state.theme.vars};
|
||
if(state.accent) v['--c-accent']=state.accent;
|
||
if(state.text) v['--c-ink']=state.text;
|
||
if(state.bg) v['--c-bg']=state.bg;
|
||
const itemsMap = { left:'flex-start', center:'center', right:'flex-end' };
|
||
v['--c-align'] = state.align || 'center';
|
||
v['--c-items'] = itemsMap[state.align || 'center'];
|
||
if(state.fontOverride) v['--c-titleFont']=state.fontOverride;
|
||
const ts = state.titleScale || 1;
|
||
if(ts !== 1){
|
||
const base = parseFloat(v['--c-titleSize'] || '40px');
|
||
v['--c-titleSize'] = (base * ts).toFixed(1) + 'px';
|
||
}
|
||
const bs = state.bodyScale || 1;
|
||
if(bs !== 1){
|
||
const subBase = parseFloat(v['--c-subSize'] || '16px');
|
||
v['--c-subSize'] = (subBase * bs).toFixed(1) + 'px';
|
||
const msgBase = parseFloat(v['--c-msgSize'] || '15px');
|
||
v['--c-msgSize'] = (msgBase * bs).toFixed(1) + 'px';
|
||
}
|
||
return Object.entries(v).map(([k,val])=>`${k}:${val}`).join(';');
|
||
}
|
||
function esc(s){return (s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
|
||
|
||
function logoImg(cls){
|
||
return `<div class="${cls}"><img src="${LOGO}" alt="Beach Party Balloons" onerror="this.parentNode.style.display='none'"></div>`;
|
||
}
|
||
|
||
function coverFace(){
|
||
const front = state.logo && state.logoPos==='front';
|
||
return `<div class="face" style="${faceVars()}">
|
||
<div class="deco">${deco(state.theme,'cover')}</div>
|
||
<div class="content">
|
||
${state.title?`<h2 class="ttl">${esc(state.title)}</h2>`:''}
|
||
${state.sub?`<p class="sub">${esc(state.sub)}</p>`:''}
|
||
${front?logoImg('logo-sm'):''}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function backFace(){
|
||
const back = state.logo && state.logoPos==='back';
|
||
return `<div class="face" style="${faceVars()};background:var(--c-bg)">
|
||
<div class="deco" style="opacity:.5">${deco(state.theme,'back')}</div>
|
||
${back?`<div class="logo-back">${logoImg('')}${state.back?`<div class="backmark" style="position:static">${esc(state.back)}</div>`:''}</div>`
|
||
:(state.back?`<div class="backmark">${esc(state.back)}</div>`:'')}
|
||
</div>`;
|
||
}
|
||
|
||
function insideFace(side){
|
||
const hasMsg = (side === (state.msgSide || 'right'));
|
||
if(hasMsg){
|
||
return `<div class="face inside-r" style="${faceVars()}">
|
||
<div class="content"><p class="msg">${esc(state.msg)}</p></div>
|
||
</div>`;
|
||
}
|
||
return `<div class="face inside-l" style="${faceVars()}"></div>`;
|
||
}
|
||
|
||
function flatCell(){
|
||
return `<div class="cell"><div class="face" style="${faceVars()}">
|
||
<div class="deco">${deco(state.theme,'cover')}</div>
|
||
<div class="content">
|
||
${state.title?`<h2 class="ttl">${esc(state.title)}</h2>`:''}
|
||
${state.sub?`<p class="sub">${esc(state.sub)}</p>`:''}
|
||
${state.msg?`<p class="msg">${esc(state.msg)}</p>`:''}
|
||
${state.logo?logoImg('logo-sm'):''}
|
||
</div>
|
||
</div></div>`;
|
||
}
|
||
|
||
function render(){
|
||
let html='';
|
||
if(state.style==='folded'){
|
||
const manual = !state.doubleSided && state.inside;
|
||
const isOutside = !manual || state.activeSide === 'outside';
|
||
const isInside = !manual || state.activeSide === 'inside';
|
||
|
||
if(isOutside){
|
||
const outside = `<div class="sheet folded">
|
||
<div class="panel left">${backFace()}</div>
|
||
<div class="panel right">${coverFace()}</div>
|
||
<div class="${guideClass()} v" style="left:50%"><span class="gtxt">fold</span></div>
|
||
</div>`;
|
||
html += `<div class="sheetHolder ${manual && state.activeSide==='outside' ? 'active-print' : ''}">${holderLabel(state.inside?'Outside (front & back)':'Card sheet — fold in half', outside)}</div>`;
|
||
}
|
||
|
||
if(state.inside && isInside){
|
||
const inside = `<div class="sheet folded second-sheet ${state.inside ? '' : 'no-double-sided'}">
|
||
<div class="panel left">${insideFace('left')}</div>
|
||
<div class="panel right">${insideFace('right')}</div>
|
||
<div class="${guideClass()} v" style="left:50%"><span class="gtxt">fold</span></div>
|
||
</div>`;
|
||
html += `<div class="sheetHolder ${manual && state.activeSide==='inside' ? 'active-print' : ''}">${holderLabel('Inside (message)', inside)}</div>`;
|
||
}
|
||
} else if(state.style==='quarter'){
|
||
const g = `<div class="qgrid">
|
||
<div class="qcell rot">${insideFace('left')}</div>
|
||
<div class="qcell rot">${insideFace('right')}</div>
|
||
<div class="qcell">${backFace()}</div>
|
||
<div class="qcell">${coverFace()}</div>
|
||
</div>`;
|
||
const labels = `<div class="qlabels no-print">
|
||
<span>Inside ↧ (prints upside-down)</span><span>Message ↧</span>
|
||
<span>Back</span><span>Front cover</span>
|
||
</div>`;
|
||
const sheet = `<div class="sheet quarter">
|
||
${g}
|
||
<div class="${guideClass()} v" style="left:50%"></div>
|
||
<div class="${guideClass()} h" style="top:50%"></div>
|
||
${labels}
|
||
</div>`;
|
||
html += `<div class="sheetHolder active-print">${holderLabel('Small card — fold in half, then half again', sheet)}</div>`;
|
||
} else {
|
||
const flat = `<div class="sheet flat">
|
||
<div class="grid4">${flatCell()}${flatCell()}${flatCell()}${flatCell()}</div>
|
||
<div class="${guideClass()} v" style="left:50%"></div>
|
||
<div class="${guideClass()} h" style="top:50%"></div>
|
||
</div>`;
|
||
html += `<div class="sheetHolder active-print">${holderLabel('4 cards — cut to separate', flat)}</div>`;
|
||
}
|
||
sheets.innerHTML = html;
|
||
updatePageStyle();
|
||
fit();
|
||
saveState();
|
||
}
|
||
|
||
function holderLabel(label, inner){
|
||
return `<div class="sheetLabel">${label}</div>${inner}`;
|
||
}
|
||
|
||
const guideClass = ()=> 'guide ' + (state.guide ? '' : 'noprint-guide');
|
||
|
||
let pageStyleEl=document.createElement('style');document.head.appendChild(pageStyleEl);
|
||
function updatePageStyle(){
|
||
const land = state.style==='folded';
|
||
const orient = land ? 'landscape' : 'portrait';
|
||
const pageSize = state.paper === 'a4' ? 'A4' : 'Letter';
|
||
const sheetW = state.paper === 'a4' ? '210mm' : '8.5in';
|
||
const sheetH = state.paper === 'a4' ? '297mm' : '11in';
|
||
pageStyleEl.textContent = `@page{ size: ${pageSize} ${orient}; margin:0; }
|
||
.sheet.flat,.sheet.quarter{ width:${sheetW}; height:${sheetH}; }`;
|
||
}
|
||
|
||
function fit(){
|
||
const dim = PAPER_DIMS[state.paper || 'letter'];
|
||
const wrap = document.getElementById('previewWrap');
|
||
if(!wrap) return;
|
||
|
||
// Use a more robust width measurement
|
||
const isMobile = window.innerWidth <= 960;
|
||
const w = (isMobile ? window.innerWidth : wrap.clientWidth) || 360;
|
||
const h = wrap.clientHeight || (window.innerHeight * 0.5);
|
||
|
||
const style = window.getComputedStyle(wrap);
|
||
const padW = (parseFloat(style.paddingLeft)||0) + (parseFloat(style.paddingRight)||0);
|
||
const padH = (parseFloat(style.paddingTop)||0) + (parseFloat(style.paddingBottom)||0);
|
||
|
||
// Leave a bit more breathing room on mobile
|
||
const margin = isMobile ? 24 : 12;
|
||
const availW = Math.max(40, w - padW - margin);
|
||
const availH = Math.max(40, h - padH - margin);
|
||
|
||
let scale = Math.min(1, availW / dim.w, availH / dim.h);
|
||
|
||
if (isNaN(scale) || scale <= 0) scale = 0.1;
|
||
|
||
document.querySelectorAll('.sheetHolder').forEach(holder => {
|
||
const sheet = holder.querySelector('.sheet');
|
||
if(sheet) sheet.style.transform = `scale(${scale})`;
|
||
|
||
holder.style.width = Math.floor(dim.w * scale) + 'px';
|
||
holder.style.height = Math.floor(dim.h * scale) + 'px';
|
||
});
|
||
}
|
||
|
||
window.addEventListener('resize',fit);
|
||
window.addEventListener('load',fit);
|
||
window.addEventListener('orientationchange',()=>setTimeout(fit,200));
|
||
|
||
if('ResizeObserver' in window){
|
||
new ResizeObserver(fit).observe(document.getElementById('previewWrap'));
|
||
}
|
||
|
||
/* ---- Tabs ---- */
|
||
document.getElementById('tabBar').addEventListener('click', e => {
|
||
const btn = e.target.closest('.tab');
|
||
if (!btn) return;
|
||
const target = btn.dataset.tab;
|
||
document.querySelectorAll('.tab').forEach(t => {
|
||
t.classList.toggle('active', t === btn);
|
||
t.setAttribute('aria-selected', t === btn);
|
||
});
|
||
document.querySelectorAll('.tab-panel').forEach(p => {
|
||
p.classList.toggle('active', p.dataset.tab === target);
|
||
});
|
||
requestAnimationFrame(fit);
|
||
});
|
||
|
||
loadState();
|
||
syncColorInputs();
|
||
syncLogoUI();
|
||
render();
|
||
requestAnimationFrame(fit);
|