// 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.20; // 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 VIEW_MIN_SCALE = 0.12; const VIEW_MAX_SCALE = 1.05; const MAX_BALLOONS = 800; // ====== Garland path defaults ====== const GARLAND_POINT_STEP = 8; const GARLAND_BASE_DIAM = 18; const GARLAND_FILLER_DIAMS = [11, 9]; const GARLAND_ACCENT_DIAM = 5; const GARLAND_SPACING_RATIO = 0.85; // spacing along path vs base diameter const GARLAND_WOBBLE_RATIO = 0.35; const GARLAND_SIZE_JITTER = 0.14; const clamp = (v, min, max) => Math.max(min, Math.min(max, v)); const clamp01 = v => clamp(v, 0, 1); const makeSeededRng = seed => { let s = seed || 1; return () => { s ^= s << 13; s ^= s >>> 17; s ^= s << 5; return (s >>> 0) / 4294967296; }; }; 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 toolGarlandBtn = document.getElementById('tool-garland'); 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 garlandDensityInput = document.getElementById('garland-density'); const garlandDensityLabel = document.getElementById('garland-density-label'); const garlandColorMain1Sel = document.getElementById('garland-color-main1'); const garlandColorMain2Sel = document.getElementById('garland-color-main2'); const garlandColorMain3Sel = document.getElementById('garland-color-main3'); const garlandColorMain4Sel = document.getElementById('garland-color-main4'); const garlandColorAccentSel = document.getElementById('garland-color-accent'); const garlandSwatchMain1 = document.getElementById('garland-swatch-main1'); const garlandSwatchMain2 = document.getElementById('garland-swatch-main2'); const garlandSwatchMain3 = document.getElementById('garland-swatch-main3'); const garlandSwatchMain4 = document.getElementById('garland-swatch-main4'); const garlandSwatchAccent = document.getElementById('garland-swatch-accent'); const garlandControls = document.getElementById('garland-controls'); const sizePresetGroup = document.getElementById('size-preset-group'); const toggleShineBtn = null; const toggleShineCheckbox = document.getElementById('toggle-shine-checkbox'); const toggleBorderCheckbox = document.getElementById('toggle-border-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'); const clearCanvasBtnTop = document.getElementById('clear-canvas-btn-top'); // 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 isBorderEnabled = false; 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; let garlandPath = []; let garlandDensity = parseFloat(garlandDensityInput?.value || '1') || 1; let garlandMainIdx = [0, 0, 0, 0]; let garlandAccentIdx = 0; // 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 hex = normalizeHex(colorHex); const isRetroWhite = hex === '#e8e3d9'; const isPureWhite = hex === '#ffffff'; const lum = luminance(hex); if (isPureWhite || isRetroWhite) { // subtle gray shine on pure white return { fill: 'rgba(220,220,220,0.22)', stroke: null }; } if (lum > 0.7) { const t = clamp01((lum - 0.7) / 0.3); const fillAlpha = 0.08 + (0.04 - 0.08) * t; return { fill: `rgba(0,0,0,${fillAlpha})`, stroke: null }; } const base = SHINE_ALPHA; const softened = lum > 0.4 ? base * 0.7 : base; const finalAlpha = isRetroWhite ? softened * 0.6 : softened; return { fill: `rgba(255,255,255,${finalAlpha})`, stroke: null }; } function clampViewScale() { view.s = clamp(view.s, VIEW_MIN_SCALE, VIEW_MAX_SCALE); } 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, opts = {}) { if (window.Swal) { Swal.fire({ title: opts.title || 'Notice', text: msg, icon: opts.icon || 'info', confirmButtonText: opts.confirmText || 'OK' }); return; } if (!messageModal || !modalText) { window.alert?.(msg); return; } modalText.textContent = msg; messageModal.classList.remove('hidden'); } function hideModal() { if (window.Swal) { Swal.close?.(); return; } 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) { if (mode === 'garland' && next !== 'garland') { garlandPath = []; } mode = next; toolDrawBtn?.setAttribute('aria-pressed', String(mode === 'draw')); toolGarlandBtn?.setAttribute('aria-pressed', String(mode === 'garland')); 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'); garlandControls?.classList.toggle('hidden', mode !== 'garland'); if (mode === 'erase') canvas.style.cursor = 'none'; else if (mode === 'select') canvas.style.cursor = 'default'; // will be move over items else if (mode === 'garland') canvas.style.cursor = 'crosshair'; 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 === 'garland') { pointerDown = true; garlandPath = [{ ...mousePos }]; requestDraw(); 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 === 'garland') { if (pointerDown) { const last = garlandPath[garlandPath.length - 1]; if (!last || Math.hypot(mousePos.x - last.x, mousePos.y - last.y) >= GARLAND_POINT_STEP) { garlandPath.push({ ...mousePos }); requestDraw(); } } return; } 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 === 'garland') { if (garlandPath.length > 1) addGarlandFromPath(garlandPath); garlandPath = []; requestDraw(); canvas.releasePointerCapture?.(e.pointerId); return; } 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 === 'garland') { pointerDown = false; garlandPath = []; requestDraw(); } 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(); const lum = luminance(meta.hex || b.color); if (lum > 0.6) { const strength = clamp01((lum - 0.6) / 0.4); // more shadow for lighter colors ctx.shadowColor = `rgba(0,0,0,${0.05 + 0.07 * strength})`; ctx.shadowBlur = 4 + 4 * strength; ctx.shadowOffsetY = 1 + 2 * strength; } ctx.drawImage(img, srcX, srcY, srcW, srcH, b.x - b.radius, b.y - b.radius, b.radius * 2, b.radius * 2); if (isBorderEnabled) { ctx.strokeStyle = '#111827'; ctx.lineWidth = Math.max(0.35, 0.5 / view.s); ctx.stroke(); } 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(); if (isBorderEnabled) { ctx.strokeStyle = '#111827'; ctx.lineWidth = Math.max(0.35, 0.5 / view.s); ctx.stroke(); } 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(); if (isBorderEnabled) { ctx.strokeStyle = '#111827'; ctx.lineWidth = Math.max(0.35, 0.5 / view.s); ctx.stroke(); } 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(); } }); // garland path preview if (mode === 'garland' && garlandPath.length) { ctx.save(); ctx.lineWidth = 1.5 / view.s; ctx.strokeStyle = 'rgba(59,130,246,0.7)'; ctx.setLineDash([8 / view.s, 6 / view.s]); ctx.beginPath(); garlandPath.forEach((p, idx) => { if (idx === 0) ctx.moveTo(p.x, p.y); else ctx.lineTo(p.x, p.y); }); ctx.stroke(); ctx.setLineDash([]); const previewNodes = computeGarlandNodes(garlandPath).sort((a, b) => b.radius - a.radius); ctx.strokeStyle = 'rgba(59,130,246,0.28)'; previewNodes.forEach(n => { ctx.beginPath(); ctx.arc(n.x, n.y, n.radius, 0, Math.PI * 2); ctx.stroke(); }); 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, garlandDensity, garlandMainIdx, garlandAccentIdx, isBorderEnabled }; 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; clampViewScale(); } if (typeof s.usedSortDesc === 'boolean') { usedSortDesc = s.usedSortDesc; if (sortUsedToggle) sortUsedToggle.textContent = usedSortDesc ? 'Sort: Most → Least' : 'Sort: Least → Most'; } if (typeof s.garlandDensity === 'number') { garlandDensity = clamp(s.garlandDensity, 0.6, 1.6); if (garlandDensityInput) garlandDensityInput.value = garlandDensity; if (garlandDensityLabel) garlandDensityLabel.textContent = garlandDensity.toFixed(1); } if (Array.isArray(s.garlandMainIdx)) { garlandMainIdx = s.garlandMainIdx.slice(0, 4).map(v => Number(v) || -1); while (garlandMainIdx.length < 4) garlandMainIdx.push(-1); } if (typeof s.garlandAccentIdx === 'number') garlandAccentIdx = s.garlandAccentIdx; if (typeof s.isBorderEnabled === 'boolean') isBorderEnabled = s.isBorderEnabled; if (toggleBorderCheckbox) toggleBorderCheckbox.checked = isBorderEnabled; 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, { showLabel = true } = {}) => { 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 = showLabel ? (meta.name || meta.hex || 'Current') : ''; label.title = meta.name || meta.hex || 'Current'; } chip.title = meta.name || meta.hex || 'Current'; }; updateChip('current-color-chip', 'current-color-label', { showLabel: true }); updateChip('current-color-chip-global', 'current-color-label-global', { showLabel: false }); } 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 buildBalloon(meta, x, y, radius) { return { x, y, radius, color: meta.hex, image: meta.image || null, colorIdx: meta._idx, id: crypto.randomUUID() }; } function addBalloon(x, y) { const meta = FLAT_COLORS[selectedColorIdx] || FLAT_COLORS[0]; if (balloons.length >= MAX_BALLOONS) { showModal(`Balloon limit reached (${MAX_BALLOONS}). Delete some to add more.`); return; } balloons.push(buildBalloon(meta, x, y, currentRadius)); ensureVisibleAfterAdd(balloons[balloons.length - 1]); refreshAll(); pushHistory(); } function garlandSeed(path) { let h = 2166136261 >>> 0; path.forEach(p => { h ^= Math.round(p.x * 10) + 0x9e3779b9; h = Math.imul(h, 16777619); h ^= Math.round(p.y * 10); h = Math.imul(h, 16777619); }); return h >>> 0 || 1; } function computeGarlandNodes(path) { if (!Array.isArray(path) || path.length < 2) return []; const baseRadius = inchesToRadiusPx(GARLAND_BASE_DIAM); const fillerRadii = GARLAND_FILLER_DIAMS.map(inchesToRadiusPx); const accentRadius = inchesToRadiusPx(GARLAND_ACCENT_DIAM); const spacing = Math.max(10, baseRadius * (GARLAND_SPACING_RATIO / garlandDensity)); const nodes = []; let carry = 0; const rng = makeSeededRng(garlandSeed(path)); for (let i = 0; i < path.length - 1; i++) { const a = path[i]; const b = path[i + 1]; const dx = b.x - a.x; const dy = b.y - a.y; const segLen = Math.hypot(dx, dy); if (segLen < 1) continue; let dist = carry; const nx = segLen > 0 ? (dy / segLen) : 0; const ny = segLen > 0 ? (-dx / segLen) : 0; while (dist <= segLen) { const t = dist / segLen; const px = a.x + dx * t; const py = a.y + dy * t; const side = rng() > 0.5 ? 1 : -1; const wobble = (rng() * 2 - 1) * baseRadius * GARLAND_WOBBLE_RATIO; const sizeJitter = 1 + (rng() * 2 - 1) * GARLAND_SIZE_JITTER; const r = clamp(baseRadius * sizeJitter, baseRadius * 0.75, baseRadius * 1.35); const baseX = px + nx * wobble; const baseY = py + ny * wobble; nodes.push({ x: baseX, y: baseY, radius: r, type: 'base' }); // filler balloons hugging the base to thicken the line const fillerR = fillerRadii[Math.floor(rng() * fillerRadii.length)] || baseRadius * 0.7; const offset1 = r * 0.7; nodes.push({ x: baseX + nx * offset1 * side, y: baseY + ny * offset1 * side, radius: fillerR * (0.9 + rng() * 0.2), type: 'filler' }); const tangentSide = side * (rng() > 0.5 ? 1 : -1); const offset2 = r * 0.5; nodes.push({ x: baseX + (-ny) * offset2 * tangentSide, y: baseY + (nx) * offset2 * tangentSide, radius: fillerR * (0.8 + rng() * 0.25), type: 'filler' }); // Accent cluster of three 5" balloons to add texture if (rng() < Math.min(0.8, 0.35 * garlandDensity + 0.1)) { const clusterCenterX = baseX + nx * r * 0.4 * side; const clusterCenterY = baseY + ny * r * 0.4 * side; for (let c = 0; c < 3; c++) { const ang = rng() * Math.PI * 2; const mag = accentRadius * (0.8 + rng() * 0.5); nodes.push({ x: clusterCenterX + Math.cos(ang) * mag, y: clusterCenterY + Math.sin(ang) * mag, radius: accentRadius * (0.85 + rng() * 0.25), type: 'accent' }); } } dist += spacing; } carry = dist - segLen; } return nodes; } function addGarlandFromPath(path) { const meta = FLAT_COLORS[selectedColorIdx] || FLAT_COLORS[0]; if (!meta) return; const nodes = computeGarlandNodes(path).sort((a, b) => b.radius - a.radius); // draw larger first so small accents sit on top if (!nodes.length) return; const available = Math.max(0, MAX_BALLOONS - balloons.length); const limitedNodes = available ? nodes.slice(0, available) : []; if (!limitedNodes.length) { showModal(`Balloon limit reached (${MAX_BALLOONS}). Delete some to add more.`); return; } const newIds = []; const rng = makeSeededRng(garlandSeed(path) + 101); const metaFromIdx = idx => { const m = FLAT_COLORS[idx]; return m ? m : meta; }; const pickMainMeta = () => { const choices = garlandMainIdx.filter(v => Number.isFinite(v) && v >= 0 && FLAT_COLORS[v]); if (!choices.length) return meta; const pick = choices.length === 1 ? choices[0] : choices[Math.floor(rng() * choices.length)]; return metaFromIdx(pick); }; const accentMeta = (garlandAccentIdx >= 0 && FLAT_COLORS[garlandAccentIdx]) ? FLAT_COLORS[garlandAccentIdx] : metaFromIdx(garlandMainIdx.find(v => v >= 0)); limitedNodes.forEach(n => { const m = n.type === 'accent' ? accentMeta : pickMainMeta(); const b = buildBalloon(m, n.x, n.y, n.radius); balloons.push(b); newIds.push(b.id); }); if (newIds.length) { selectedIds.clear(); updateSelectButtons(); } refreshAll(); 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); const loaded = 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() }; }) : []; balloons = loaded.slice(0, MAX_BALLOONS); selectedIds.clear(); updateSelectButtons(); refreshAll({ refit: true }); resetHistory(); persist(); if (loaded.length > MAX_BALLOONS) { showModal(`Design loaded (trimmed to ${MAX_BALLOONS} balloons).`); } } 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)})`; } const strokeAttr = isBorderEnabled ? ` stroke="#111827" stroke-width="0.5"` : ` stroke="none" stroke-width="0"`; 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"` : ''; 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', isBorderEnabled ? '#111827' : 'none'); if (!el.getAttribute('stroke-width')) el.setAttribute('stroke-width', isBorderEnabled ? '1' : '0'); 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); const loaded = 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); balloons = loaded.slice(0, MAX_BALLOONS); refreshAll({ refit: true }); resetHistory(); persist(); updateCurrentColorChip(); if (loaded.length > MAX_BALLOONS) { showModal(`Design loaded (trimmed to ${MAX_BALLOONS} balloons).`); } else { 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; clampViewScale(); 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(VIEW_MAX_SCALE, isFinite(sFit) && sFit > 0 ? sFit : 1); clampViewScale(); 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(VIEW_MIN_SCALE, sNeeded); } clampViewScale(); 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')); toolGarlandBtn?.addEventListener('click', () => setMode('garland')); 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(); }); toggleBorderCheckbox?.addEventListener('change', e => { isBorderEnabled = !!e.target.checked; draw(); persist(); }); garlandDensityInput?.addEventListener('input', e => { garlandDensity = clamp(parseFloat(e.target.value) || 1, 0.6, 1.6); if (garlandDensityLabel) garlandDensityLabel.textContent = garlandDensity.toFixed(1); if (mode === 'garland') requestDraw(); persist(); }); const handleGarlandColorChange = () => { updateGarlandSwatches(); persist(); if (mode === 'garland') requestDraw(); }; garlandColorMain1Sel?.addEventListener('change', e => { garlandMainIdx[0] = parseInt(e.target.value, 10) || -1; handleGarlandColorChange(); }); garlandColorMain2Sel?.addEventListener('change', e => { garlandMainIdx[1] = parseInt(e.target.value, 10) || -1; handleGarlandColorChange(); }); garlandColorMain3Sel?.addEventListener('change', e => { garlandMainIdx[2] = parseInt(e.target.value, 10) || -1; handleGarlandColorChange(); }); garlandColorMain4Sel?.addEventListener('change', e => { garlandMainIdx[3] = parseInt(e.target.value, 10) || -1; handleGarlandColorChange(); }); garlandColorAccentSel?.addEventListener('change', e => { garlandAccentIdx = parseInt(e.target.value, 10) || -1; handleGarlandColorChange(); }); 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 === 'g' || e.key === 'G') setMode('garland'); 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(); } }); async function confirmAndClear() { let ok = true; if (window.Swal) { const res = await Swal.fire({ title: 'Start fresh?', text: 'This will remove all balloons from the canvas.', icon: 'warning', showCancelButton: true, confirmButtonText: 'Yes, clear', cancelButtonText: 'Cancel' }); ok = res.isConfirmed; } else { ok = window.confirm('Start fresh? This will remove all balloons from the canvas.'); } if (!ok) return; balloons = []; selectedIds.clear(); garlandPath = []; updateSelectButtons(); refreshAll({ refit: true }); pushHistory(); } clearCanvasBtn?.addEventListener('click', confirmAndClear); clearCanvasBtnTop?.addEventListener('click', confirmAndClear); 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); }); } function populateGarlandColorSelects() { const addOpts = sel => { if (!sel) return; sel.innerHTML = ''; const noneOpt = document.createElement('option'); noneOpt.value = '-1'; noneOpt.textContent = 'None (use active color)'; sel.appendChild(noneOpt); FLAT_COLORS.forEach((c, idx) => { const opt = document.createElement('option'); opt.value = String(idx); opt.textContent = c.name || c.hex; sel.appendChild(opt); }); }; addOpts(garlandColorMain1Sel); addOpts(garlandColorMain2Sel); addOpts(garlandColorMain3Sel); addOpts(garlandColorMain4Sel); addOpts(garlandColorAccentSel); if (garlandColorMain1Sel) garlandColorMain1Sel.value = String(garlandMainIdx[0] ?? -1); if (garlandColorMain2Sel) garlandColorMain2Sel.value = String(garlandMainIdx[1] ?? -1); if (garlandColorMain3Sel) garlandColorMain3Sel.value = String(garlandMainIdx[2] ?? -1); if (garlandColorMain4Sel) garlandColorMain4Sel.value = String(garlandMainIdx[3] ?? -1); if (garlandColorAccentSel) garlandColorAccentSel.value = String(garlandAccentIdx ?? -1); updateGarlandSwatches(); } function updateGarlandSwatches() { const setSw = (sw, idx) => { if (!sw) return; const meta = idx >= 0 ? FLAT_COLORS[idx] : null; if (meta?.image) { sw.style.backgroundImage = `url("${meta.image}")`; sw.style.backgroundColor = meta.hex || '#fff'; sw.style.backgroundSize = 'cover'; } else { sw.style.backgroundImage = 'none'; sw.style.backgroundColor = meta?.hex || '#f1f5f9'; } }; setSw(garlandSwatchMain1, garlandMainIdx[0]); setSw(garlandSwatchMain2, garlandMainIdx[1]); setSw(garlandSwatchMain3, garlandMainIdx[2]); setSw(garlandSwatchMain4, garlandMainIdx[3]); setSw(garlandSwatchAccent, garlandAccentIdx); } 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(); populateGarlandColorSelects(); // 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 classicActive = document.body?.dataset.activeTab === '#tab-classic'; const topperActive = document.body?.dataset.topperOverlay === '1'; const shouldShow = 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(); }); })(); }); })();