balloonDesign/script.js

1877 lines
71 KiB
JavaScript

// 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 = '<div class="hint">No colors yet.</div>';
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 += `<pattern id="${patternId}" patternContentUnits="objectBoundingBox" width="1" height="1">
<image href="${imageHref}" x="${imgX}" y="${imgY}" width="${imgW}" height="${imgH}" preserveAspectRatio="xMidYMid slice" />
</pattern>`;
}
fill = `url(#${patterns.get(patternKey)})`;
}
elements += `<circle cx="${b.x}" cy="${b.y}" r="${b.radius}" fill="${fill}" stroke="#111827" stroke-width="2" />`;
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 += `<ellipse cx="${sx}" cy="${sy}" rx="${rx}" ry="${ry}" fill="${shineFill}"${stroke} transform="rotate(${SHINE_ROT} ${sx} ${sy})" />`;
}
});
const svgString = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="${XLINK_NS}" viewBox="${vb}" width="${width}" height="${height}">
<defs>${defs}</defs>
${elements}
</svg>`;
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 <image> 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 = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
<image href="${pngUrl}" x="0" y="0" width="${width}" height="${height}" preserveAspectRatio="xMidYMid meet" />
</svg>`;
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();
})();
});
})();