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