<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Packshot Lineup Builder</title>
<link rel="stylesheet" href="main.2a3cba51.css" />
<style>
:root{
--acc: #183EF6;
--surface: #ffffff;
--surface-subtle: #f7f9fb;
--border: #e6e8ee;
--text: #0f1222;
--muted: #627089;
/* Canvas tokens (on-screen; export scales proportionally) */
--imgH: 360px;
--spacing: 120px;
--scaleStep: 0.08;
--tilt: 2.5deg;
--arc: 20px;
--shadow: 24px;
--radius: .75rem;
--radius-lg: 1rem;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body{
margin:0;
font-family: "Work Sans", ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", Arial, "Noto Sans";
color: var(--text);
background: #fbfcfe;
}
header{
position: sticky; top: 0; z-index: 10;
backdrop-filter: saturate(140%) blur(8px);
background: color-mix(in oklab, #fff 72%, transparent);
border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
padding: 14px 16px;
}
.brand{ display:flex; align-items:center; gap:12px; min-height:24px; }
.brand img{ height:24px; width:auto; display:block; }
.brand .divider{ opacity:.25; }
.brand .title{ font-weight:700; letter-spacing:.2px; }
.controls{ margin-left: auto; display: flex; gap: 10px; align-items:center; }
.button{
appearance: none; border: 1px solid var(--border); background: var(--surface);
color: var(--text); padding: 10px 14px; border-radius: var(--radius); cursor: pointer; font-weight: 700;
transition: .18s ease; line-height: 1;
}
.button:hover{ border-color: color-mix(in oklab, var(--acc) 35%, var(--border)); box-shadow: 0 1px 0 rgba(0,0,0,.03); }
.button.ghost{ background: transparent; }
.button.primary{ background: var(--acc); color: #fff; border-color: var(--acc); }
.button.primary:hover{ filter: saturate(1.05) brightness(0.98); }
.btn-group { position: relative; display: inline-flex; }
.split { display: inline-flex; }
.split .button { border-radius: var(--radius) 0 0 var(--radius); border-right: none; }
.split .caret { border-radius: 0 var(--radius) var(--radius) 0; padding: 10px 12px; width: 40px; display:grid; place-items:center; }
.menu{
position:absolute; top:calc(100% + 6px); right:0; min-width: 280px;
background:#fff; border:1px solid var(--border); border-radius: .75rem; box-shadow:0 12px 28px rgba(17,24,39,.12);
display:none; overflow:hidden; z-index:20;
}
.menu.open{ display:block; }
.menu button{
width:100%; text-align:left; padding:10px 12px; border:0; background:#fff; cursor:pointer; font-weight:600;
}
.menu button:hover{ background: var(--surface-subtle); }
main{ display: grid; grid-template-columns: 360px 1fr; min-height: calc(100vh - 56px); }
aside{
border-right: 1px solid var(--border);
padding: 14px; background: var(--surface);
position: sticky; top: 56px; height: calc(100vh - 56px); overflow: auto;
}
.stack{ display:flex; gap:12px; align-items:center; flex-wrap:wrap; }
.row{ display:flex; gap:12px; align-items:center; flex-wrap:wrap; }
.hr{ height:1px; background: var(--border); margin: 14px 0; }
.hint{ font-size: 12px; color: var(--muted); }
.dropzone{
display:grid; gap:10px; text-align:center; padding:16px;
border: 1px dashed color-mix(in oklab, var(--border) 70%, #cfd6e6);
border-radius: var(--radius); background: var(--surface-subtle);
color: var(--muted); transition: .18s;
}
.dropzone .dz-inner{ display:grid; place-items:center; gap:8px; }
.dropzone strong{ color:#1f2a44; }
.dropzone:hover{ border-color: var(--acc); background: color-mix(in oklab, var(--surface-subtle) 80%, var(--acc)); }
.dropzone.dragover{ border-color: var(--acc); box-shadow: 0 0 0 3px color-mix(in oklab, var(--acc) 20%, transparent) inset; color:#163142; }
.file input{ display:none; }
#thumbs{ display:grid; grid-template-columns: repeat(auto-fill, minmax(64px, 1fr)); gap:10px; margin-top:10px; }
.thumb{
position:relative; background:#fff; border:1px solid var(--border); border-radius: var(--radius);
aspect-ratio:1/1; display:grid; place-items:center; overflow:hidden;
}
.thumb img{ width:100%; height:100%; object-fit:contain; pointer-events:none; }
.thumb button.remove{
position:absolute; top:4px; right:4px; background:#ffffff; border:1px solid var(--border); color:#4a5568;
border-radius: .5rem; padding:2px 6px; font-size:11px; cursor:pointer;
}
.thumb.dragging{ opacity:.5; outline:2px dashed var(--acc); }
.thumb.drag-over{ outline:2px dashed var(--acc); }
#stageWrap{ background: #f3f6fb; border-left: 1px solid var(--border); }
#stage{ position:relative; height:min(78vh, 900px); width:100%; overflow:hidden; }
#stageInner{ position:absolute; inset:0; display:grid; place-items:center; }
.canvas{
position:relative; width: min(1100px, 92%); height: min(78vh, 820px);
border-radius: var(--radius-lg); background: var(--bgCanvas, #fff); border:1px solid var(--border);
box-shadow: 0 10px 28px rgba(17, 24, 39, .06);
overflow:hidden; display:grid; place-items:center;
}
.slider{ display:grid; grid-template-columns: 140px 1fr auto; gap:8px; align-items:center; }
.slider label{ color: var(--muted); font-size: 13px; }
.slider input[type=range]{ width:100%; }
.slider output{ font-variant-numeric: tabular-nums; color:#1f2a44; font-size: 13px; }
.color{ display:flex; gap:10px; align-items:center; }
details.advanced{
border: 1px solid var(--border);
background: var(--surface-subtle);
border-radius: var(--radius);
padding: 10px 12px;
}
details.advanced summary{
cursor: pointer;
list-style: none;
font-weight: 700;
color: #1f2a44;
display:flex; align-items:center; justify-content:space-between;
}
details.advanced summary::-webkit-details-marker { display: none; }
.chev{ transition: transform .2s ease; }
details[open] .chev{ transform: rotate(90deg); }
.adv-row{ display:flex; flex-direction:column; gap:6px; padding:10px 0; border-top: 1px dashed var(--border); }
.adv-row:first-of-type{ border-top:none; }
.adv-header{ display:flex; gap:8px; align-items:center; color:#27324a; font-weight:600; overflow:hidden; }
.adv-index{ display:inline-grid; place-items:center; width:24px; height:24px; border-radius:6px; background:#fff; border:1px solid var(--border); font-size:12px; flex:0 0 auto; }
.name{ display:flex; gap:0; align-items:center; min-width:0; overflow:hidden; }
.name-base{ min-width:0; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }
.name-ext{ flex:0 0 auto; white-space:nowrap; }
.adv-hint{ color: var(--muted); font-size:12px; }
.adv-controls{ display:grid; grid-template-columns: 1fr auto auto; gap:8px; align-items:center; }
.adv-reset{ border:1px solid var(--border); background:#fff; border-radius:.5rem; padding:6px 8px; cursor:pointer; white-space:nowrap; }
.item{
position:absolute; left:50%; top:50%;
transform: translate(-50%,-50%);
will-change: transform; transition: transform .28s ease, filter .22s ease, opacity .22s ease;
filter: drop-shadow(0 14px var(--shadow) rgba(0,0,0,.25));
background: transparent; border: 0; padding: 0; appearance: none; -webkit-appearance: none; cursor: pointer;
}
.item img{ height:var(--imgH); width:auto; display:block; object-fit:contain; pointer-events:none; }
.item:focus-visible{ outline:2px solid var(--acc); outline-offset:4px; border-radius:12px; }
.legend{
position:absolute; left:12px; bottom:12px; font-size:12px; color: var(--muted); background: #ffffffd9;
border: 1px solid var(--border); padding: 6px 8px; border-radius: .5rem;
}
.empty{ display:grid; place-items:center; gap:8px; color:#6b7a90; text-align:center; width:100%; height:100%; }
@media (max-width: 980px){
main{ grid-template-columns: 1fr; }
aside{ position:static; height:auto; }
#stage{ height: 70vh; }
.slider{ grid-template-columns: 1fr; }
.adv-controls{ grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<header>
<div class="brand">
<img src="https://bcloud.bright.blue/static/media/Logos.94306c697a9c364c7d63c17e5172e5a3.svg" alt="Bright Blue logo" />
<span class="divider">|</span>
<span class="title">Packshot Lineup Builder</span>
</div>
<div class="controls">
<button class="button ghost" id="clearBtn" title="Remove all images">Clear</button>
<!-- Split dropdown download -->
<div class="btn-group" id="downloadGroup">
<div class="split">
<button class="button primary" id="dlMain" title="Download">Download</button>
<button class="button primary caret" id="dlCaret" aria-haspopup="menu" aria-expanded="false">▾</button>
</div>
<div class="menu" id="dlMenu" role="menu" aria-label="Download options">
<button type="button" data-preset="hd" role="menuitem">HD Uncompressed PNG (width 2000)</button>
<button type="button" data-preset="sd" role="menuitem">SD Uncompressed PNG (width 1000)</button>
<button type="button" data-preset="sd-compressed" role="menuitem">SD Compressed PNG (≤150KB, keeps transparency)</button>
</div>
</div>
</div>
</header>
<main>
<aside>
<!-- Upload box with Browse inside -->
<div class="dropzone" id="drop" tabindex="0" role="button" aria-label="Add images: browse or drop">
<div class="dz-inner">
<strong>Drop images here</strong>
<div class="hint">…or</div>
<div class="file">
<input id="file" type="file" accept="image/*" multiple>
<label for="file" class="button">Browse</label>
</div>
<div class="hint">PNG with transparency looks best. Minimum 800px height recommended.</div>
</div>
</div>
<div class="stack" style="margin-top:10px;">
<button class="button ghost" id="sampleBtn" title="Adds placeholders you can replace later">Add placeholders</button>
</div>
<div id="thumbs"></div>
<div class="hr"></div>
<div class="stack" style="gap:16px; align-items:stretch;">
<div class="slider">
<label for="height">Image height</label>
<input id="height" type="range" min="120" max="720" step="2" value="360">
<output id="heightOut">360 px</output>
</div>
<div class="slider">
<label for="spacing">Spacing (overlap ↔)</label>
<input id="spacing" type="range" min="0" max="300" step="2" value="120">
<output id="spacingOut">120 px</output>
</div>
<div class="slider">
<label for="scale">Scale step</label>
<input id="scale" type="range" min="0" max="0.25" step="0.005" value="0.08">
<output id="scaleOut">0.08</output>
</div>
<div class="slider">
<label for="tilt">Tilt per step</label>
<input id="tilt" type="range" min="0" max="12" step="0.1" value="2.5">
<output id="tiltOut">2.5°</output>
</div>
<div class="slider">
<label for="arc">Arc (lift)</label>
<input id="arc" type="range" min="0" max="120" step="1" value="20">
<output id="arcOut">20 px</output>
</div>
<div class="slider">
<label for="edgeTuck">Edge tuck (outermost)</label>
<input id="edgeTuck" type="range" min="0" max="0.95" step="0.01" value="0">
<output id="edgeTuckOut">0.00</output>
</div>
<div class="slider" id="centreWrap" style="display:none;">
<label for="centre">Centre index</label>
<input id="centre" type="range" min="0" max="0" step="1" value="0">
<output id="centreOut">0</output>
</div>
<div class="row">
<label class="color" title="Set background behind the lineup">
<span>Canvas background</span>
<input id="bgColor" type="color" value="#ffffff">
</label>
<label class="color" title="Shadow strength">
<span>Shadow</span>
<input id="shadow" type="range" min="0" max="48" step="1" value="24">
</label>
<label class="color" title="Make the canvas transparent for export">
<input id="transparent" type="checkbox"> Transparent canvas
</label>
</div>
</div>
<div class="hr"></div>
<details class="advanced" id="advanced">
<summary>
Advanced adjustments
<span class="chev">▶</span>
</summary>
<div class="hint" style="margin:8px 0 10px;">
Fine-tune horizontal position of individual images. Positive = right, negative = left.
</div>
<div class="row" style="justify-content: flex-end; margin-bottom:6px;">
<button class="adv-reset" id="resetAllOffsets" title="Reset all fine-tune offsets">Reset all</button>
</div>
<div id="advancedList" aria-live="polite"></div>
</details>
<div class="hr"></div>
<div class="hint">Tips: Click an image to set it as centre • Use ←/→ to nudge centre • Hold Shift while clicking to remove an item • Drag thumbnails to reorder.</div>
</aside>
<section id="stageWrap">
<div id="stage">
<div id="stageInner">
<div class="canvas" id="canvas" aria-live="polite" aria-label="Lineup canvas">
<div class="empty" id="empty">
<div>Upload images to see the lineup.</div>
<div class="hint">PNG with transparency looks best. Minimum 800px height recommended.</div>
</div>
<div class="legend">Click an item to centre • ← / → keys supported</div>
</div>
</div>
</div>
</section>
</main>
<script>
let uidCounter = 1;
const MAX_COMPRESSED_BYTES = 150 * 1024;
const state = {
files: [], // { id, url, name, offset, sig }
centre: 0,
objURLs: new Set(),
seenSigs: new Set(),
};
const els = {
file: document.getElementById('file'),
drop: document.getElementById('drop'),
thumbs: document.getElementById('thumbs'),
canvas: document.getElementById('canvas'),
empty: document.getElementById('empty'),
clearBtn: document.getElementById('clearBtn'),
dlMain: document.getElementById('dlMain'),
dlCaret: document.getElementById('dlCaret'),
dlMenu: document.getElementById('dlMenu'),
sampleBtn: document.getElementById('sampleBtn'),
height: document.getElementById('height'), heightOut: document.getElementById('heightOut'),
spacing: document.getElementById('spacing'), spacingOut: document.getElementById('spacingOut'),
scale: document.getElementById('scale'), scaleOut: document.getElementById('scaleOut'),
tilt: document.getElementById('tilt'), tiltOut: document.getElementById('tiltOut'),
arc: document.getElementById('arc'), arcOut: document.getElementById('arcOut'),
edgeTuck: document.getElementById('edgeTuck'), edgeTuckOut: document.getElementById('edgeTuckOut'),
centre: document.getElementById('centre'), centreOut: document.getElementById('centreOut'), centreWrap: document.getElementById('centreWrap'),
bgColor: document.getElementById('bgColor'),
shadow: document.getElementById('shadow'),
transparent: document.getElementById('transparent'),
advancedList: document.getElementById('advancedList'),
resetAllOffsets: document.getElementById('resetAllOffsets'),
};
const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
const splitName = (name='') => { const m = name.match(/^(.*?)(\.[^.]*)?$/); return { base:(m?.[1]??''), ext:(m?.[2]??'') }; };
/* ----- Keep on-screen image height within canvas ----- */
function syncImgHeightMax(){
const CH = Math.max(1, Math.floor(els.canvas.clientHeight));
const maxH = Math.max(120, CH); // WYSIWYG: image height cannot exceed canvas height
els.height.max = String(maxH);
if (parseInt(els.height.value, 10) > maxH) els.height.value = String(maxH);
document.documentElement.style.setProperty('--imgH', els.height.value + 'px');
els.heightOut.textContent = els.height.value + ' px';
}
function updateCSSVars(){
syncImgHeightMax();
document.documentElement.style.setProperty('--spacing', els.spacing.value + 'px');
document.documentElement.style.setProperty('--scaleStep', els.scale.value);
document.documentElement.style.setProperty('--tilt', els.tilt.value + 'deg');
document.documentElement.style.setProperty('--arc', els.arc.value + 'px');
document.documentElement.style.setProperty('--shadow', els.shadow.value + 'px');
const bg = els.transparent.checked ? 'transparent' : els.bgColor.value;
els.canvas.style.setProperty('--bgCanvas', bg);
els.spacingOut.textContent = els.spacing.value + ' px';
els.scaleOut.textContent = Number(els.scale.value).toFixed(3).replace(/0+$/,'').replace(/\.$/,'');
els.tiltOut.textContent = els.tilt.value + '°';
els.arcOut.textContent = els.arc.value + ' px';
els.edgeTuckOut.textContent = Number(els.edgeTuck.value).toFixed(2);
}
function buildAdvancedPanel(){
const list = document.getElementById('advancedList');
list.innerHTML = '';
if(!state.files.length) return;
state.files.forEach((f, i) => {
if(typeof f.offset !== 'number') f.offset = 0;
const row = document.createElement('div');
row.className = 'adv-row';
row.dataset.uid = f.id;
const title = document.createElement('div');
title.className = 'adv-header';
const idx = document.createElement('span'); idx.className='adv-index'; idx.textContent = String(i+1);
const nameWrap = document.createElement('div'); nameWrap.className='name';
const {base,ext} = splitName(f.name || ('image-'+(i+1)));
const baseSpan = document.createElement('span'); baseSpan.className='name-base'; baseSpan.textContent = base;
const extSpan = document.createElement('span'); extSpan.className='name-ext'; extSpan.textContent = ext;
nameWrap.append(baseSpan,extSpan);
title.append(idx,nameWrap); title.title = f.name || ('image-'+(i+1));
const hint = document.createElement('div'); hint.className='adv-hint'; hint.textContent='Offset (px)';
const controls = document.createElement('div'); controls.className='adv-controls';
const slider = document.createElement('input'); slider.type='range'; slider.min='-200'; slider.max='200'; slider.step='1'; slider.value=String(f.offset||0);
const out = document.createElement('output'); out.textContent=`${f.offset||0} px`;
const resetBtn = document.createElement('button'); resetBtn.className='adv-reset'; resetBtn.textContent='Reset';
slider.addEventListener('input', () => { f.offset = parseInt(slider.value,10)||0; out.textContent=`${f.offset} px`; layout(); });
resetBtn.addEventListener('click', () => { f.offset=0; slider.value='0'; out.textContent='0 px'; layout(); });
controls.append(slider,out,resetBtn);
row.append(title,hint,controls);
list.appendChild(row);
});
}
function layout(){
const items = Array.from(els.canvas.querySelectorAll('.item'));
if(!items.length){ els.empty.style.display = 'grid'; return; }
els.empty.style.display = 'none';
const spacing = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--spacing'));
const scaleStep = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--scaleStep'));
const tiltPer = parseFloat(els.tilt.value);
const arc = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--arc'));
const edgeTuck = parseFloat(els.edgeTuck.value || '0');
const twoMode = items.length === 2;
items.forEach((node, i) => {
const d = i - state.centre;
const ad = Math.abs(d);
let x, s, r, y;
if (twoMode) {
x = (i === 0 ? -1 : 1) * (spacing / 2);
s = 1; r = (i === 0 ? -1 : 1) * tiltPer; y = 0;
} else {
x = d * spacing;
if ((i === 0 || i === items.length - 1) && edgeTuck > 0) x *= (1 - edgeTuck);
s = clamp(1 - ad * scaleStep, 0.35, 1.0);
r = d * tiltPer;
y = -ad * arc;
}
const f = state.files[i]; x += (f && typeof f.offset==='number') ? f.offset : 0;
const z = twoMode ? (i === 1 ? 1001 : 1000) : (1000 - ad);
node.style.zIndex = String(z);
node.style.transform = `translate(-50%,-50%) translateX(${x}px) translateY(${y}px) rotate(${r}deg) scale(${s})`;
node.style.filter = ad === 0 ? `drop-shadow(0 16px var(--shadow) rgba(0,0,0,.28))` : `drop-shadow(0 14px var(--shadow) rgba(0,0,0,.22))`;
node.style.opacity = ad > 5 && !twoMode ? 0 : 1;
});
els.centre.max = Math.max(0, state.files.length - 1);
els.centre.value = state.centre;
els.centreOut.textContent = state.centre;
els.centreWrap.style.display = state.files.length ? 'grid' : 'none';
}
function render(){
els.canvas.querySelectorAll('.item').forEach(n => n.remove());
state.files.forEach((f, i) => {
const wrap = document.createElement('button');
wrap.className = 'item'; wrap.type='button'; wrap.dataset.index=i; wrap.title=`Click to centre (index ${i})`;
const img = document.createElement('img'); img.alt = f.name || `image-${i+1}`; img.src = f.url;
wrap.appendChild(img);
wrap.addEventListener('click', (ev) => { if(ev.shiftKey){ removeAt(i); return; } state.centre = i; layout(); });
els.canvas.appendChild(wrap);
});
updateThumbs();
buildAdvancedPanel();
syncImgHeightMax();
layout();
}
function updateThumbs(){
els.thumbs.innerHTML = '';
state.files.forEach((f, i) => {
const t = document.createElement('div');
t.className = 'thumb'; t.draggable = true; t.dataset.index = i;
const img = document.createElement('img'); img.src = f.url; img.alt = f.name || `thumb-${i+1}`;
const b = document.createElement('button'); b.className='remove'; b.textContent='×'; b.title='Remove';
b.addEventListener('click', (ev) => { ev.stopPropagation(); removeAt(i); });
t.addEventListener('dragstart', (e) => { t.classList.add('dragging'); e.dataTransfer.effectAllowed='move'; e.dataTransfer.setData('text/plain', String(i)); });
t.addEventListener('dragend', () => t.classList.remove('dragging'));
t.addEventListener('dragover', (e) => { e.preventDefault(); t.classList.add('drag-over'); });
t.addEventListener('dragleave', () => t.classList.remove('drag-over'));
t.addEventListener('drop', (e) => {
e.preventDefault(); t.classList.remove('drag-over');
const from = parseInt(e.dataTransfer.getData('text/plain'), 10);
const to = parseInt(t.dataset.index, 10);
if (isNaN(from) || isNaN(to) || from === to) return;
moveFile(from, to);
});
t.append(img, b);
els.thumbs.appendChild(t);
});
}
function moveFile(from, to){
const arr = state.files;
const item = arr.splice(from, 1)[0];
arr.splice(to, 0, item);
state.centre = clamp(state.centre, 0, Math.max(0, arr.length-1));
render();
}
function removeAt(i){
const f = state.files[i];
if(!f) return;
if(f.url && state.objURLs.has(f.url)){ URL.revokeObjectURL(f.url); state.objURLs.delete(f.url); }
if (f.sig) state.seenSigs.delete(f.sig);
state.files.splice(i,1);
state.centre = clamp(state.centre, 0, Math.max(0, state.files.length-1));
render();
}
function clearAll(){
state.files.forEach(f => { if(state.objURLs.has(f.url)){ URL.revokeObjectURL(f.url); } if (f.sig) state.seenSigs.delete(f.sig); });
state.objURLs.clear(); state.files = []; state.centre = 0; render();
}
function addFiles(fileList){
const incoming = Array.from(fileList).filter(f => f.type.startsWith('image/'));
if(!incoming.length) return;
const fresh = [];
incoming.forEach(f => {
const sig = `${f.name}|${f.size}|${f.lastModified || 0}`;
if (state.seenSigs.has(sig)) return;
const url = URL.createObjectURL(f);
state.objURLs.add(url); state.seenSigs.add(sig);
fresh.push({ id: uidCounter++, url, name: f.name, offset: 0, sig });
});
if(!fresh.length) return;
state.files.push(...fresh);
state.centre = Math.floor((state.files.length - 1) / 2);
els.file.value = '';
render();
}
/* ---- Controls wiring ---- */
[els.height, els.spacing, els.scale, els.tilt, els.arc, els.bgColor, els.shadow, els.transparent, els.edgeTuck]
.forEach(inp => inp.addEventListener('input', () => { updateCSSVars(); layout(); }));
window.addEventListener('resize', () => { syncImgHeightMax(); layout(); });
els.centre.addEventListener('input', () => { state.centre = parseInt(els.centre.value,10); layout(); });
els.file.addEventListener('change', (e) => addFiles(e.target.files));
function openPicker(){ els.file.click(); }
els.drop.addEventListener('click', (e) => { if(!(e.target instanceof HTMLLabelElement)) openPicker(); });
els.drop.addEventListener('keydown', (e) => { if(e.key==='Enter' || e.key===' '){ e.preventDefault(); openPicker(); } });
;['dragenter','dragover'].forEach(type => { els.drop.addEventListener(type, (e) => { e.preventDefault(); e.stopPropagation(); els.drop.classList.add('dragover'); }); });
;['dragleave','drop'].forEach(type => { els.drop.addEventListener(type, (e) => { e.preventDefault(); e.stopPropagation(); els.drop.classList.remove('dragover'); }); });
els.drop.addEventListener('drop', (e) => { addFiles(e.dataTransfer.files); });
els.clearBtn.addEventListener('click', clearAll);
window.addEventListener('keydown', (e) => {
if(!state.files.length) return;
if(e.key === 'ArrowLeft'){ state.centre = clamp(state.centre - 1, 0, state.files.length-1); layout(); }
if(e.key === 'ArrowRight'){ state.centre = clamp(state.centre + 1, 0, state.files.length-1); layout(); }
});
document.getElementById('resetAllOffsets').addEventListener('click', () => {
state.files.forEach(f => f.offset = 0);
buildAdvancedPanel();
layout();
});
/* ---- Demo placeholders ---- */
els.sampleBtn.addEventListener('click', () => {
const make = (w, h, hue) => {
const svg = encodeURIComponent(`<?xml version='1.0' encoding='UTF-8'?>\n<svg xmlns='http://www.w3.org/2000/svg' width='${w}' height='${h}' viewBox='0 0 ${w} ${h}'>\n <defs>\n <linearGradient id='g' x1='0' y1='0' x2='0' y2='1'>\n <stop offset='0' stop-color='hsl(${hue},70%,62%)' stop-opacity='.95'/>\n <stop offset='1' stop-color='hsl(${hue},70%,48%)' stop-opacity='.95'/>\n </linearGradient>\n <filter id='shadow' x='-50%' y='-50%' width='200%' height='200%'>\n <feDropShadow dx='0' dy='8' stdDeviation='8' flood-opacity='.15'/>\n </filter>\n </defs>\n <rect width='100%' height='100%' fill='none'/>\n <g filter='url(#shadow)'>\n <rect x='${w*0.3}' y='${h*0.08}' rx='${w*0.05}' ry='${w*0.05}' width='${w*0.4}' height='${h*0.84}' fill='url(#g)'/>\n <rect x='${w*0.4}' y='${h*0.02}' rx='${w*0.02}' ry='${w*0.02}' width='${w*0.2}' height='${h*0.08}' fill='hsl(${hue},60%,35%)'/>\n </g>\n</svg>`);
return `data:image/svg+xml;charset=UTF-8,${svg}`;
};
const hues = [205, 15, 265, 35, 125, 300, 185];
const fresh = hues.map((hue, i) => ({ id: uidCounter++, url: make(800, 1200, hue), name: `placeholder-${i+1}.svg`, offset: 0 }));
state.files.push(...fresh);
state.centre = Math.floor((state.files.length - 1) / 2);
render();
});
/* ---- Download split menu ---- */
els.dlMain.addEventListener('click', () => runDownloadPreset('hd'));
els.dlCaret.addEventListener('click', (e) => {
e.stopPropagation();
const open = els.dlMenu.classList.toggle('open');
els.dlCaret.setAttribute('aria-expanded', String(open));
});
els.dlMenu.addEventListener('click', (e) => {
if(e.target instanceof HTMLButtonElement && e.target.dataset.preset){
els.dlMenu.classList.remove('open');
els.dlCaret.setAttribute('aria-expanded','false');
runDownloadPreset(e.target.dataset.preset);
}
});
document.addEventListener('click', (e) => {
if(!document.getElementById('downloadGroup').contains(e.target)){
els.dlMenu.classList.remove('open');
els.dlCaret.setAttribute('aria-expanded','false');
}
});
/* ---------- EXPORT: WYSIWYG SCALING ---------- */
const getCanvasSize = () => {
const rect = els.canvas.getBoundingClientRect();
return { W: Math.max(1, Math.floor(rect.width)), H: Math.max(1, Math.floor(rect.height)) };
};
function drawToCanvas(targetW, targetH){
// Scale factor from on-screen canvas to off-screen export
const { W: screenW, H: screenH } = getCanvasSize();
const k = targetW / screenW; // same as targetH/screenH because we preserve aspect
const c = document.createElement('canvas');
c.width = targetW; c.height = targetH;
const ctx = c.getContext('2d');
const transparent = !!els.transparent.checked;
if(!transparent){
ctx.fillStyle = getComputedStyle(els.canvas).getPropertyValue('--bgCanvas') || '#fff';
ctx.fillRect(0,0,targetW,targetH);
}
// Read on-screen values and scale them by k
const spacing = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--spacing')) * k;
const scaleStep = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--scaleStep'));
const tiltPer = parseFloat(els.tilt.value);
const arc = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--arc')) * k;
const imgH = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--imgH')) * k;
const edgeTuck = parseFloat(els.edgeTuck.value || '0');
const cx = targetW/2, cy = targetH/2;
const twoMode = state.files.length === 2;
const order = state.files.map((_,i)=>i).sort((a,b)=>Math.abs(b - state.centre) - Math.abs(a - state.centre));
order.forEach(i => {
const imgEl = els.canvas.querySelector(`.item[data-index="${i}"] img`);
if(!imgEl) return;
let x, s, r, y;
if (twoMode) {
x = (i === 0 ? -1 : 1) * (spacing / 2);
s = 1;
r = (i === 0 ? -1 : 1) * (tiltPer * Math.PI/180);
y = 0;
} else {
const d = i - state.centre;
const ad = Math.abs(d);
x = d * spacing;
if ((i === 0 || i === (state.files.length - 1)) && edgeTuck > 0) x = x * (1 - edgeTuck);
s = Math.max(0.35, 1 - ad * scaleStep);
r = d * tiltPer * Math.PI/180;
y = -ad * arc;
}
// per-image offset (px on screen) -> scale to export
const f = state.files[i];
const perImageOffset = ((f && typeof f.offset === 'number') ? f.offset : 0) * k;
x += perImageOffset;
const natW = imgEl.naturalWidth || 1000;
const natH = imgEl.naturalHeight || 1000;
const drawH = imgH; // already scaled to k
const drawW = drawH * (natW / natH);
ctx.save();
ctx.translate(cx + x, cy + y);
ctx.rotate(r);
ctx.scale(s, s);
ctx.drawImage(imgEl, -drawW/2, -drawH/2, drawW, drawH);
ctx.restore();
});
return c;
}
async function runDownloadPreset(preset){
if(!state.files.length){ alert('Add at least one image first.'); return; }
const { W: screenW, H: screenH } = getCanvasSize();
const aspect = screenH / screenW;
let outW = 2000;
if(preset === 'hd') outW = 2000;
if(preset === 'sd') outW = 1000;
if(preset === 'sd-compressed') outW = 1000;
const outH = Math.round(outW * aspect);
if(preset === 'hd' || preset === 'sd'){
const drawCanvas = drawToCanvas(outW, outH);
const blob = await new Promise(res => drawCanvas.toBlob(res, 'image/png'));
if(!blob){ alert('Export failed.'); return; }
triggerDownload(blob, `packshot-lineup-${preset}-${outW}w.png`);
return;
}
if(preset === 'sd-compressed'){
// Always PNG with alpha. Try 1000px; if >150KB, shrink keeping WYSIWYG.
let testW = outW, blob = null, lastBlob = null;
while (testW >= 320) {
const testH = Math.round(testW * aspect);
const testCanvas = drawToCanvas(testW, testH);
blob = await new Promise(res => testCanvas.toBlob(res, 'image/png'));
if (!blob) break;
lastBlob = blob;
if (blob.size <= MAX_COMPRESSED_BYTES) break;
testW = Math.floor(testW * 0.9);
}
if (!lastBlob) { alert('Export failed.'); return; }
triggerDownload(lastBlob, `packshot-lineup-sd-compressed-${testW}w.png`);
return;
}
}
function triggerDownload(blob, filename){
const a = document.createElement('a');
const url = URL.createObjectURL(blob);
a.href = url; a.download = filename;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(()=>URL.revokeObjectURL(url), 1000);
}
/* ---- Init ---- */
syncImgHeightMax();
updateCSSVars();
// Upload wiring
els.file.addEventListener('change', (e) => addFiles(e.target.files));
function openPicker(){ els.file.click(); }
els.drop.addEventListener('click', (e) => { if(!(e.target instanceof HTMLLabelElement)) openPicker(); });
els.drop.addEventListener('keydown', (e) => { if(e.key==='Enter' || e.key===' '){ e.preventDefault(); openPicker(); } });
;['dragenter','dragover'].forEach(type => { els.drop.addEventListener(type, (e) => { e.preventDefault(); e.stopPropagation(); els.drop.classList.add('dragover'); }); });
;['dragleave','drop'].forEach(type => { els.drop.addEventListener(type, (e) => { e.preventDefault(); e.stopPropagation(); els.drop.classList.remove('dragover'); }); });
els.drop.addEventListener('drop', (e) => { addFiles(e.dataTransfer.files); });
render();
</script>
</body>
</html>