// 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.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'); 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'); // 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 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 selectedBalloonId = null; let usedSortDesc = true; // History for Undo/Redo const historyStack = []; let historyPointer = -1; 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--; } } function undo() { if (historyPointer > 0) { historyPointer--; balloons = JSON.parse(JSON.stringify(historyStack[historyPointer])); selectedBalloonId = null; // clear selection on undo to avoid issues updateSelectButtons(); draw(); renderUsedPalette(); persist(); } } function redo() { if (historyPointer < historyStack.length - 1) { historyPointer++; balloons = JSON.parse(JSON.stringify(historyStack[historyPointer])); selectedBalloonId = null; updateSelectButtons(); draw(); renderUsedPalette(); persist(); } } // Bind Undo/Redo Buttons document.getElementById('tool-undo')?.addEventListener('click', () => { undo(); // Auto-minimize on mobile to see changes if (window.innerWidth < 1024) { document.getElementById('controls-panel')?.classList.add('minimized'); } }); document.getElementById('tool-redo')?.addEventListener('click', () => { redo(); if (window.innerWidth < 1024) { document.getElementById('controls-panel')?.classList.add('minimized'); } }); // Eyedropper Tool const toolEyedropperBtn = document.getElementById('tool-eyedropper'); toolEyedropperBtn?.addEventListener('click', () => { // Toggle eyedropper mode if (mode === 'eyedropper') { setMode('draw'); // toggle off } else { setMode('eyedropper'); // Auto-minimize on mobile if (window.innerWidth < 1024) { document.getElementById('controls-panel')?.classList.add('minimized'); } } }); // ====== 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 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')); toolEyedropperBtn?.setAttribute('aria-pressed', String(mode === 'eyedropper')); // Update Mobile Dock Active States document.querySelectorAll('.mobile-tool-btn[data-dock="organic"]').forEach(btn => btn.classList.remove('active')); if (mode === 'draw') document.getElementById('dock-draw')?.classList.add('active'); if (mode === 'erase') document.getElementById('dock-erase')?.classList.add('active'); if (mode === 'select') document.getElementById('dock-select')?.classList.add('active'); if (mode === 'eyedropper') document.getElementById('dock-picker')?.classList.add('active'); eraserControls?.classList.toggle('hidden', mode !== 'erase'); selectControls?.classList.toggle('hidden', mode !== 'select'); // Show/Hide empty hint in Selection Options panel const emptyHint = document.getElementById('controls-empty-hint'); if (emptyHint) { emptyHint.classList.toggle('hidden', mode === 'erase' || mode === 'select'); emptyHint.textContent = mode === 'draw' ? 'Switch to Select or Erase tool to see options.' : 'Select a tool...'; } if (mode === 'erase') canvas.style.cursor = 'none'; else if (mode === 'select') { canvas.style.cursor = 'default'; } else if (mode === 'eyedropper') canvas.style.cursor = 'cell'; else canvas.style.cursor = 'crosshair'; // Contextual Tab Switching if (window.innerWidth < 1024) { if (mode === 'select' || mode === 'erase') { setMobileTab('controls'); } else if (mode === 'draw') { // Optional: switch to colors, or stay put? // setMobileTab('colors'); } // Minimize drawer on tool switch to clear view const panel = document.getElementById('controls-panel'); if (panel && !panel.classList.contains('minimized')) { panel.classList.add('minimized'); } } draw(); persist(); } // ... (rest of the file) ... function updateSelectButtons() { const has = !!selectedBalloonId; if (deleteSelectedBtn) deleteSelectedBtn.disabled = !has; if (duplicateSelectedBtn) duplicateSelectedBtn.disabled = !has; if (selectedSizeInput) selectedSizeInput.disabled = !has; if (bringForwardBtn) bringForwardBtn.disabled = !has; if (sendBackwardBtn) sendBackwardBtn.disabled = !has; if (applyColorBtn) applyColorBtn.disabled = !has; if (has && selectedSizeInput && selectedSizeLabel) { const b = balloons.find(bb => bb.id === selectedBalloonId); if (b) { selectedSizeInput.value = Math.round(b.radius); selectedSizeLabel.textContent = `${Math.round(b.radius)}`; } } } // ====== Pointer Events ====== let pointerDown = false; let isDragging = false; let dragStartPos = { x: 0, y: 0 }; let initialBalloonPos = { x: 0, y: 0 }; 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; pushHistory(); // Save state before erasing eraseAt(mousePos.x, mousePos.y); return; } if (mode === 'select') { const clickedIdx = findBalloonIndexAt(mousePos.x, mousePos.y); if (clickedIdx !== -1) { // We clicked on a balloon const b = balloons[clickedIdx]; if (selectedBalloonId !== b.id) { selectedBalloonId = b.id; updateSelectButtons(); draw(); } // Start Dragging isDragging = true; pointerDown = true; dragStartPos = { ...mousePos }; initialBalloonPos = { x: b.x, y: b.y }; pushHistory(); // Save state before move } else { // Clicked empty space -> deselect if (selectedBalloonId) { selectedBalloonId = null; updateSelectButtons(); draw(); } // Perhaps handle panning here later? } return; } // draw mode: add pushHistory(); // Save state before add addBalloon(mousePos.x, mousePos.y); pointerDown = true; // track for potential continuous drawing or other gestures? }, { passive: false }); canvas.addEventListener('pointermove', e => { mousePos = getMousePos(e); if (mode === 'select') { if (isDragging && selectedBalloonId) { const dx = mousePos.x - dragStartPos.x; const dy = mousePos.y - dragStartPos.y; const b = balloons.find(bb => bb.id === selectedBalloonId); if (b) { b.x = initialBalloonPos.x + dx; b.y = initialBalloonPos.y + dy; draw(); } } else { // Hover cursor const hoverIdx = findBalloonIndexAt(mousePos.x, mousePos.y); canvas.style.cursor = (hoverIdx !== -1) ? 'move' : 'default'; } } if (mode === 'erase') { if (pointerDown) eraseAt(mousePos.x, mousePos.y); else draw(); } }, { passive: true }); canvas.addEventListener('pointerup', e => { pointerDown = false; isDragging = false; canvas.releasePointerCapture?.(e.pointerId); }, { passive: true }); canvas.addEventListener('pointerleave', () => { mouseInside = false; if (mode === 'erase') draw(); }, { passive: true }); // ====== Canvas & Drawing ====== let hasFittedView = false; function resizeCanvas() { const rect = canvas.parentElement?.getBoundingClientRect?.() || canvas.getBoundingClientRect(); const prevDpr = dpr || 1; const prevCw = canvas.width / prevDpr; const prevCh = canvas.height / prevDpr; const prevCenter = { x: (prevCw / 2) / (view.s || 1) - view.tx, y: (prevCh / 2) / (view.s || 1) - view.ty }; dpr = Math.max(1, window.devicePixelRatio || 1); canvas.width = Math.round(Math.min(rect.width, window.innerWidth) * dpr); canvas.height = Math.round(Math.min(rect.height, window.innerHeight) * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); if (!hasFittedView) { fitView(); hasFittedView = true; } else if (prevCw > 0 && prevCh > 0) { const cw = canvas.width / dpr; const ch = canvas.height / dpr; view.tx = (cw / (2 * (view.s || 1))) - prevCenter.x; view.ty = (ch / (2 * (view.s || 1))) - prevCenter.y; } 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 isBright = luminance(b.color) > 0.75; const shineFill = isBright ? 'rgba(0,0,0,0.55)' : `rgba(255,255,255,${SHINE_ALPHA})`; 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 (isBright) { ctx.strokeStyle = 'rgba(0,0,0,0.45)'; ctx.lineWidth = 1.5; ctx.stroke(); } 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); // White halo ctx.lineWidth = 4 / view.s; ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)'; ctx.stroke(); // Blue ring ctx.lineWidth = 2 / view.s; ctx.strokeStyle = '#3b82f6'; ctx.stroke(); 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, mode, view, usedSortDesc }; try { localStorage.setItem(APP_STATE_KEY, JSON.stringify(state)); } catch {} // Update dock color trigger const meta = FLAT_COLORS[selectedColorIdx]; const trig = document.getElementById('dock-color-trigger'); if (trig && meta) { if (meta.image) { trig.style.backgroundImage = `url("${meta.image}")`; trig.style.backgroundSize = '200%'; trig.style.backgroundColor = 'transparent'; } else { trig.style.backgroundImage = 'none'; trig.style.backgroundColor = meta.hex; } } } 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'; } } 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('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(); 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('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(); } 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 moveSelected(dx, dy) { if (!selectedBalloonId) return; const b = balloons.find(bb => bb.id === selectedBalloonId); if (!b) return; b.x += dx; b.y += dy; refreshAll(); } function resizeSelected(newRadius) { if (!selectedBalloonId) return; const b = balloons.find(bb => bb.id === selectedBalloonId); if (!b) return; b.radius = clamp(newRadius, 5, 200); refreshAll(); updateSelectButtons(); } function bringSelectedForward() { if (!selectedBalloonId) return; const idx = balloons.findIndex(bb => bb.id === selectedBalloonId); if (idx === -1 || idx === balloons.length - 1) return; const [b] = balloons.splice(idx, 1); balloons.push(b); refreshAll(); } function sendSelectedBackward() { if (!selectedBalloonId) return; const idx = balloons.findIndex(bb => bb.id === selectedBalloonId); if (idx <= 0) return; const [b] = balloons.splice(idx, 1); balloons.unshift(b); refreshAll(); } function applyColorToSelected() { if (!selectedBalloonId) return; const b = balloons.find(bb => bb.id === selectedBalloonId); const meta = FLAT_COLORS[selectedColorIdx] || FLAT_COLORS[0]; if (!b || !meta) return; b.color = meta.hex; b.image = meta.image || null; b.colorIdx = meta._idx; refreshAll(); } 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); } // ====== 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 isBright = luminance(b.color) > 0.75; const shineFill = isBright ? 'rgba(0,0,0,0.55)' : `rgba(255,255,255,${SHINE_ALPHA})`; const stroke = isBright ? ' stroke="rgba(0,0,0,0.45)" 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); // 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); const vbX = isFinite(viewBox[0]) ? viewBox[0] : 0; const vbY = isFinite(viewBox[1]) ? viewBox[1] : 0; const vbW = isFinite(viewBox[2]) ? viewBox[2] : (svgElement.clientWidth || 1000); const vbH = isFinite(viewBox[3]) ? viewBox[3] : (svgElement.clientHeight || 1000); 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 = 2; 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'); 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(activeId) { const tab = activeId || detectCurrentTab(); // Panels should be visible if their tab is active. // Mobile minimization is handled by the .minimized class, not .hidden. if (orgSheet) orgSheet.classList.toggle('hidden', tab !== '#tab-organic'); if (claSheet) claSheet.classList.toggle('hidden', tab !== '#tab-classic'); // Ensure Dock is visible on both tabs (content managed by setTab) const dock = document.getElementById('mobile-tabbar'); if (dock) dock.style.display = 'flex'; const dockOrg = document.getElementById('dock-organic'); const dockCla = document.getElementById('dock-classic'); dockOrg?.classList.toggle('hidden', tab === '#tab-classic'); dockCla?.classList.toggle('hidden', 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 }); 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; hasFittedView = true; } 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); 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); }); bringForwardBtn?.addEventListener('click', bringSelectedForward); sendBackwardBtn?.addEventListener('click', sendSelectedBackward); applyColorBtn?.addEventListener('click', applyColorToSelected); 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 (selectedBalloonId) { selectedBalloonId = null; updateSelectButtons(); draw(); } else if (mode !== 'draw') { setMode('draw'); } } else if (e.key === 'Delete' || e.key === 'Backspace') { if (selectedBalloonId) { 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(); } }); 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); }); toggleShineCheckbox?.addEventListener('change', e => { const on = !!e.target.checked; window.syncAppShine(on); }); 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; // If we passed 'all', show everything (Desktop mode) const showAll = (tabName === 'all'); stacks.forEach(stack => { if (isHidden) { stack.style.display = 'none'; } else { const show = showAll ? true : stack.dataset.mobileTab === target; // Use flex to match CSS .control-stack stack.style.display = show ? 'flex' : '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; 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(id); // Ensure Dock is visible const dock = document.getElementById('mobile-tabbar'); if (dock) dock.style.display = 'flex'; 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'); 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); // Sheet toggle buttons (Hide/Show) document.querySelectorAll('[data-sheet-toggle]').forEach(btn => { btn.addEventListener('click', () => { const id = btn.dataset.sheetToggle; const panel = document.getElementById(id); if (!panel) return; const now = panel.classList.contains('minimized'); panel.classList.toggle('minimized', !now); }); }); } // =============================== // ===== Mobile Dock Logic ======= // =============================== (function initMobileDock() { if (window.__dockInit) return; window.__dockInit = true; const dockOrganic = document.getElementById('dock-organic'); const dockClassic = document.getElementById('dock-classic'); const patternBtns = Array.from(document.querySelectorAll('.classic-pattern-btn')); const variantBtns = Array.from(document.querySelectorAll('.classic-variant-btn')); const topperBtns = Array.from(document.querySelectorAll('.classic-topper-btn')); const drawerPattern = document.getElementById('classic-drawer-pattern'); const drawerColors = document.getElementById('classic-drawer-colors'); function openColorsPanel() { const isMobile = window.matchMedia('(max-width: 1023px)').matches; if (isMobile) setMobileTab('colors'); const tab = detectCurrentTab(); const panel = tab === '#tab-classic' ? document.getElementById('classic-controls-panel') : document.getElementById('controls-panel'); if (isMobile) panel?.classList.remove('minimized'); } const openOrganicPanel = (tab = 'controls') => { document.body.dataset.mobileTab = tab; const panel = document.getElementById('controls-panel'); panel?.classList.remove('minimized'); updateSheets('#tab-organic'); updateMobileStacks(tab); }; const closeClassicDrawers = () => { drawerPattern?.classList.add('hidden'); drawerColors?.classList.add('hidden'); activeClassicMenu = null; }; const openDrawer = (which) => { closeClassicDrawers(); if (which === 'pattern') drawerPattern?.classList.remove('hidden'); if (which === 'colors') drawerColors?.classList.remove('hidden'); activeClassicMenu = which; }; function syncDockGroup() { const tab = detectCurrentTab(); dockOrganic?.classList.toggle('hidden', tab === '#tab-classic'); dockClassic?.classList.toggle('hidden', tab !== '#tab-classic'); } const currentPatternParts = () => { const sel = document.getElementById('classic-pattern'); const val = sel?.value || 'Arch 4'; const isArch = val.toLowerCase().includes('arch'); const variant = val.includes('5') ? '5' : '4'; return { base: isArch ? 'Arch' : 'Column', variant }; }; let activeClassicMenu = null; const toggleClassicMenu = (target) => { const panel = document.getElementById('classic-controls-panel'); const isMobile = window.matchMedia('(max-width: 1023px)').matches; if (!panel) return; const alreadyOpen = activeClassicMenu === target && isMobile && !panel.classList.contains('minimized'); if (alreadyOpen) { closeClassicDrawers(); panel.classList.add('minimized'); patternBtns.forEach(btn => btn.classList.remove('active')); topperBtns.forEach(btn => btn.classList.remove('active')); return; } activeClassicMenu = target; panel.classList.remove('minimized'); if (isMobile) setMobileTab(target === 'colors' ? 'colors' : 'controls'); patternBtns.forEach(btn => btn.classList.toggle('active', target === 'pattern' && btn.dataset.patternBase === currentPatternParts().base)); topperBtns.forEach(btn => btn.classList.toggle('active', target === 'topper')); if (target === 'pattern') openDrawer('pattern'); else if (target === 'colors') openDrawer('colors'); }; const applyPattern = (base, variant) => { const sel = document.getElementById('classic-pattern'); if (!sel) return; const target = `${base} ${variant}`; if (sel.value !== target) sel.value = target; sel.dispatchEvent(new Event('change', { bubbles: true })); }; const refreshClassicButtons = () => { const { base } = currentPatternParts(); const { variant } = currentPatternParts(); const topperOn = !!document.getElementById('classic-topper-enabled')?.checked; patternBtns.forEach(btn => { const b = (btn.dataset.patternBase || '').toLowerCase(); const active = base.toLowerCase() === b; btn.classList.toggle('active', active); btn.setAttribute('aria-pressed', String(active)); }); variantBtns.forEach(btn => { const active = btn.dataset.patternVariant === variant; btn.classList.toggle('active', active); btn.setAttribute('aria-pressed', String(active)); }); topperBtns.forEach(btn => { btn.classList.toggle('active', topperOn); btn.setAttribute('aria-pressed', String(topperOn)); }); }; // Organic bindings document.getElementById('dock-draw')?.addEventListener('click', () => { setMode('draw'); openOrganicPanel('controls'); }); document.getElementById('dock-erase')?.addEventListener('click', () => { setMode('erase'); openOrganicPanel('controls'); }); document.getElementById('dock-select')?.addEventListener('click', () => { setMode('select'); openOrganicPanel('controls'); }); document.getElementById('dock-picker')?.addEventListener('click', () => { if (mode === 'eyedropper') setMode('draw'); else setMode('eyedropper'); openOrganicPanel('controls'); }); document.getElementById('dock-color-trigger')?.addEventListener('click', () => { const isMobile = window.matchMedia('(max-width: 1023px)').matches; if (isMobile) openOrganicPanel('colors'); else openColorsPanel(); }); // Classic bindings patternBtns.forEach(btn => { btn.addEventListener('click', () => { const { variant } = currentPatternParts(); const base = btn.dataset.patternBase || 'Arch'; applyPattern(base, variant); toggleClassicMenu('pattern'); closeClassicDrawers(); refreshClassicButtons(); }); }); variantBtns.forEach(btn => { btn.addEventListener('click', () => { const { base } = currentPatternParts(); const variant = btn.dataset.patternVariant || '4'; applyPattern(base, variant); toggleClassicMenu('pattern'); closeClassicDrawers(); refreshClassicButtons(); }); }); topperBtns.forEach(btn => { btn.addEventListener('click', () => { const cb = document.getElementById('classic-topper-enabled'); if (!cb) return; cb.checked = !cb.checked; cb.dispatchEvent(new Event('change', { bubbles: true })); toggleClassicMenu('topper'); refreshClassicButtons(); }); }); document.getElementById('dock-classic-color')?.addEventListener('click', () => { if (activeClassicMenu === 'colors') closeClassicDrawers(); else toggleClassicMenu('colors'); refreshClassicButtons(); }); // Header Export document.getElementById('header-export')?.addEventListener('click', () => exportPng()); document.getElementById('header-undo')?.addEventListener('click', undo); document.getElementById('header-redo')?.addEventListener('click', redo); const mq = window.matchMedia('(min-width: 1024px)'); const sync = () => { if (mq.matches) { document.body?.removeAttribute('data-mobile-tab'); updateMobileStacks('all'); // Remove minimized on desktop just in case const orgPanel = document.getElementById('controls-panel'); const claPanel = document.getElementById('classic-controls-panel'); if (orgPanel) { orgPanel.classList.remove('minimized'); orgPanel.style.display = ''; } if (claPanel) { claPanel.classList.remove('minimized'); claPanel.style.display = ''; } } else { setMobileTab(document.body?.dataset?.mobileTab || 'controls'); // Start minimized on mobile document.getElementById('controls-panel')?.classList.add('minimized'); document.getElementById('classic-controls-panel')?.classList.add('minimized'); } syncDockGroup(); refreshClassicButtons(); }; mq.addEventListener('change', sync); setMobileTab(document.body?.dataset?.mobileTab || 'controls'); sync(); // keep dock in sync when tab switches document.querySelectorAll('#mode-tabs .tab-btn').forEach(btn => { btn.addEventListener('click', () => setTimeout(() => { syncDockGroup(); refreshClassicButtons(); }, 50)); }); document.getElementById('classic-pattern')?.addEventListener('change', refreshClassicButtons); document.getElementById('classic-topper-enabled')?.addEventListener('change', refreshClassicButtons); refreshClassicButtons(); })(); }); })();