// script.js (() => { 'use strict'; // ----------------------------- // 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.45; // ROT is now in degrees let view = { s: 1, tx: 0, ty: 0 }; const FIT_PADDING_PX = 30; // ====== 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 PNG_EXPORT_SCALE = 3; 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'); const orgSheet = document.getElementById('controls-panel'); const claSheet = document.getElementById('classic-controls-panel'); // tool buttons const toolDrawBtn = document.getElementById('tool-draw'); const toolEraseBtn = document.getElementById('tool-erase'); const toolSelectBtn = document.getElementById('tool-select'); const toolUndoBtn = document.getElementById('tool-undo'); const toolRedoBtn = document.getElementById('tool-redo'); // 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 selectedSizeInput = document.getElementById('selected-size'); const selectedSizeLabel = document.getElementById('selected-size-label'); const nudgeSelectedBtns = Array.from(document.querySelectorAll('.nudge-selected')); const bringForwardBtn = document.getElementById('bring-forward'); const sendBackwardBtn = document.getElementById('send-backward'); const applyColorBtn = document.getElementById('apply-selected-color'); const fitViewBtn = document.getElementById('fit-view-btn'); const sizePresetGroup = document.getElementById('size-preset-group'); const toggleShineBtn = null; const toggleShineCheckbox = document.getElementById('toggle-shine-checkbox'); 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 (now by data-export to allow multiple) document.body.addEventListener('click', e => { const btn = e.target.closest('[data-export]'); if (!btn) return; const type = btn.dataset.export; if (type === 'png') exportPng(); else if (type === 'svg') 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 = null; const fullscreenBtn = null; 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 selectedIds = new Set(); let usedSortDesc = true; // History for Undo/Redo const historyStack = []; let historyPointer = -1; function resetHistory() { historyStack.length = 0; historyPointer = -1; pushHistory(); } function updateHistoryUi() { const canUndo = historyPointer > 0; const canRedo = historyPointer < historyStack.length - 1; if (toolUndoBtn) { toolUndoBtn.disabled = !canUndo; toolUndoBtn.title = canUndo ? 'Undo (Ctrl+Z)' : 'Nothing to undo'; } if (toolRedoBtn) { toolRedoBtn.disabled = !canRedo; toolRedoBtn.title = canRedo ? 'Redo (Ctrl+Y)' : 'Nothing to redo'; } } function pushHistory() { // Remove any future history if we are in the middle of the stack if (historyPointer < historyStack.length - 1) { historyStack.splice(historyPointer + 1); } // Deep clone balloons array const snapshot = JSON.parse(JSON.stringify(balloons)); historyStack.push(snapshot); historyPointer++; // Limit stack size if (historyStack.length > 50) { historyStack.shift(); historyPointer--; } updateHistoryUi(); } function undo() { if (historyPointer > 0) { historyPointer--; balloons = JSON.parse(JSON.stringify(historyStack[historyPointer])); selectedIds.clear(); // clear selection on undo to avoid issues updateSelectButtons(); draw(); renderUsedPalette(); persist(); } updateHistoryUi(); } function redo() { if (historyPointer < historyStack.length - 1) { historyPointer++; balloons = JSON.parse(JSON.stringify(historyStack[historyPointer])); selectedIds.clear(); updateSelectButtons(); draw(); renderUsedPalette(); persist(); } updateHistoryUi(); } // Bind Undo/Redo Buttons toolUndoBtn?.addEventListener('click', undo); toolRedoBtn?.addEventListener('click', redo); // Eyedropper Tool const toolEyedropperBtn = document.getElementById('tool-eyedropper'); toolEyedropperBtn?.addEventListener('click', () => { // Toggle eyedropper mode if (mode === 'eyedropper') { setMode('draw'); // toggle off } else { setMode('eyedropper'); } }); // ====== Helpers ====== const normalizeHex = h => (h || '').toLowerCase(); function hexToRgb(hex) { const h = normalizeHex(hex).replace('#',''); if (h.length === 3) { const r = parseInt(h[0] + h[0], 16); const g = parseInt(h[1] + h[1], 16); const b = parseInt(h[2] + h[2], 16); return { r, g, b }; } if (h.length === 6) { const r = parseInt(h.slice(0,2), 16); const g = parseInt(h.slice(2,4), 16); const b = parseInt(h.slice(4,6), 16); return { r, g, b }; } return { r: 0, g: 0, b: 0 }; } function luminance(hex) { const { r, g, b } = hexToRgb(hex || '#000'); const norm = [r,g,b].map(v => { const c = v / 255; return c <= 0.03928 ? c/12.92 : Math.pow((c+0.055)/1.055, 2.4); }); return 0.2126*norm[0] + 0.7152*norm[1] + 0.0722*norm[2]; } function shineStyle(colorHex) { const lum = luminance(colorHex); if (lum > 0.7) { const t = clamp01((lum - 0.7) / 0.3); const fillAlpha = 0.22 + (0.10 - 0.22) * t; return { fill: `rgba(0,0,0,${fillAlpha})`, stroke: null }; } return { fill: `rgba(255,255,255,${SHINE_ALPHA})`, stroke: null }; } function inchesToRadiusPx(diam) { return (diam * PX_PER_INCH) / 2; } function radiusPxToInches(r) { return (r * 2) / PX_PER_INCH; } function fmtInches(val) { const v = Math.round(val * 10) / 10; return `${String(v).replace(/\.0$/, '')}"`; } 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')); toolEyedropperBtn?.setAttribute('aria-pressed', String(mode === 'eyedropper')); eraserControls?.classList.toggle('hidden', mode !== 'erase'); selectControls?.classList.toggle('hidden', mode !== 'select'); if (mode === 'erase') canvas.style.cursor = 'none'; else if (mode === 'select') canvas.style.cursor = 'default'; // will be move over items else if (mode === 'eyedropper') canvas.style.cursor = 'cell'; else canvas.style.cursor = 'crosshair'; draw(); persist(); } function selectionArray() { return Array.from(selectedIds); } function selectionBalloons() { const set = new Set(selectedIds); return balloons.filter(b => set.has(b.id)); } function setSelection(ids, { additive = false } = {}) { if (!additive) selectedIds.clear(); ids.forEach(id => selectedIds.add(id)); updateSelectButtons(); draw(); } function primarySelection() { const first = selectedIds.values().next(); return first.done ? null : first.value; } function clearSelection() { selectedIds.clear(); updateSelectButtons(); draw(); } function updateSelectButtons() { const has = selectedIds.size > 0; if (deleteSelectedBtn) deleteSelectedBtn.disabled = !has; if (duplicateSelectedBtn) duplicateSelectedBtn.disabled = !has; if (selectedSizeInput) { selectedSizeInput.disabled = !has; selectedSizeInput.min = '5'; selectedSizeInput.max = '32'; selectedSizeInput.step = '0.5'; } if (bringForwardBtn) bringForwardBtn.disabled = !has; if (sendBackwardBtn) sendBackwardBtn.disabled = !has; if (applyColorBtn) applyColorBtn.disabled = !has; if (selectedSizeInput && selectedSizeLabel) { if (has) { const first = balloons.find(bb => selectedIds.has(bb.id)); if (first) { const diam = radiusPxToInches(first.radius); selectedSizeInput.value = String(Math.min(32, Math.max(5, diam))); selectedSizeLabel.textContent = fmtInches(diam); } } else { selectedSizeLabel.textContent = '0"'; } } } // ====== Pointer Events ====== let pointerDown = false; let isDragging = false; let dragStartPos = { x: 0, y: 0 }; let initialBalloonPos = { x: 0, y: 0 }; let eraseChanged = false; let dragMoved = false; let resizeChanged = false; let resizeSaveTimer = null; let erasingActive = false; let drawPending = false; let dragOffsets = []; let marqueeActive = false; let marqueeStart = { x: 0, y: 0 }; let marqueeEnd = { x: 0, y: 0 }; function requestDraw() { if (drawPending) return; drawPending = true; requestAnimationFrame(() => { drawPending = false; draw(); }); } canvas.addEventListener('pointerdown', e => { e.preventDefault(); canvas.setPointerCapture?.(e.pointerId); mouseInside = true; mousePos = getMousePos(e); if (e.altKey || mode === 'eyedropper') { pickColorAt(mousePos.x, mousePos.y); if (mode === 'eyedropper') setMode('draw'); // Auto-switch back? or stay? Let's stay for multi-pick, or switch for quick workflow. Let's switch back for now. return; } if (mode === 'erase') { pointerDown = true; erasingActive = true; eraseChanged = eraseAt(mousePos.x, mousePos.y); return; } if (mode === 'select') { pointerDown = true; const clickedIdx = findBalloonIndexAt(mousePos.x, mousePos.y); if (clickedIdx !== -1) { const b = balloons[clickedIdx]; if (e.shiftKey) { if (selectedIds.has(b.id)) selectedIds.delete(b.id); else selectedIds.add(b.id); } else if (!selectedIds.has(b.id)) { selectedIds.clear(); selectedIds.add(b.id); } updateSelectButtons(); draw(); isDragging = true; dragStartPos = { ...mousePos }; dragOffsets = selectionBalloons().map(bb => ({ id: bb.id, dx: bb.x - mousePos.x, dy: bb.y - mousePos.y })); dragMoved = false; } else { if (!e.shiftKey) selectedIds.clear(); updateSelectButtons(); marqueeActive = true; marqueeStart = { ...mousePos }; marqueeEnd = { ...mousePos }; requestDraw(); } return; } // draw mode: add addBalloon(mousePos.x, mousePos.y); pointerDown = true; // track for potential continuous drawing or other gestures? }, { passive: false }); canvas.addEventListener('pointermove', e => { mouseInside = true; mousePos = getMousePos(e); if (mode === 'select') { if (isDragging && selectedIds.size) { const dx = mousePos.x - dragStartPos.x; const dy = mousePos.y - dragStartPos.y; dragOffsets.forEach(off => { const b = balloons.find(bb => bb.id === off.id); if (b) { b.x = mousePos.x + off.dx; b.y = mousePos.y + off.dy; } }); requestDraw(); dragMoved = true; } else if (marqueeActive) { marqueeEnd = { ...mousePos }; requestDraw(); } else { const hoverIdx = findBalloonIndexAt(mousePos.x, mousePos.y); canvas.style.cursor = (hoverIdx !== -1) ? 'move' : 'default'; } } if (mode === 'erase') { if (pointerDown) { eraseChanged = eraseAt(mousePos.x, mousePos.y) || eraseChanged; if (eraseChanged) requestDraw(); } else { requestDraw(); } } }, { passive: true }); canvas.addEventListener('pointerenter', () => { mouseInside = true; if (mode === 'erase') requestDraw(); }); canvas.addEventListener('pointerup', e => { pointerDown = false; isDragging = false; if (mode === 'select' && dragMoved) { refreshAll(); pushHistory(); } if (mode === 'select' && marqueeActive) { const minX = Math.min(marqueeStart.x, marqueeEnd.x); const maxX = Math.max(marqueeStart.x, marqueeEnd.x); const minY = Math.min(marqueeStart.y, marqueeEnd.y); const maxY = Math.max(marqueeStart.y, marqueeEnd.y); const ids = balloons.filter(b => b.x >= minX && b.x <= maxX && b.y >= minY && b.y <= maxY).map(b => b.id); if (!e.shiftKey) selectedIds.clear(); ids.forEach(id => selectedIds.add(id)); marqueeActive = false; updateSelectButtons(); requestDraw(); } if (mode === 'erase' && eraseChanged) { refreshAll(); // update palette/persist once after the stroke pushHistory(); } erasingActive = false; dragMoved = false; eraseChanged = false; marqueeActive = false; canvas.releasePointerCapture?.(e.pointerId); }, { passive: true }); canvas.addEventListener('pointerleave', () => { mouseInside = false; marqueeActive = false; if (mode === 'erase') requestDraw(); }, { 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 { fill: shineFill, stroke: shineStroke } = shineStyle(b.color); 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 = shineFill; if (shineStroke) { ctx.strokeStyle = shineStroke; ctx.lineWidth = 1.5; ctx.stroke(); } ctx.fill(); ctx.restore(); } }); // selection ring(s) if (selectedIds.size) { ctx.save(); selectedIds.forEach(id => { const b = balloons.find(bb => bb.id === id); if (!b) return; ctx.beginPath(); ctx.arc(b.x, b.y, b.radius + 3, 0, Math.PI * 2); ctx.lineWidth = 4 / view.s; ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)'; ctx.stroke(); ctx.lineWidth = 2 / view.s; ctx.strokeStyle = '#3b82f6'; ctx.stroke(); }); ctx.restore(); } // marquee preview if (mode === 'select' && marqueeActive) { ctx.save(); ctx.setLineDash([6 / view.s, 4 / view.s]); ctx.lineWidth = 1.5 / view.s; ctx.strokeStyle = 'rgba(59,130,246,0.8)'; ctx.fillStyle = 'rgba(59,130,246,0.12)'; const x = Math.min(marqueeStart.x, marqueeEnd.x); const y = Math.min(marqueeStart.y, marqueeEnd.y); const w = Math.abs(marqueeStart.x - marqueeEnd.x); const h = Math.abs(marqueeStart.y - marqueeEnd.y); ctx.strokeRect(x, y, w, h); ctx.fillRect(x, y, w, h); 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(); } // eyedropper preview if (mode === 'eyedropper' && mouseInside) { ctx.save(); ctx.beginPath(); ctx.arc(mousePos.x, mousePos.y, 10 / view.s, 0, Math.PI * 2); ctx.lineWidth = 2 / view.s; ctx.strokeStyle = '#fff'; ctx.stroke(); ctx.lineWidth = 1 / view.s; ctx.strokeStyle = '#000'; ctx.stroke(); ctx.restore(); } ctx.restore(); } 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, view, usedSortDesc }; 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 (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'; } updateCurrentColorChip(); } catch {} } loadAppState(); resetHistory(); // establish initial history state for undo/redo controls // ====== 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('button'); sw.type = 'button'; sw.className = 'swatch'; sw.setAttribute('aria-label', c.name); 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(); updateCurrentColorChip(); 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 updateCurrentColorChip() { const meta = FLAT_COLORS[selectedColorIdx] || FLAT_COLORS[0]; const updateChip = (chipId, labelId) => { const chip = document.getElementById(chipId); const label = document.getElementById(labelId); if (!chip || !meta) return; if (meta.image) { const fx = clamp01(meta.imageFocus?.x ?? TEXTURE_FOCUS_DEFAULT.x); const fy = clamp01(meta.imageFocus?.y ?? TEXTURE_FOCUS_DEFAULT.y); chip.style.backgroundImage = `url("${meta.image}")`; chip.style.backgroundSize = `${100 * SWATCH_TEXTURE_ZOOM}%`; chip.style.backgroundPosition = `${fx * 100}% ${fy * 100}%`; chip.style.backgroundColor = '#fff'; } else { chip.style.backgroundImage = 'none'; chip.style.backgroundColor = meta.hex || '#fff'; } if (label) label.textContent = meta.name || meta.hex || 'Current'; }; updateChip('current-color-chip', 'current-color-label'); } 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('button'); sw.type = 'button'; sw.className = 'swatch'; const name = item.name || NAME_BY_HEX.get(item.hex) || item.hex; sw.setAttribute('aria-label', `${name} - Count: ${item.count}`); 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 = `${name} — ${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({ autoFit: true }); pushHistory(); } 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); selectedIds.clear(); if (i !== -1) selectedIds.add(balloons[i].id); updateSelectButtons(); draw(); } function moveSelected(dx, dy) { const sel = selectionBalloons(); if (!sel.length) return; sel.forEach(b => { b.x += dx; b.y += dy; }); refreshAll(); pushHistory(); } function resizeSelected(newDiamInches) { const sel = selectionBalloons(); if (!sel.length) return; const diam = clamp(newDiamInches, 5, 32); const newRadius = inchesToRadiusPx(diam); sel.forEach(b => { b.radius = newRadius; }); refreshAll(); updateSelectButtons(); resizeChanged = true; clearTimeout(resizeSaveTimer); resizeSaveTimer = setTimeout(() => { if (resizeChanged) { pushHistory(); resizeChanged = false; } }, 200); } function bringSelectedForward() { const sel = selectionArray(); if (!sel.length) return; const set = new Set(sel); const kept = balloons.filter(b => !set.has(b.id)); const moving = balloons.filter(b => set.has(b.id)); balloons = kept.concat(moving); refreshAll({ autoFit: true }); pushHistory(); } function sendSelectedBackward() { const sel = selectionArray(); if (!sel.length) return; const set = new Set(sel); const moving = balloons.filter(b => set.has(b.id)); const kept = balloons.filter(b => !set.has(b.id)); balloons = moving.concat(kept); refreshAll({ autoFit: true }); pushHistory(); } function applyColorToSelected() { const meta = FLAT_COLORS[selectedColorIdx] || FLAT_COLORS[0]; if (!meta) return; let changed = false; selectionBalloons().forEach(b => { b.color = meta.hex; b.image = meta.image || null; b.colorIdx = meta._idx; changed = true; }); if (!changed) return; refreshAll(); pushHistory(); } function deleteSelected() { if (!selectedIds.size) return; balloons = balloons.filter(b => !selectedIds.has(b.id)); selectedIds.clear(); updateSelectButtons(); refreshAll({ autoFit: true }); pushHistory(); } function duplicateSelected() { const sel = selectionBalloons(); if (!sel.length) return; const copies = sel.map(b => ({ ...b, x: b.x + 10, y: b.y + 10, id: crypto.randomUUID() })); copies.forEach(c => balloons.push(c)); selectedIds = new Set(copies.map(c => c.id)); refreshAll({ autoFit: true }); updateSelectButtons(); pushHistory(); } function eraseAt(x, y) { const before = balloons.length; balloons = balloons.filter(b => Math.hypot(x - b.x, y - b.y) > eraserRadius); const removed = balloons.length !== before; if (selectedIds.size) { const set = new Set(balloons.map(b => b.id)); let changed = false; selectedIds.forEach(id => { if (!set.has(id)) { selectedIds.delete(id); changed = true; } }); if (changed) updateSelectButtons(); } if (removed && !erasingActive) requestDraw(); return removed; } 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(); updateCurrentColorChip(); } } 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() }; }) : []; selectedIds.clear(); updateSelectButtons(); refreshAll({ refit: true }); resetHistory(); persist(); } catch { showModal('Error parsing JSON file.'); } }; reader.readAsText(file); } // ====== Export helpers ====== const DATA_URL_CACHE = new Map(); const XLINK_NS = 'http://www.w3.org/1999/xlink'; let lastActiveTab = '#tab-organic'; function getImageHref(el) { return el.getAttribute('href') || el.getAttributeNS(XLINK_NS, 'href'); } function setImageHref(el, val) { el.setAttribute('href', val); el.setAttributeNS(XLINK_NS, 'xlink:href', val); } const blobToDataUrl = blob => new Promise((resolve, reject) => { const r = new FileReader(); r.onloadend = () => resolve(r.result); r.onerror = reject; r.readAsDataURL(blob); }); function imageToDataUrl(img) { if (!img || !img.complete || img.naturalWidth === 0) return null; try { const c = document.createElement('canvas'); c.width = img.naturalWidth; c.height = img.naturalHeight; c.getContext('2d').drawImage(img, 0, 0); return c.toDataURL('image/png'); } catch (err) { console.warn('[Export] imageToDataUrl failed:', err); return null; } } async function imageUrlToDataUrl(src) { if (!src || src.startsWith('data:')) return src; if (DATA_URL_CACHE.has(src)) return DATA_URL_CACHE.get(src); const cachedImg = IMG_CACHE.get(src); const cachedUrl = imageToDataUrl(cachedImg); if (cachedUrl) { DATA_URL_CACHE.set(src, cachedUrl); return cachedUrl; } const abs = (() => { try { return new URL(src, window.location.href).href; } catch { return src; } })(); let dataUrl = null; try { const resp = await fetch(abs); if (!resp.ok) throw new Error(`Status ${resp.status}`); dataUrl = await blobToDataUrl(await resp.blob()); } catch (err) { console.warn('[Export] Fetch failed for', abs, err); // Fallback: draw to a canvas to capture even when fetch is blocked (e.g., file://) dataUrl = await new Promise(resolve => { const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => { try { const c = document.createElement('canvas'); c.width = img.naturalWidth || 1; c.height = img.naturalHeight || 1; c.getContext('2d').drawImage(img, 0, 0); resolve(c.toDataURL('image/png')); } catch (e) { console.error('[Export] Canvas fallback failed for', abs, e); resolve(null); } }; img.onerror = () => resolve(null); img.src = abs; }); } if (!dataUrl) dataUrl = abs; DATA_URL_CACHE.set(src, dataUrl); return dataUrl; } async function embedImagesInSvg(svgEl) { const images = Array.from(svgEl.querySelectorAll('image')); const hrefs = [...new Set(images.map(getImageHref).filter(h => h && !h.startsWith('data:')))]; const urlMap = new Map(); await Promise.all(hrefs.map(async (href) => { urlMap.set(href, await imageUrlToDataUrl(href)); })); images.forEach(img => { const orig = getImageHref(img); const val = urlMap.get(orig); if (val) setImageHref(img, val); }); return svgEl; } async function buildOrganicSvgPayload() { if (balloons.length === 0) throw new Error('Canvas is empty. Add some balloons first.'); const uniqueImageUrls = [...new Set(balloons.map(b => b.image).filter(Boolean))]; const dataUrlMap = new Map(); await Promise.all(uniqueImageUrls.map(async (url) => dataUrlMap.set(url, await imageUrlToDataUrl(url)))); const bounds = balloonsBounds(); const pad = 20; const width = bounds.w + pad * 2; const height = bounds.h + pad * 2; const vb = [bounds.minX - pad, bounds.minY - pad, width, height].join(' '); let defs = ''; let elements = ''; const patterns = new Map(); balloons.forEach(b => { let fill = b.color; if (b.image) { const patternKey = `${b.colorIdx}|${b.image}`; if (!patterns.has(patternKey)) { const patternId = `p${patterns.size}`; patterns.set(patternKey, 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); const imgW = zoom, imgH = zoom; const imgX = 0.5 - (fx * zoom); const imgY = 0.5 - (fy * zoom); const imageHref = dataUrlMap.get(b.image) || b.image; defs += ` `; } fill = `url(#${patterns.get(patternKey)})`; } elements += ``; 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 { fill: shineFill, stroke: shineStroke } = shineStyle(b.color); const stroke = shineStroke ? ` stroke="${shineStroke}" stroke-width="1.5"` : ''; elements += ``; } }); const svgString = ` ${defs} ${elements} `; return { svgString, width, height }; } async function buildClassicSvgPayload() { const svgElement = document.querySelector('#classic-display svg'); if (!svgElement) throw new Error('Classic design not found. Please create a design first.'); const clonedSvg = svgElement.cloneNode(true); let bbox = null; try { const temp = clonedSvg.cloneNode(true); temp.style.position = 'absolute'; temp.style.left = '-99999px'; temp.style.top = '-99999px'; temp.style.width = '0'; temp.style.height = '0'; document.body.appendChild(temp); const target = temp.querySelector('g') || temp; bbox = target.getBBox(); temp.remove(); } catch {} // Inline pattern images and any other nodes const allImages = Array.from(clonedSvg.querySelectorAll('image')); await Promise.all(allImages.map(async img => { const href = getImageHref(img); if (!href || href.startsWith('data:')) return; const dataUrl = await imageUrlToDataUrl(href); if (dataUrl) setImageHref(img, dataUrl); })); // Ensure required namespaces are present const viewBox = (clonedSvg.getAttribute('viewBox') || '0 0 1000 1000').split(/\s+/).map(Number); let vbX = isFinite(viewBox[0]) ? viewBox[0] : 0; let vbY = isFinite(viewBox[1]) ? viewBox[1] : 0; let vbW = isFinite(viewBox[2]) ? viewBox[2] : (svgElement.clientWidth || 1000); let vbH = isFinite(viewBox[3]) ? viewBox[3] : (svgElement.clientHeight || 1000); if (bbox && isFinite(bbox.x) && isFinite(bbox.y) && isFinite(bbox.width) && isFinite(bbox.height)) { const pad = 10; vbX = bbox.x - pad; vbY = bbox.y - pad; vbW = Math.max(1, bbox.width + pad * 2); vbH = Math.max(1, bbox.height + pad * 2); } clonedSvg.setAttribute('width', vbW); clonedSvg.setAttribute('height', vbH); if (!clonedSvg.getAttribute('xmlns')) clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); if (!clonedSvg.getAttribute('xmlns:xlink')) clonedSvg.setAttribute('xmlns:xlink', XLINK_NS); // Some viewers ignore external styles; bake key style attributes directly clonedSvg.querySelectorAll('g.balloon, path.balloon, ellipse.balloon, circle.balloon').forEach(el => { if (!el.getAttribute('stroke')) el.setAttribute('stroke', '#111827'); if (!el.getAttribute('stroke-width')) el.setAttribute('stroke-width', '2'); if (!el.getAttribute('paint-order')) el.setAttribute('paint-order', 'stroke fill'); if (!el.getAttribute('vector-effect')) el.setAttribute('vector-effect', 'non-scaling-stroke'); }); const svgString = new XMLSerializer().serializeToString(clonedSvg); return { svgString, width: vbW, height: vbH, minX: vbX, minY: vbY }; } async function svgStringToPng(svgString, width, height) { const img = new Image(); const scale = PNG_EXPORT_SCALE; const canvasEl = document.createElement('canvas'); canvasEl.width = Math.max(1, Math.round(width * scale)); canvasEl.height = Math.max(1, Math.round(height * scale)); const ctx2 = canvasEl.getContext('2d'); if (ctx2) { ctx2.imageSmoothingEnabled = true; ctx2.imageSmoothingQuality = 'high'; } const dataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgString)}`; await new Promise((resolve, reject) => { img.onload = resolve; img.onerror = () => reject(new Error('Could not rasterize SVG.')); img.src = dataUrl; }); ctx2.drawImage(img, 0, 0, canvasEl.width, canvasEl.height); return canvasEl.toDataURL('image/png'); } function detectCurrentTab() { const bodyActive = document.body?.dataset?.activeTab; const activeBtn = document.querySelector('#mode-tabs .tab-btn.tab-active'); const classicVisible = !document.getElementById('tab-classic')?.classList.contains('hidden'); const organicVisible = !document.getElementById('tab-organic')?.classList.contains('hidden'); let id = bodyActive || activeBtn?.dataset?.target; if (!id) { if (classicVisible && !organicVisible) id = '#tab-classic'; else if (organicVisible && !classicVisible) id = '#tab-organic'; } if (!id) id = lastActiveTab || '#tab-organic'; lastActiveTab = id; if (document.body) document.body.dataset.activeTab = id; return id; } function updateSheets() { const tab = detectCurrentTab(); const hide = !window.matchMedia('(min-width: 1024px)').matches && document.body?.dataset?.controlsHidden === '1'; if (orgSheet) orgSheet.classList.toggle('hidden', hide || tab !== '#tab-organic'); if (claSheet) claSheet.classList.toggle('hidden', hide || tab !== '#tab-classic'); } async function exportPng() { try { const currentTab = detectCurrentTab(); if (currentTab === '#tab-classic') { const { svgString, width, height } = await buildClassicSvgPayload(); const pngUrl = await svgStringToPng(svgString, width, height); download(pngUrl, 'classic_design.png'); return; } const { svgString, width, height } = await buildOrganicSvgPayload(); const pngUrl = await svgStringToPng(svgString, width, height); download(pngUrl, 'balloon_design.png'); } catch (err) { console.error('[Export PNG] Failed:', err); showModal(err.message || 'Could not export PNG. Check console for details.'); } } function downloadSvg(svgString, filename) { const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' }); const url = URL.createObjectURL(blob); download(url, filename); setTimeout(() => URL.revokeObjectURL(url), 20000); } async function exportSvg() { try { const currentTab = detectCurrentTab(); if (currentTab === '#tab-classic') { const { svgString, width, height } = await buildClassicSvgPayload(); try { const pngUrl = await svgStringToPng(svgString, width, height); const cleanSvg = ` `; downloadSvg(cleanSvg, 'classic_design.svg'); return; } catch (pngErr) { console.warn('[Export SVG] PNG embed failed, falling back to vector-only SVG', pngErr); downloadSvg(svgString, 'classic_design.svg'); return; } } const { svgString } = await buildOrganicSvgPayload(); downloadSvg(svgString, 'organic_design.svg'); } catch (err) { console.error('[Export] A critical error occurred during SVG export:', err); showModal(err.message || 'An unexpected error occurred during SVG export. Check console for details.'); } } 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 }); resetHistory(); persist(); updateCurrentColorChip(); 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; return (dx !== 0 || dy !== 0); } // ====== Refresh & Events ====== function refreshAll({ refit = false, autoFit = false } = {}) { if (refit) fitView(); else if (autoFit) 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); nudgeSelectedBtns.forEach(btn => btn.addEventListener('click', () => { const dx = Number(btn.dataset.dx || 0); const dy = Number(btn.dataset.dy || 0); moveSelected(dx, dy); })); selectedSizeInput?.addEventListener('input', e => { resizeSelected(parseFloat(e.target.value) || 0); }); selectedSizeInput?.addEventListener('pointerdown', () => { resizeChanged = false; clearTimeout(resizeSaveTimer); }); selectedSizeInput?.addEventListener('pointerup', () => { clearTimeout(resizeSaveTimer); if (resizeChanged) { pushHistory(); resizeChanged = false; } }); bringForwardBtn?.addEventListener('click', bringSelectedForward); sendBackwardBtn?.addEventListener('click', sendSelectedBackward); applyColorBtn?.addEventListener('click', applyColorToSelected); fitViewBtn?.addEventListener('click', () => refreshAll({ refit: true })); 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') { if (selectedIds.size) { clearSelection(); } else if (mode !== 'draw') { setMode('draw'); } } else if (e.key === 'Delete' || e.key === 'Backspace') { if (selectedIds.size) { e.preventDefault(); deleteSelected(); } } else if ((e.ctrlKey || e.metaKey) && (e.key === 'z' || e.key === 'Z')) { e.preventDefault(); undo(); } else if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || e.key === 'Y')) { e.preventDefault(); redo(); } else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'd') { e.preventDefault(); duplicateSelected(); } }); clearCanvasBtn?.addEventListener('click', () => { balloons = []; selectedIds.clear(); updateSelectButtons(); refreshAll({ refit: true }); pushHistory(); }); 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) { pushHistory(); 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); }); toggleShineCheckbox?.addEventListener('change', e => { const on = !!e.target.checked; window.syncAppShine(on); }); mode = 'draw'; // force default tool on load renderAllowedPalette(); resizeCanvas(); loadFromUrl(); renderUsedPalette(); setMode('draw'); updateSelectButtons(); populateReplaceTo(); // default to canvas-first on mobile; no expansion toggles remain // 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 (toggleShineCheckbox) toggleShineCheckbox.checked = isShineEnabled; // 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'); const ACTIVE_TAB_KEY = 'balloonDesigner:activeTab:v1'; function updateMobileStacks(tabName) { const orgPanel = document.getElementById('controls-panel'); const claPanel = document.getElementById('classic-controls-panel'); const currentTab = detectCurrentTab(); const panel = currentTab === '#tab-classic' ? claPanel : orgPanel; const target = tabName || document.body?.dataset?.mobileTab || 'controls'; const isHidden = document.body?.dataset?.controlsHidden === '1'; if (!panel) return; const stacks = Array.from(panel.querySelectorAll('.control-stack')); if (!stacks.length) return; stacks.forEach(stack => { if (isHidden) { stack.style.display = 'none'; } else { const show = stack.dataset.mobileTab === target; stack.style.display = show ? 'block' : 'none'; } }); } function setMobileTab(tab) { const name = tab || 'controls'; const isDesktop = window.matchMedia('(min-width: 1024px)').matches; if (document.body) { document.body.dataset.mobileTab = name; delete document.body.dataset.controlsHidden; } updateSheets(); updateMobileStacks(name); const buttons = document.querySelectorAll('#mobile-tabbar .mobile-tab-btn'); buttons.forEach(btn => btn.setAttribute('aria-pressed', String(btn.dataset.mobileTab === name))); } window.__setMobileTab = setMobileTab; let floatingNudgeCollapsed = false; function updateFloatingNudge() { const el = document.getElementById('floating-topper-nudge'); if (!el) return; const isMobile = !window.matchMedia('(min-width: 1024px)').matches; const classicActive = document.body?.dataset.activeTab === '#tab-classic'; const topperActive = document.body?.dataset.topperOverlay === '1'; const shouldShow = isMobile && classicActive && topperActive; el.classList.toggle('hidden', !shouldShow); el.classList.toggle('collapsed', floatingNudgeCollapsed); const toggle = document.getElementById('floating-nudge-toggle'); if (toggle) toggle.textContent = floatingNudgeCollapsed ? 'Show' : 'Hide'; } window.__updateFloatingNudge = updateFloatingNudge; if (orgSection && claSection && tabBtns.length > 0) { let current = '#tab-organic'; function setTab(id, isInitial = false) { if (!id || !document.querySelector(id)) id = '#tab-organic'; current = id; lastActiveTab = id; if (document.body) document.body.dataset.activeTab = id; // Reset minimized state on tab switch orgSheet?.classList.remove('minimized'); claSheet?.classList.remove('minimized'); orgSection.classList.toggle('hidden', id !== '#tab-organic'); claSection.classList.toggle('hidden', id !== '#tab-classic'); updateSheets(); updateFloatingNudge(); 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)); }); if (!isInitial) { try { localStorage.setItem(ACTIVE_TAB_KEY, id); } catch {} } if (document.body) delete document.body.dataset.controlsHidden; setMobileTab(document.body?.dataset?.mobileTab || 'controls'); orgSheet?.classList.toggle('hidden', id !== '#tab-organic'); claSheet?.classList.toggle('hidden', id !== '#tab-classic'); if (window.updateExportButtonVisibility) window.updateExportButtonVisibility(); } tabBtns.forEach(btn => { btn.addEventListener('click', (e) => { const button = e.target.closest('button[data-target]'); if (button) setTab(button.dataset.target); }); }); let savedTab = null; try { savedTab = localStorage.getItem(ACTIVE_TAB_KEY); } catch {} setTab(savedTab || '#tab-organic', true); window.__whichTab = () => current; // ensure mobile default if (!document.body?.dataset?.mobileTab) document.body.dataset.mobileTab = 'controls'; setMobileTab(document.body.dataset.mobileTab); updateSheets(); updateMobileStacks(document.body.dataset.mobileTab); } // =============================== // ===== Mobile bottom tabs ====== // =============================== (function initMobileTabs() { const buttons = Array.from(document.querySelectorAll('#mobile-tabbar .mobile-tab-btn')); if (!buttons.length) return; buttons.forEach(btn => { btn.addEventListener('click', () => { const tab = btn.dataset.mobileTab || 'controls'; const panel = (!document.getElementById('tab-classic')?.classList.contains('hidden') ? document.getElementById('classic-controls-panel') : document.getElementById('controls-panel')); const currentTab = document.body.dataset.mobileTab; if (tab === currentTab) { // Toggle minimized state panel.classList.toggle('minimized'); } else { // Switch tab and ensure expanded panel.classList.remove('minimized'); setMobileTab(tab); panel.scrollTop = 0; } }); }); const mq = window.matchMedia('(min-width: 1024px)'); const sync = () => { if (mq.matches) { document.body?.removeAttribute('data-mobile-tab'); updateMobileStacks('controls'); // keep controls visible on desktop } else { setMobileTab(document.body?.dataset?.mobileTab || 'controls'); } updateFloatingNudge(); }; mq.addEventListener('change', sync); setMobileTab(document.body?.dataset?.mobileTab || 'controls'); sync(); const nudgeToggle = document.getElementById('floating-nudge-toggle'); nudgeToggle?.addEventListener('click', () => { floatingNudgeCollapsed = !floatingNudgeCollapsed; updateFloatingNudge(); }); })(); }); })();