// script.js (() => { 'use strict'; // ----------------------------- // Accordion panel (shared) // ----------------------------- function setupAccordionPanel(options) { const { panelId, expandBtnId, collapseBtnId, reorderBtnId, storagePrefix } = options; const accPanel = document.getElementById(panelId); if (!accPanel) return; const expandBtn = document.getElementById(expandBtnId); const collapseBtn = document.getElementById(collapseBtnId); const reorderBtn = document.getElementById(reorderBtnId); const ACC_ORDER_KEY = `${storagePrefix}:accOrder:v1`; const ACC_OPEN_KEY = `${storagePrefix}:accOpen:v1`; const SCROLL_KEY = `${storagePrefix}:controlsScroll:v1`; const accSections = () => Array.from(accPanel.querySelectorAll('details[data-acc-id]')); function saveAccOpen() { const map = {}; accSections().forEach(d => (map[d.dataset.accId] = d.open ? 1 : 0)); try { localStorage.setItem(ACC_OPEN_KEY, JSON.stringify(map)); } catch {} } function restoreAccOpen() { try { const map = JSON.parse(localStorage.getItem(ACC_OPEN_KEY) || '{}'); accSections().forEach(d => { if (map[d.dataset.accId] === 1) d.open = true; if (map[d.dataset.accId] === 0) d.open = false; }); } catch {} } function saveAccOrder() { const order = accSections().map(d => d.dataset.accId); try { localStorage.setItem(ACC_ORDER_KEY, JSON.stringify(order)); } catch {} } function restoreAccOrder() { try { const order = JSON.parse(localStorage.getItem(ACC_ORDER_KEY) || '[]'); if (!Array.isArray(order) || order.length === 0) return; const map = new Map(accSections().map(d => [d.dataset.accId, d])); order.forEach(id => { const el = map.get(id); if (el) accPanel.appendChild(el); }); } catch {} } // --- drag/reorder within the panel let drag = { el: null, ph: null }; accPanel.addEventListener('click', e => { if (e.target.closest('.drag-handle')) e.preventDefault(); }); accPanel.addEventListener('pointerdown', e => { if (e.target.closest('.drag-handle')) e.stopPropagation(); }); accPanel.addEventListener('dragstart', e => { const handle = e.target.closest('.drag-handle'); if (!handle) return; drag.el = handle.closest('details[data-acc-id]'); drag.ph = document.createElement('div'); drag.ph.className = 'rounded-lg border border-dashed border-gray-300 bg-white/30'; drag.ph.style.height = drag.el.offsetHeight + 'px'; drag.el.classList.add('opacity-50'); drag.el.after(drag.ph); e.dataTransfer.effectAllowed = 'move'; }); accPanel.addEventListener('dragover', e => { if (!drag.el) return; e.preventDefault(); const y = e.clientY; let closest = null, dist = Infinity; for (const it of accSections().filter(x => x !== drag.el)) { const r = it.getBoundingClientRect(); const m = r.top + r.height / 2; const d = Math.abs(y - m); if (d < dist) { dist = d; closest = it; } } if (!closest) return; const r = closest.getBoundingClientRect(); if (y < r.top + r.height / 2) accPanel.insertBefore(drag.ph, closest); else accPanel.insertBefore(drag.ph, closest.nextSibling); }); function cleanupDrag() { if (!drag.el) return; drag.el.classList.remove('opacity-50'); if (drag.ph && drag.ph.parentNode) { accPanel.insertBefore(drag.el, drag.ph); drag.ph.remove(); } drag.el = drag.ph = null; saveAccOrder(); } accPanel.addEventListener('drop', e => { if (drag.el) { e.preventDefault(); cleanupDrag(); }}); accPanel.addEventListener('dragend', () => cleanupDrag()); accPanel.addEventListener('toggle', e => { if (e.target.matches('details[data-acc-id]')) saveAccOpen(); }, true); accPanel.addEventListener('scroll', () => { try { localStorage.setItem(SCROLL_KEY, String(accPanel.scrollTop)); } catch {} }); function restorePanelScroll() { accPanel.scrollTop = Number(localStorage.getItem(SCROLL_KEY)) || 0; } // Toolbar expandBtn?.addEventListener('click', () => { accSections().forEach(d => (d.open = true)); saveAccOpen(); }); collapseBtn?.addEventListener('click', () => { accSections().forEach(d => (d.open = false)); saveAccOpen(); }); reorderBtn?.addEventListener('click', () => { const isReordering = accPanel.classList.toggle('reorder-on'); reorderBtn.setAttribute('aria-pressed', String(isReordering)); }); // Init restoreAccOrder(); restoreAccOpen(); restorePanelScroll(); } // expose for classic.js window.setupAccordionPanel = setupAccordionPanel; // ----------------------------- // Organic app logic // ----------------------------- document.addEventListener('DOMContentLoaded', () => { // ====== GLOBAL SCALE ====== const PX_PER_INCH = 4; const SIZE_PRESETS = [24, 18, 11, 9, 5]; // ====== Shine ellipse tuning ====== const SHINE_OFFSET = 0.30, SHINE_RX = 0.40, SHINE_RY = 0.24, SHINE_ROT = -25, SHINE_ALPHA = 0.7; // ROT is now in degrees let view = { s: 1, tx: 0, ty: 0 }; const FIT_PADDING_PX = 15; // ====== Texture defaults ====== const TEXTURE_ZOOM_DEFAULT = 1.8; const TEXTURE_FOCUS_DEFAULT = { x: 0.5, y: 0.5 }; const SWATCH_TEXTURE_ZOOM = 2.5; const clamp = (v, min, max) => Math.max(min, Math.min(max, v)); const clamp01 = v => clamp(v, 0, 1); const QUERY_KEY = 'd'; // ====== Flatten palette & maps ====== const FLAT_COLORS = []; const NAME_BY_HEX = new Map(); const HEX_TO_FIRST_IDX = new Map(); const allowedSet = new Set(); (function buildFlat() { if (!Array.isArray(window.PALETTE)) return; window.PALETTE.forEach(group => { (group.colors || []).forEach(c => { if (!c?.hex) return; const item = { ...c, family: group.family }; item.imageZoom = Number.isFinite(c.imageZoom) ? Math.max(1, c.imageZoom) : TEXTURE_ZOOM_DEFAULT; item.imageFocus = { x: clamp01(c.imageFocusX ?? c.imageFocus?.x ?? TEXTURE_FOCUS_DEFAULT.x), y: clamp01(c.imageFocusY ?? c.imageFocus?.y ?? TEXTURE_FOCUS_DEFAULT.y) }; item._idx = FLAT_COLORS.length; FLAT_COLORS.push(item); const key = (c.hex || '').toLowerCase(); if (!NAME_BY_HEX.has(key)) NAME_BY_HEX.set(key, c.name); if (!HEX_TO_FIRST_IDX.has(key)) HEX_TO_FIRST_IDX.set(key, item._idx); allowedSet.add(key); }); }); })(); // ====== Image cache ====== const IMG_CACHE = new Map(); function getImage(path) { if (!path) return null; let img = IMG_CACHE.get(path); if (!img) { img = new Image(); img.decoding = 'async'; img.loading = 'eager'; img.src = path; img.onload = () => draw(); IMG_CACHE.set(path, img); } return img; } // ====== DOM ====== const canvas = document.getElementById('balloon-canvas'); const ctx = canvas?.getContext('2d'); // tool buttons const toolDrawBtn = document.getElementById('tool-draw'); const toolEraseBtn = document.getElementById('tool-erase'); const toolSelectBtn = document.getElementById('tool-select'); // panels/controls const eraserControls = document.getElementById('eraser-controls'); const selectControls = document.getElementById('select-controls'); const eraserSizeInput = document.getElementById('eraser-size'); const eraserSizeLabel = document.getElementById('eraser-size-label'); const deleteSelectedBtn = document.getElementById('delete-selected'); const duplicateSelectedBtn = document.getElementById('duplicate-selected'); const sizePresetGroup = document.getElementById('size-preset-group'); const toggleShineBtn = document.getElementById('toggle-shine-btn'); const paletteBox = document.getElementById('color-palette'); const usedPaletteBox = document.getElementById('used-palette'); const sortUsedToggle = document.getElementById('sort-used-toggle'); // replace colors panel const replaceFromSel = document.getElementById('replace-from'); const replaceToSel = document.getElementById('replace-to'); const replaceBtn = document.getElementById('replace-btn'); const replaceMsg = document.getElementById('replace-msg'); // IO const clearCanvasBtn = document.getElementById('clear-canvas-btn'); const saveJsonBtn = document.getElementById('save-json-btn'); const loadJsonInput = document.getElementById('load-json-input'); // delegate export buttons (shared IDs across tabs) document.body.addEventListener('click', e => { if (e.target.id === 'export-png-btn') exportPng(); else if (e.target.id === 'export-svg-btn') exportSvg(); }); const generateLinkBtn = document.getElementById('generate-link-btn'); const shareLinkOutput = document.getElementById('share-link-output'); const copyMessage = document.getElementById('copy-message'); // messages const messageModal = document.getElementById('message-modal'); const modalText = document.getElementById('modal-text'); const modalCloseBtn = document.getElementById('modal-close-btn'); // layout const controlsPanel = document.getElementById('controls-panel'); const canvasPanel = document.getElementById('canvas-panel'); const expandBtn = document.getElementById('expand-workspace-btn'); const fullscreenBtn = document.getElementById('fullscreen-btn'); if (!canvas || !ctx) return; // nothing to do if organic UI isn't on page // ====== State ====== let balloons = []; let selectedColorIdx = 0; let currentDiameterInches = 11; let currentRadius = inchesToRadiusPx(currentDiameterInches); let isShineEnabled = true; // will be initialized from localStorage let dpr = 1; let mode = 'draw'; let eraserRadius = parseInt(eraserSizeInput?.value || '40', 10); let mouseInside = false; let mousePos = { x: 0, y: 0 }; let selectedBalloonId = null; let usedSortDesc = true; // ====== Helpers ====== const normalizeHex = h => (h || '').toLowerCase(); function inchesToRadiusPx(diam) { return (diam * PX_PER_INCH) / 2; } function radiusToSizeIndex(r) { let best = 0, bestDiff = Infinity; for (let i = 0; i < SIZE_PRESETS.length; i++) { const diff = Math.abs(inchesToRadiusPx(SIZE_PRESETS[i]) - r); if (diff < bestDiff) { best = i; bestDiff = diff; } } return best; } function showModal(msg) { if (!messageModal) return; modalText.textContent = msg; messageModal.classList.remove('hidden'); } function hideModal() { if (!messageModal) return; messageModal.classList.add('hidden'); } function showCopyMessage() { if (!copyMessage) return; copyMessage.classList.add('show'); setTimeout(() => copyMessage.classList.remove('show'), 2000); } function getMousePos(e) { const r = canvas.getBoundingClientRect(); return { x: (e.clientX - r.left) / view.s - view.tx, y: (e.clientY - r.top) / view.s - view.ty }; } // ====== Global shine sync (shared with Classic) window.syncAppShine = function(isEnabled) { isShineEnabled = isEnabled; // mirror both UIs const organicBtn = document.getElementById('toggle-shine-btn'); const classicCb = document.getElementById('classic-shine-enabled'); if (organicBtn) organicBtn.textContent = isEnabled ? 'Turn Off Shine' : 'Turn On Shine'; if (classicCb) classicCb.checked = isEnabled; try { localStorage.setItem('app:shineEnabled:v1', JSON.stringify(isEnabled)); } catch {} // push into Classic engine if available if (window.ClassicDesigner?.api?.setShineEnabled) { window.ClassicDesigner.api.setShineEnabled(isEnabled); } // redraw both tabs (cheap + robust) try { draw?.(); } catch {} try { window.ClassicDesigner?.redraw?.(); } catch {} }; function setMode(next) { mode = next; toolDrawBtn?.setAttribute('aria-pressed', String(mode === 'draw')); toolEraseBtn?.setAttribute('aria-pressed', String(mode === 'erase')); toolSelectBtn?.setAttribute('aria-pressed', String(mode === 'select')); eraserControls?.classList.toggle('hidden', mode !== 'erase'); selectControls?.classList.toggle('hidden', mode !== 'select'); canvas.style.cursor = (mode === 'erase') ? 'none' : (mode === 'select' ? 'pointer' : 'crosshair'); draw(); persist(); } function updateSelectButtons() { const has = !!selectedBalloonId; if (deleteSelectedBtn) deleteSelectedBtn.disabled = !has; if (duplicateSelectedBtn) duplicateSelectedBtn.disabled = !has; } // ====== Pointer Events ====== let pointerDown = false; canvas.addEventListener('pointerdown', e => { e.preventDefault(); canvas.setPointerCapture?.(e.pointerId); mouseInside = true; mousePos = getMousePos(e); if (e.altKey) { pickColorAt(mousePos.x, mousePos.y); return; } if (mode === 'erase') { pointerDown = true; eraseAt(mousePos.x, mousePos.y); return; } if (mode === 'select') { selectAt(mousePos.x, mousePos.y); return; } // draw mode: add addBalloon(mousePos.x, mousePos.y); }, { passive: false }); canvas.addEventListener('pointermove', e => { mousePos = getMousePos(e); if (mode === 'erase') { if (pointerDown) eraseAt(mousePos.x, mousePos.y); else draw(); } }, { passive: true }); canvas.addEventListener('pointerup', e => { pointerDown = false; canvas.releasePointerCapture?.(e.pointerId); }, { passive: true }); canvas.addEventListener('pointerleave', () => { mouseInside = false; if (mode === 'erase') draw(); }, { passive: true }); // ====== Canvas & Drawing ====== function resizeCanvas() { const rect = canvas.getBoundingClientRect(); dpr = Math.max(1, window.devicePixelRatio || 1); canvas.width = Math.round(rect.width * dpr); canvas.height = Math.round(rect.height * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); fitView(); draw(); } function clearCanvasArea() { ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr); } function draw() { clearCanvasArea(); ctx.save(); ctx.scale(view.s, view.s); ctx.translate(view.tx, view.ty); balloons.forEach(b => { if (b.image) { const img = getImage(b.image); if (img && img.complete && img.naturalWidth > 0) { const meta = FLAT_COLORS[b.colorIdx] || {}; const zoom = Math.max(1, meta.imageZoom ?? TEXTURE_ZOOM_DEFAULT); const fx = clamp01(meta.imageFocus?.x ?? TEXTURE_FOCUS_DEFAULT.x); const fy = clamp01(meta.imageFocus?.y ?? TEXTURE_FOCUS_DEFAULT.y); const srcW = img.naturalWidth / zoom; const srcH = img.naturalHeight / zoom; const srcX = clamp(fx * img.naturalWidth - srcW/2, 0, img.naturalWidth - srcW); const srcY = clamp(fy * img.naturalHeight - srcH/2, 0, img.naturalHeight - srcH); ctx.save(); ctx.beginPath(); ctx.arc(b.x, b.y, b.radius, 0, Math.PI * 2); ctx.clip(); ctx.drawImage(img, srcX, srcY, srcW, srcH, b.x - b.radius, b.y - b.radius, b.radius * 2, b.radius * 2); ctx.restore(); } else { // fallback solid ctx.beginPath(); ctx.arc(b.x, b.y, b.radius, 0, Math.PI * 2); ctx.fillStyle = b.color; ctx.shadowColor = 'rgba(0,0,0,0.2)'; ctx.shadowBlur = 10; ctx.fill(); ctx.shadowBlur = 0; } } else { // solid fill ctx.beginPath(); ctx.arc(b.x, b.y, b.radius, 0, Math.PI * 2); ctx.fillStyle = b.color; ctx.shadowColor = 'rgba(0,0,0,0.2)'; ctx.shadowBlur = 10; ctx.fill(); ctx.shadowBlur = 0; } if (isShineEnabled) { const sx = b.x - b.radius * SHINE_OFFSET; const sy = b.y - b.radius * SHINE_OFFSET; const rx = b.radius * SHINE_RX; const ry = b.radius * SHINE_RY; const rotRad = SHINE_ROT * Math.PI / 180; ctx.save(); ctx.shadowColor = 'rgba(0,0,0,0.1)'; ctx.shadowBlur = 3; // SHINE_BLUR ctx.beginPath(); if (ctx.ellipse) { ctx.ellipse(sx, sy, rx, ry, rotRad, 0, Math.PI * 2); } else { ctx.translate(sx, sy); ctx.rotate(rotRad); ctx.scale(rx / ry, 1); ctx.arc(0, 0, ry, 0, Math.PI * 2); } ctx.fillStyle = `rgba(255,255,255,${SHINE_ALPHA})`; ctx.fill(); ctx.restore(); } }); // selection ring if (selectedBalloonId) { const b = balloons.find(bb => bb.id === selectedBalloonId); if (b) { ctx.save(); ctx.beginPath(); ctx.arc(b.x, b.y, b.radius + 3, 0, Math.PI * 2); ctx.setLineDash([6, 4]); ctx.lineWidth = 2 / view.s; ctx.strokeStyle = '#2563eb'; ctx.stroke(); ctx.setLineDash([]); ctx.restore(); } } // eraser preview if (mode === 'erase' && mouseInside) { ctx.save(); ctx.beginPath(); ctx.arc(mousePos.x, mousePos.y, eraserRadius, 0, Math.PI * 2); ctx.lineWidth = 1.5 / view.s; ctx.strokeStyle = 'rgba(31,41,55,0.8)'; ctx.setLineDash([4 / view.s, 4 / view.s]); ctx.stroke(); ctx.restore(); } ctx.restore(); } // --- Workspace expansion + Fullscreen --- let expanded = false; function setExpanded(on) { expanded = on; controlsPanel?.classList.toggle('hidden', expanded); if (canvasPanel) { canvasPanel.classList.toggle('lg:w-full', expanded); canvasPanel.classList.toggle('lg:w-2/3', !expanded); } if (expanded) { canvas.classList.remove('aspect-video'); canvas.style.height = '85vh'; } else { canvas.classList.add('aspect-video'); canvas.style.height = ''; } resizeCanvas(); if (expandBtn) expandBtn.textContent = expanded ? 'Exit expanded view' : 'Expand workspace'; persist(); } function isFullscreen() { return !!(document.fullscreenElement || document.webkitFullscreenElement); } async function toggleFullscreenPage() { try { if (!isFullscreen()) { await document.documentElement.requestFullscreen(); } else { await document.exitFullscreen(); } } catch { // if blocked, just use expanded setExpanded(true); } } const onFsChange = () => { if (fullscreenBtn) fullscreenBtn.textContent = isFullscreen() ? 'Exit Fullscreen' : 'Fullscreen'; resizeCanvas(); }; expandBtn?.addEventListener('click', () => setExpanded(!expanded)); fullscreenBtn?.addEventListener('click', toggleFullscreenPage); document.addEventListener('fullscreenchange', onFsChange); document.addEventListener('webkitfullscreenchange', onFsChange); new ResizeObserver(() => resizeCanvas()).observe(canvas.parentElement); canvas.style.touchAction = 'none'; // ====== State Persistence ====== const APP_STATE_KEY = 'obd:state:v3'; function saveAppState() { // Note: isShineEnabled is managed globally. const state = { balloons, selectedColorIdx, currentDiameterInches, eraserRadius, mode, view, usedSortDesc, expanded }; try { localStorage.setItem(APP_STATE_KEY, JSON.stringify(state)); } catch {} } const persist = (() => { let t; return () => { clearTimeout(t); t = setTimeout(saveAppState, 120); }; })(); function loadAppState() { try { const s = JSON.parse(localStorage.getItem(APP_STATE_KEY) || '{}'); if (Array.isArray(s.balloons)) balloons = s.balloons; if (typeof s.selectedColorIdx === 'number') selectedColorIdx = s.selectedColorIdx; if (typeof s.currentDiameterInches === 'number') { currentDiameterInches = s.currentDiameterInches; currentRadius = inchesToRadiusPx(currentDiameterInches); } if (typeof s.eraserRadius === 'number') { eraserRadius = s.eraserRadius; if (eraserSizeInput) eraserSizeInput.value = eraserRadius; if (eraserSizeLabel) eraserSizeLabel.textContent = eraserRadius; } if (typeof s.mode === 'string') mode = s.mode; if (s.view && typeof s.view.s === 'number') view = s.view; if (typeof s.usedSortDesc === 'boolean') { usedSortDesc = s.usedSortDesc; if (sortUsedToggle) sortUsedToggle.textContent = usedSortDesc ? 'Sort: Most → Least' : 'Sort: Least → Most'; } if (typeof s.expanded === 'boolean') setExpanded(s.expanded); } catch {} } loadAppState(); // ====== UI Rendering (Palettes) ====== function renderAllowedPalette() { if (!paletteBox) return; paletteBox.innerHTML = ''; (window.PALETTE || []).forEach(group => { const title = document.createElement('div'); title.className = 'family-title'; title.textContent = group.family; paletteBox.appendChild(title); const row = document.createElement('div'); row.className = 'swatch-row'; (group.colors || []).forEach(c => { const idx = FLAT_COLORS.find(fc => fc.name === c.name && fc.hex === c.hex && fc.family === group.family)?._idx ?? HEX_TO_FIRST_IDX.get(normalizeHex(c.hex)); const sw = document.createElement('div'); sw.className = 'swatch'; if (c.image) { const meta = FLAT_COLORS[idx] || {}; sw.style.backgroundImage = `url("${c.image}")`; sw.style.backgroundSize = `${100 * SWATCH_TEXTURE_ZOOM}%`; sw.style.backgroundPosition = `${(meta.imageFocus?.x ?? 0.5) * 100}% ${(meta.imageFocus?.y ?? 0.5) * 100}%`; } else { sw.style.backgroundColor = c.hex; } if (idx === selectedColorIdx) sw.classList.add('active'); sw.title = c.name; sw.addEventListener('click', () => { selectedColorIdx = idx ?? 0; renderAllowedPalette(); persist(); }); row.appendChild(sw); }); paletteBox.appendChild(row); }); } function getUsedColors() { const map = new Map(); balloons.forEach(b => { const key = normalizeHex(b.color); if (!allowedSet.has(key)) return; if (!map.has(key)) { const meta = FLAT_COLORS[HEX_TO_FIRST_IDX.get(key)] || {}; map.set(key, { hex: key, count: 0, image: meta.image, name: meta.name }); } map.get(key).count++; }); const arr = [...map.values()]; arr.sort((a, b) => (usedSortDesc ? (b.count - a.count) : (a.count - b.count))); return arr; } function renderUsedPalette() { if (!usedPaletteBox) return; usedPaletteBox.innerHTML = ''; const used = getUsedColors(); if (used.length === 0) { usedPaletteBox.innerHTML = '
No colors yet.
'; if (replaceFromSel) replaceFromSel.innerHTML = ''; return; } const row = document.createElement('div'); row.className = 'swatch-row'; used.forEach(item => { const sw = document.createElement('div'); sw.className = 'swatch'; if (item.image) { const meta = FLAT_COLORS[HEX_TO_FIRST_IDX.get(item.hex)] || {}; sw.style.backgroundImage = `url("${item.image}")`; sw.style.backgroundSize = `${100 * SWATCH_TEXTURE_ZOOM}%`; sw.style.backgroundPosition = `${(meta.imageFocus?.x ?? 0.5) * 100}% ${(meta.imageFocus?.y ?? 0.5) * 100}%`; } else { sw.style.backgroundColor = item.hex; } if (normalizeHex(FLAT_COLORS[selectedColorIdx]?.hex) === item.hex) sw.classList.add('active'); sw.title = `${item.name || NAME_BY_HEX.get(item.hex) || item.hex} — ${item.count}`; sw.addEventListener('click', () => { selectedColorIdx = HEX_TO_FIRST_IDX.get(item.hex) ?? 0; renderAllowedPalette(); renderUsedPalette(); }); const badge = document.createElement('div'); badge.className = 'badge'; badge.textContent = String(item.count); sw.appendChild(badge); row.appendChild(sw); }); usedPaletteBox.appendChild(row); // fill "replace from" if (replaceFromSel) { replaceFromSel.innerHTML = ''; used.forEach(item => { const opt = document.createElement('option'); const name = item.name || NAME_BY_HEX.get(item.hex) || item.hex; opt.value = item.hex; opt.textContent = `${name} (${item.count})`; replaceFromSel.appendChild(opt); }); } } // ====== Balloon Ops & Data/Export ====== function addBalloon(x, y) { const meta = FLAT_COLORS[selectedColorIdx] || FLAT_COLORS[0]; balloons.push({ x, y, radius: currentRadius, color: meta.hex, image: meta.image || null, colorIdx: meta._idx, id: crypto.randomUUID() }); ensureVisibleAfterAdd(balloons[balloons.length - 1]); refreshAll(); } function findBalloonIndexAt(x, y) { for (let i = balloons.length - 1; i >= 0; i--) { const b = balloons[i]; if (Math.hypot(x - b.x, y - b.y) <= b.radius) return i; } return -1; } function selectAt(x, y) { const i = findBalloonIndexAt(x, y); selectedBalloonId = (i !== -1) ? balloons[i].id : null; updateSelectButtons(); draw(); } function deleteSelected() { if (!selectedBalloonId) return; balloons = balloons.filter(b => b.id !== selectedBalloonId); selectedBalloonId = null; updateSelectButtons(); refreshAll(); } function duplicateSelected() { if (!selectedBalloonId) return; const b = balloons.find(bb => bb.id === selectedBalloonId); if (!b) return; const copy = { ...b, x: b.x + 10, y: b.y + 10, id: crypto.randomUUID() }; balloons.push(copy); selectedBalloonId = copy.id; refreshAll(); } function eraseAt(x, y) { balloons = balloons.filter(b => Math.hypot(x - b.x, y - b.y) > eraserRadius); if (selectedBalloonId && !balloons.find(b => b.id === selectedBalloonId)) { selectedBalloonId = null; updateSelectButtons(); } refreshAll(); } function pickColorAt(x, y) { const i = findBalloonIndexAt(x, y); if (i !== -1) { selectedColorIdx = HEX_TO_FIRST_IDX.get(normalizeHex(balloons[i].color)) ?? 0; renderAllowedPalette(); renderUsedPalette(); } } function promptForFilename(suggested) { const m = suggested.match(/\.([a-z0-9]+)$/i); const ext = m ? m[1].toLowerCase() : ''; const defaultBase = suggested.replace(/\.[^.]+$/, ''); const lsKey = ext ? `lastFilenameBase.${ext}` : `lastFilenameBase`; const last = localStorage.getItem(lsKey) || defaultBase; const input = window.prompt(ext ? `File name (.${ext} will be added)` : 'File name', last); if (input === null) return null; let base = (input.trim() || defaultBase) .replace(/[<>:"/\\|?*\x00-\x1F]/g, '') .replace(/\.+$/, '') .replace(/\.[^.]+$/, ''); try { localStorage.setItem(lsKey, base); } catch {} return ext ? `${base}.${ext}` : base; } function download(href, suggestedFilename) { const finalName = promptForFilename(suggestedFilename); if (!finalName) return; const a = document.createElement('a'); a.href = href; a.download = finalName; a.click(); a.remove(); } function saveJson() { download('data:text/json;charset=utf-8,' + encodeURIComponent(JSON.stringify({ balloons })), 'balloon_design.json'); } function loadJson(e) { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = ev => { try { const data = JSON.parse(ev.target.result); balloons = Array.isArray(data.balloons) ? data.balloons.map(b => { const idx = b.colorIdx ?? (HEX_TO_FIRST_IDX.get(normalizeHex(b.color)) ?? 0); const meta = FLAT_COLORS[idx] || {}; return { x: b.x, y: b.y, radius: b.radius, color: meta.hex || b.color, image: meta.image || null, colorIdx: idx, id: crypto.randomUUID() }; }) : []; selectedBalloonId = null; updateSelectButtons(); refreshAll({ refit: true }); persist(); } catch { showModal('Error parsing JSON file.'); } }; reader.readAsText(file); } // ** NEW ** Rewritten export function to embed images async function exportPng() { const currentTab = (window.__whichTab && window.__whichTab()) || '#tab-organic'; if (currentTab === '#tab-classic') { const svgElement = document.querySelector('#classic-display svg'); if (!svgElement) { showModal('Classic design not found. Please create a design first.'); return; } // 1. Clone the SVG to avoid modifying the live one const clonedSvg = svgElement.cloneNode(true); const imageElements = Array.from(clonedSvg.querySelectorAll('image')); // 2. Create promises to fetch and convert each image to a Data URL const promises = imageElements.map(async (image) => { const href = image.getAttribute('href'); if (!href || href.startsWith('data:')) return; // Skip if no href or already a data URL try { const response = await fetch(href); const blob = await response.blob(); const dataUrl = await new Promise(resolve => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result); reader.readAsDataURL(blob); }); image.setAttribute('href', dataUrl); } catch (error) { console.error(`Could not fetch image ${href}:`, error); } }); // 3. Wait for all images to be embedded await Promise.all(promises); // 4. Serialize the modified, self-contained SVG const svgData = new XMLSerializer().serializeToString(clonedSvg); const img = new Image(); img.onload = () => { const viewBox = svgElement.getAttribute('viewBox').split(' ').map(Number); const svgWidth = viewBox[2]; const svgHeight = viewBox[3]; const scale = 2; // for higher resolution const canvasEl = document.createElement('canvas'); canvasEl.width = svgWidth * scale; canvasEl.height = svgHeight * scale; const ctx2 = canvasEl.getContext('2d'); ctx2.drawImage(img, 0, 0, canvasEl.width, canvasEl.height); download(canvasEl.toDataURL('image/png'), 'classic_design.png'); }; img.onerror = () => { showModal("An error occurred while creating the PNG from the SVG."); }; img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgData))); } else { // Organic canvas export (remains the same) if (balloons.length === 0) { showModal('Canvas is empty.'); return; } download(canvas.toDataURL('image/png'), 'balloon_design.png'); } } function exportSvg() { const currentTab = (window.__whichTab && window.__whichTab()) || '#tab-organic'; if (currentTab === '#tab-classic') { const svgElement = document.querySelector('#classic-display svg'); if (!svgElement) { showModal('Classic design not found.'); return; } const svgData = new XMLSerializer().serializeToString(svgElement); const url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgData)}`; download(url, 'classic_design.svg'); } else { // Organic canvas-to-SVG export if (balloons.length === 0) { showModal('Canvas is empty. Add some balloons first.'); return; } const bounds = balloonsBounds(); const pad = 20; const vb = [bounds.minX - pad, bounds.minY - pad, bounds.w + pad * 2, bounds.h + pad * 2].join(' '); let defs = ''; let elements = ''; const patterns = new Map(); balloons.forEach(b => { let fill = b.color; if (b.image) { const patternId = `p${b.colorIdx}`; if (!patterns.has(b.colorIdx)) { patterns.set(b.colorIdx, patternId); const meta = FLAT_COLORS[b.colorIdx] || {}; const zoom = Math.max(1, meta.imageZoom ?? TEXTURE_ZOOM_DEFAULT); const fx = clamp01(meta.imageFocus?.x ?? TEXTURE_FOCUS_DEFAULT.x); const fy = clamp01(meta.imageFocus?.y ?? TEXTURE_FOCUS_DEFAULT.y); // Calculate image attributes to simulate the canvas crop/zoom const imgW = zoom; const imgH = zoom; const imgX = 0.5 - (fx * zoom); const imgY = 0.5 - (fy * zoom); defs += ` `; } fill = `url(#${patternId})`; } elements += `\n`; if (isShineEnabled) { const sx = b.x - b.radius * SHINE_OFFSET; const sy = b.y - b.radius * SHINE_OFFSET; const rx = b.radius * SHINE_RX; const ry = b.radius * SHINE_RY; elements += `\n`; } }); const svgData = ` ${defs} ${elements} `; const url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgData)}`; download(url, 'organic_design.svg'); } } function designToCompact(list) { return { v: 2, b: list.map(b => [ Math.round(b.x), Math.round(b.y), radiusToSizeIndex(b.radius), b.colorIdx ?? 0 ]) }; } function compactToDesign(obj) { if (!obj || !Array.isArray(obj.b)) return []; return obj.b.map(row => { const [x, y, sizeIdx, colorIdx] = row; const diam = SIZE_PRESETS[sizeIdx] ?? SIZE_PRESETS[0]; const radius = inchesToRadiusPx(diam); const meta = FLAT_COLORS[colorIdx] || FLAT_COLORS[0]; return { x, y, radius, color: meta.hex, image: meta.image || null, colorIdx: meta._idx, id: crypto.randomUUID() }; }); } function generateShareLink() { const base = `${window.location.origin}${window.location.pathname}`; const link = `${base}?${QUERY_KEY}=${LZString.compressToEncodedURIComponent(JSON.stringify(designToCompact(balloons)))}`; if (shareLinkOutput) shareLinkOutput.value = link; navigator.clipboard?.writeText(link).then(showCopyMessage); } function loadFromUrl() { const params = new URLSearchParams(window.location.search); const encoded = params.get(QUERY_KEY) || params.get('design'); if (!encoded) return; try { let jsonStr = LZString.decompressFromEncodedURIComponent(encoded) || atob(encoded); const data = JSON.parse(jsonStr); balloons = Array.isArray(data.balloons) ? data.balloons.map(b => { const idx = b.colorIdx ?? (HEX_TO_FIRST_IDX.get(normalizeHex(b.color)) ?? 0); const meta = FLAT_COLORS[idx] || {}; return { x: b.x, y: b.y, radius: b.radius, color: meta.hex, image: meta.image, colorIdx: idx, id: crypto.randomUUID() }; }) : compactToDesign(data); refreshAll({ refit: true }); persist(); showModal('Design loaded from link!'); } catch { showModal('Could not load design from URL.'); } } // ====== Fit/Camera helpers ====== function balloonsBounds() { if (balloons.length === 0) return { minX: 0, minY: 0, maxX: 500, maxY: 500, w: 500, h: 500 }; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const b of balloons) { minX = Math.min(minX, b.x - b.radius); minY = Math.min(minY, b.y - b.radius); maxX = Math.max(maxX, b.x + b.radius); maxY = Math.max(maxY, b.y + b.radius); } return { minX, minY, maxX, maxY, w: maxX - minX, h: maxY - minY }; } function fitView() { const box = balloonsBounds(); const cw = canvas.width / dpr; // CSS px const ch = canvas.height / dpr; if (balloons.length === 0) { view.s = 1; view.tx = 0; view.ty = 0; return; } const pad = FIT_PADDING_PX; const w = Math.max(1, box.w); const h = Math.max(1, box.h); const sFit = Math.min((cw - 2*pad) / w, (ch - 2*pad) / h); view.s = Math.min(1, isFinite(sFit) && sFit > 0 ? sFit : 1); const worldW = cw / view.s; const worldH = ch / view.s; view.tx = (worldW - w) * 0.5 - box.minX; view.ty = (worldH - h) * 0.5 - box.minY; } function balloonScreenBounds(b) { const left = (b.x - b.radius + view.tx) * view.s; const right = (b.x + b.radius + view.tx) * view.s; const top = (b.y - b.radius + view.ty) * view.s; const bottom = (b.y + b.radius + view.ty) * view.s; return { left, right, top, bottom }; } function ensureVisibleAfterAdd(b) { const pad = FIT_PADDING_PX; const cw = canvas.width / dpr; const ch = canvas.height / dpr; // zoom out only if needed to keep the new balloon visible const needSx = (cw - 2*pad) / (2*b.radius); const needSy = (ch - 2*pad) / (2*b.radius); const sNeeded = Math.min(needSx, needSy); if (isFinite(sNeeded) && sNeeded > 0 && sNeeded < view.s) { view.s = Math.max(0.05, sNeeded); } const r = balloonScreenBounds(b); let dx = 0, dy = 0; if (r.left < pad) dx = (pad - r.left) / view.s; else if (r.right > cw-pad) dx = ((cw - pad) - r.right) / view.s; if (r.top < pad) dy = (pad - r.top) / view.s; else if (r.bottom > ch-pad) dy = ((ch - pad) - r.bottom) / view.s; view.tx += dx; view.ty += dy; } // ====== Refresh & Events ====== function refreshAll({ refit = false } = {}) { if (refit) fitView(); draw(); renderUsedPalette(); persist(); if(window.updateExportButtonVisibility) window.updateExportButtonVisibility(); } // --- UI bindings --- modalCloseBtn?.addEventListener('click', hideModal); toolDrawBtn?.addEventListener('click', () => setMode('draw')); toolEraseBtn?.addEventListener('click', () => setMode('erase')); toolSelectBtn?.addEventListener('click', () => setMode('select')); eraserSizeInput?.addEventListener('input', e => { eraserRadius = parseInt(e.target.value, 10); if (eraserSizeLabel) eraserSizeLabel.textContent = eraserRadius; if (mode === 'erase') draw(); persist(); }); deleteSelectedBtn?.addEventListener('click', deleteSelected); duplicateSelectedBtn?.addEventListener('click', duplicateSelected); document.addEventListener('keydown', e => { if (document.activeElement && document.activeElement.tagName === 'INPUT') return; if (e.key === 'e' || e.key === 'E') setMode('erase'); else if (e.key === 'v' || e.key === 'V') setMode('draw'); else if (e.key === 's' || e.key === 'S') setMode('select'); else if (e.key === 'Escape') { selectedBalloonId = null; updateSelectButtons(); draw(); } else if (e.key === 'Delete' || e.key === 'Backspace') { if (selectedBalloonId) { e.preventDefault(); deleteSelected(); } } }); clearCanvasBtn?.addEventListener('click', () => { balloons = []; selectedBalloonId = null; updateSelectButtons(); refreshAll({ refit: true }); }); saveJsonBtn?.addEventListener('click', saveJson); loadJsonInput?.addEventListener('change', loadJson); generateLinkBtn?.addEventListener('click', generateShareLink); sortUsedToggle?.addEventListener('click', () => { usedSortDesc = !usedSortDesc; sortUsedToggle.textContent = usedSortDesc ? 'Sort: Most → Least' : 'Sort: Least → Most'; renderUsedPalette(); persist(); }); function populateReplaceTo() { if (!replaceToSel) return; replaceToSel.innerHTML = ''; (window.PALETTE || []).forEach(group => { const og = document.createElement('optgroup'); og.label = group.family; (group.colors || []).forEach(c => { const idx = FLAT_COLORS.find(fc => fc.name === c.name && fc.hex === c.hex && fc.family === group.family)?._idx ?? HEX_TO_FIRST_IDX.get(normalizeHex(c.hex)) ?? 0; const opt = document.createElement('option'); opt.value = String(idx); opt.textContent = c.name + (c.image ? ' (image)' : ''); og.appendChild(opt); }); replaceToSel.appendChild(og); }); } replaceBtn?.addEventListener('click', () => { const fromHex = replaceFromSel?.value; const toIdx = parseInt(replaceToSel?.value || '', 10); if (!fromHex || Number.isNaN(toIdx)) { if (replaceMsg) replaceMsg.textContent = 'Pick both colors.'; return; } let count = 0; balloons.forEach(b => { if (normalizeHex(b.color) === normalizeHex(fromHex)) { const toMeta = FLAT_COLORS[toIdx]; b.color = toMeta.hex; b.image = toMeta.image || null; b.colorIdx = toMeta._idx; count++; } }); if (count > 0) { if (replaceMsg) replaceMsg.textContent = `Replaced ${count} balloon${count === 1 ? '' : 's'}.`; if (normalizeHex(FLAT_COLORS[selectedColorIdx]?.hex) === normalizeHex(fromHex)) selectedColorIdx = toIdx; refreshAll(); renderAllowedPalette(); } else { if (replaceMsg) replaceMsg.textContent = 'Nothing to replace.'; } }); // ====== Init ====== sizePresetGroup && (sizePresetGroup.innerHTML = ''); SIZE_PRESETS.forEach(di => { const btn = document.createElement('button'); btn.className = 'tool-btn'; btn.textContent = `${di}"`; btn.setAttribute('aria-pressed', String(di === currentDiameterInches)); btn.addEventListener('click', () => { currentDiameterInches = di; currentRadius = inchesToRadiusPx(di); [...sizePresetGroup.querySelectorAll('button')].forEach(b => b.setAttribute('aria-pressed', 'false')); btn.setAttribute('aria-pressed', 'true'); persist(); }); sizePresetGroup?.appendChild(btn); }); toggleShineBtn?.addEventListener('click', () => { window.syncAppShine(!isShineEnabled); }); renderAllowedPalette(); resizeCanvas(); loadFromUrl(); renderUsedPalette(); setMode('draw'); updateSelectButtons(); populateReplaceTo(); if (window.matchMedia('(max-width: 768px)').matches) setExpanded(true); // Init accordion for the Organic panel setupAccordionPanel({ panelId: 'controls-panel', expandBtnId: 'expand-all', collapseBtnId: 'collapse-all', reorderBtnId: 'toggle-reorder', storagePrefix: 'obd' // Organic Balloon Designer }); // Initialize shine state from localStorage for both panels let initialShineState = true; try { const saved = localStorage.getItem('app:shineEnabled:v1'); if (saved !== null) initialShineState = JSON.parse(saved); } catch {} // Set Organic panel's internal state and UI isShineEnabled = initialShineState; if (toggleShineBtn) toggleShineBtn.textContent = isShineEnabled ? 'Turn Off Shine' : 'Turn On Shine'; // Set Classic panel's UI checkbox (its script will read this too) const classicCb = document.getElementById('classic-shine-enabled'); if (classicCb) classicCb.checked = isShineEnabled; // =============================== // ===== TAB SWITCHING (UI) ====== // =============================== (() => { const orgSection = document.getElementById('tab-organic'); const claSection = document.getElementById('tab-classic'); const tabBtns = document.querySelectorAll('#mode-tabs .tab-btn'); if (!orgSection || !claSection || tabBtns.length === 0) return; let current = '#tab-organic'; function show(id) { orgSection.classList.toggle('hidden', id !== '#tab-organic'); claSection.classList.toggle('hidden', id !== '#tab-classic'); tabBtns.forEach(btn => { const active = btn.dataset.target === id; btn.classList.toggle('tab-active', active); btn.classList.toggle('tab-idle', !active); btn.setAttribute('aria-pressed', String(active)); }); current = id; if (window.updateExportButtonVisibility) window.updateExportButtonVisibility(); } tabBtns.forEach(btn => btn.addEventListener('click', () => show(btn.dataset.target))); show('#tab-organic'); // default // Helper so other code (e.g., export) can know which tab is visible window.__whichTab = () => current; })(); }); document.addEventListener('DOMContentLoaded', () => { const modeTabs = document.getElementById('mode-tabs'); const allPanels = document.querySelectorAll('#tab-organic, #tab-classic'); const ACTIVE_TAB_KEY = 'balloonDesigner:activeTab:v1'; function switchTab(targetId) { if (!targetId || !document.querySelector(targetId)) return; const targetPanel = document.querySelector(targetId); const targetButton = modeTabs.querySelector(`button[data-target="${targetId}"]`); modeTabs.querySelectorAll('button').forEach(btn => { btn.classList.remove('tab-active'); btn.classList.add('tab-idle'); btn.setAttribute('aria-pressed', 'false'); }); allPanels.forEach(panel => panel.classList.add('hidden')); if (targetButton && targetPanel) { targetButton.classList.add('tab-active'); targetButton.classList.remove('tab-idle'); targetButton.setAttribute('aria-pressed', 'true'); targetPanel.classList.remove('hidden'); } if (window.updateExportButtonVisibility) { window.updateExportButtonVisibility(); } } modeTabs.addEventListener('click', (e) => { const button = e.target.closest('button[data-target]'); if (button) { const targetId = button.dataset.target; localStorage.setItem(ACTIVE_TAB_KEY, targetId); switchTab(targetId); } }); const savedTab = localStorage.getItem(ACTIVE_TAB_KEY); if (savedTab) { switchTab(savedTab); } }); })();