2383 lines
89 KiB
JavaScript
2383 lines
89 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.20; // ROT is now in degrees
|
|
let view = { s: 1, tx: 0, ty: 0 };
|
|
const FIT_PADDING_PX = 30;
|
|
|
|
// ====== Texture defaults ======
|
|
const TEXTURE_ZOOM_DEFAULT = 1.8;
|
|
const TEXTURE_FOCUS_DEFAULT = { x: 0.5, y: 0.5 };
|
|
const SWATCH_TEXTURE_ZOOM = 2.5;
|
|
const PNG_EXPORT_SCALE = 3;
|
|
const VIEW_MIN_SCALE = 0.12;
|
|
const VIEW_MAX_SCALE = 1.05;
|
|
const MAX_BALLOONS = 800;
|
|
|
|
// ====== Garland path defaults ======
|
|
const GARLAND_POINT_STEP = 8;
|
|
const GARLAND_BASE_DIAM = 18;
|
|
const GARLAND_FILLER_DIAMS = [11, 9];
|
|
const GARLAND_ACCENT_DIAM = 5;
|
|
const GARLAND_SPACING_RATIO = 0.85; // spacing along path vs base diameter
|
|
const GARLAND_WOBBLE_RATIO = 0.35;
|
|
const GARLAND_SIZE_JITTER = 0.14;
|
|
|
|
const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
|
|
const clamp01 = v => clamp(v, 0, 1);
|
|
const makeSeededRng = seed => {
|
|
let s = seed || 1;
|
|
return () => {
|
|
s ^= s << 13;
|
|
s ^= s >>> 17;
|
|
s ^= s << 5;
|
|
return (s >>> 0) / 4294967296;
|
|
};
|
|
};
|
|
|
|
const QUERY_KEY = 'd';
|
|
|
|
// ====== Flatten palette & maps ======
|
|
const FLAT_COLORS = [];
|
|
const NAME_BY_HEX = new Map();
|
|
const HEX_TO_FIRST_IDX = new Map();
|
|
const allowedSet = new Set();
|
|
|
|
(function buildFlat() {
|
|
if (!Array.isArray(window.PALETTE)) return;
|
|
window.PALETTE.forEach(group => {
|
|
(group.colors || []).forEach(c => {
|
|
if (!c?.hex) return;
|
|
const item = { ...c, family: group.family };
|
|
item.imageZoom = Number.isFinite(c.imageZoom) ? Math.max(1, c.imageZoom) : TEXTURE_ZOOM_DEFAULT;
|
|
item.imageFocus = {
|
|
x: clamp01(c.imageFocusX ?? c.imageFocus?.x ?? TEXTURE_FOCUS_DEFAULT.x),
|
|
y: clamp01(c.imageFocusY ?? c.imageFocus?.y ?? TEXTURE_FOCUS_DEFAULT.y)
|
|
};
|
|
item._idx = FLAT_COLORS.length;
|
|
FLAT_COLORS.push(item);
|
|
|
|
const key = (c.hex || '').toLowerCase();
|
|
if (!NAME_BY_HEX.has(key)) NAME_BY_HEX.set(key, c.name);
|
|
if (!HEX_TO_FIRST_IDX.has(key)) HEX_TO_FIRST_IDX.set(key, item._idx);
|
|
allowedSet.add(key);
|
|
});
|
|
});
|
|
})();
|
|
|
|
// ====== Image cache ======
|
|
const IMG_CACHE = new Map();
|
|
function getImage(path) {
|
|
if (!path) return null;
|
|
let img = IMG_CACHE.get(path);
|
|
if (!img) {
|
|
img = new Image();
|
|
img.decoding = 'async';
|
|
img.loading = 'eager';
|
|
img.src = path;
|
|
img.onload = () => draw();
|
|
IMG_CACHE.set(path, img);
|
|
}
|
|
return img;
|
|
}
|
|
|
|
// ====== DOM ======
|
|
const canvas = document.getElementById('balloon-canvas');
|
|
const ctx = canvas?.getContext('2d');
|
|
const orgSheet = document.getElementById('controls-panel');
|
|
const claSheet = document.getElementById('classic-controls-panel');
|
|
|
|
// tool buttons
|
|
const toolDrawBtn = document.getElementById('tool-draw');
|
|
const toolGarlandBtn = document.getElementById('tool-garland');
|
|
const toolEraseBtn = document.getElementById('tool-erase');
|
|
const toolSelectBtn = document.getElementById('tool-select');
|
|
const toolUndoBtn = document.getElementById('tool-undo');
|
|
const toolRedoBtn = document.getElementById('tool-redo');
|
|
|
|
// panels/controls
|
|
const eraserControls = document.getElementById('eraser-controls');
|
|
const selectControls = document.getElementById('select-controls');
|
|
const eraserSizeInput = document.getElementById('eraser-size');
|
|
const eraserSizeLabel = document.getElementById('eraser-size-label');
|
|
const deleteSelectedBtn = document.getElementById('delete-selected');
|
|
const duplicateSelectedBtn = document.getElementById('duplicate-selected');
|
|
const selectedSizeInput = document.getElementById('selected-size');
|
|
const selectedSizeLabel = document.getElementById('selected-size-label');
|
|
const nudgeSelectedBtns = Array.from(document.querySelectorAll('.nudge-selected'));
|
|
const bringForwardBtn = document.getElementById('bring-forward');
|
|
const sendBackwardBtn = document.getElementById('send-backward');
|
|
const applyColorBtn = document.getElementById('apply-selected-color');
|
|
const fitViewBtn = document.getElementById('fit-view-btn');
|
|
const garlandDensityInput = document.getElementById('garland-density');
|
|
const garlandDensityLabel = document.getElementById('garland-density-label');
|
|
const garlandColorMain1Sel = document.getElementById('garland-color-main1');
|
|
const garlandColorMain2Sel = document.getElementById('garland-color-main2');
|
|
const garlandColorMain3Sel = document.getElementById('garland-color-main3');
|
|
const garlandColorMain4Sel = document.getElementById('garland-color-main4');
|
|
const garlandColorAccentSel = document.getElementById('garland-color-accent');
|
|
const garlandSwatchMain1 = document.getElementById('garland-swatch-main1');
|
|
const garlandSwatchMain2 = document.getElementById('garland-swatch-main2');
|
|
const garlandSwatchMain3 = document.getElementById('garland-swatch-main3');
|
|
const garlandSwatchMain4 = document.getElementById('garland-swatch-main4');
|
|
const garlandSwatchAccent = document.getElementById('garland-swatch-accent');
|
|
const garlandControls = document.getElementById('garland-controls');
|
|
|
|
const sizePresetGroup = document.getElementById('size-preset-group');
|
|
const toggleShineBtn = null;
|
|
const toggleShineCheckbox = document.getElementById('toggle-shine-checkbox');
|
|
const toggleBorderCheckbox = document.getElementById('toggle-border-checkbox');
|
|
|
|
const paletteBox = document.getElementById('color-palette');
|
|
const usedPaletteBox = document.getElementById('used-palette');
|
|
const sortUsedToggle = document.getElementById('sort-used-toggle');
|
|
|
|
// replace colors panel
|
|
const replaceFromSel = document.getElementById('replace-from');
|
|
const replaceToSel = document.getElementById('replace-to');
|
|
const replaceBtn = document.getElementById('replace-btn');
|
|
const replaceMsg = document.getElementById('replace-msg');
|
|
|
|
// IO
|
|
const clearCanvasBtn = document.getElementById('clear-canvas-btn');
|
|
const saveJsonBtn = document.getElementById('save-json-btn');
|
|
const loadJsonInput = document.getElementById('load-json-input');
|
|
|
|
// delegate export buttons (now by data-export to allow multiple)
|
|
document.body.addEventListener('click', e => {
|
|
const btn = e.target.closest('[data-export]');
|
|
if (!btn) return;
|
|
const type = btn.dataset.export;
|
|
if (type === 'png') exportPng();
|
|
else if (type === 'svg') exportSvg();
|
|
});
|
|
|
|
const generateLinkBtn = document.getElementById('generate-link-btn');
|
|
const shareLinkOutput = document.getElementById('share-link-output');
|
|
const copyMessage = document.getElementById('copy-message');
|
|
const clearCanvasBtnTop = document.getElementById('clear-canvas-btn-top');
|
|
|
|
// messages
|
|
const messageModal = document.getElementById('message-modal');
|
|
const modalText = document.getElementById('modal-text');
|
|
const modalCloseBtn = document.getElementById('modal-close-btn');
|
|
|
|
// layout
|
|
const controlsPanel = document.getElementById('controls-panel');
|
|
const canvasPanel = document.getElementById('canvas-panel');
|
|
const expandBtn = null;
|
|
const fullscreenBtn = null;
|
|
|
|
if (!canvas || !ctx) return; // nothing to do if organic UI isn't on page
|
|
|
|
// ====== State ======
|
|
let balloons = [];
|
|
let selectedColorIdx = 0;
|
|
let currentDiameterInches = 11;
|
|
let currentRadius = inchesToRadiusPx(currentDiameterInches);
|
|
let isShineEnabled = true; // will be initialized from localStorage
|
|
let isBorderEnabled = true;
|
|
|
|
let dpr = 1;
|
|
let mode = 'draw';
|
|
let eraserRadius = parseInt(eraserSizeInput?.value || '40', 10);
|
|
let mouseInside = false;
|
|
let mousePos = { x: 0, y: 0 };
|
|
let selectedIds = new Set();
|
|
let usedSortDesc = true;
|
|
let garlandPath = [];
|
|
let garlandDensity = parseFloat(garlandDensityInput?.value || '1') || 1;
|
|
let garlandMainIdx = [0, 0, 0, 0];
|
|
let garlandAccentIdx = 0;
|
|
|
|
// History for Undo/Redo
|
|
const historyStack = [];
|
|
let historyPointer = -1;
|
|
function resetHistory() {
|
|
historyStack.length = 0;
|
|
historyPointer = -1;
|
|
pushHistory();
|
|
}
|
|
|
|
function updateHistoryUi() {
|
|
const canUndo = historyPointer > 0;
|
|
const canRedo = historyPointer < historyStack.length - 1;
|
|
if (toolUndoBtn) {
|
|
toolUndoBtn.disabled = !canUndo;
|
|
toolUndoBtn.title = canUndo ? 'Undo (Ctrl+Z)' : 'Nothing to undo';
|
|
}
|
|
if (toolRedoBtn) {
|
|
toolRedoBtn.disabled = !canRedo;
|
|
toolRedoBtn.title = canRedo ? 'Redo (Ctrl+Y)' : 'Nothing to redo';
|
|
}
|
|
}
|
|
|
|
function pushHistory() {
|
|
// Remove any future history if we are in the middle of the stack
|
|
if (historyPointer < historyStack.length - 1) {
|
|
historyStack.splice(historyPointer + 1);
|
|
}
|
|
// Deep clone balloons array
|
|
const snapshot = JSON.parse(JSON.stringify(balloons));
|
|
historyStack.push(snapshot);
|
|
historyPointer++;
|
|
// Limit stack size
|
|
if (historyStack.length > 50) {
|
|
historyStack.shift();
|
|
historyPointer--;
|
|
}
|
|
updateHistoryUi();
|
|
}
|
|
|
|
function undo() {
|
|
if (historyPointer > 0) {
|
|
historyPointer--;
|
|
balloons = JSON.parse(JSON.stringify(historyStack[historyPointer]));
|
|
selectedIds.clear(); // clear selection on undo to avoid issues
|
|
updateSelectButtons();
|
|
draw();
|
|
renderUsedPalette();
|
|
persist();
|
|
}
|
|
updateHistoryUi();
|
|
}
|
|
|
|
function redo() {
|
|
if (historyPointer < historyStack.length - 1) {
|
|
historyPointer++;
|
|
balloons = JSON.parse(JSON.stringify(historyStack[historyPointer]));
|
|
selectedIds.clear();
|
|
updateSelectButtons();
|
|
draw();
|
|
renderUsedPalette();
|
|
persist();
|
|
}
|
|
updateHistoryUi();
|
|
}
|
|
|
|
// Bind Undo/Redo Buttons
|
|
toolUndoBtn?.addEventListener('click', undo);
|
|
toolRedoBtn?.addEventListener('click', redo);
|
|
|
|
// Eyedropper Tool
|
|
const toolEyedropperBtn = document.getElementById('tool-eyedropper');
|
|
toolEyedropperBtn?.addEventListener('click', () => {
|
|
// Toggle eyedropper mode
|
|
if (mode === 'eyedropper') {
|
|
setMode('draw'); // toggle off
|
|
} else {
|
|
setMode('eyedropper');
|
|
}
|
|
});
|
|
|
|
|
|
// ====== Helpers ======
|
|
const normalizeHex = h => (h || '').toLowerCase();
|
|
function hexToRgb(hex) {
|
|
const h = normalizeHex(hex).replace('#','');
|
|
if (h.length === 3) {
|
|
const r = parseInt(h[0] + h[0], 16);
|
|
const g = parseInt(h[1] + h[1], 16);
|
|
const b = parseInt(h[2] + h[2], 16);
|
|
return { r, g, b };
|
|
}
|
|
if (h.length === 6) {
|
|
const r = parseInt(h.slice(0,2), 16);
|
|
const g = parseInt(h.slice(2,4), 16);
|
|
const b = parseInt(h.slice(4,6), 16);
|
|
return { r, g, b };
|
|
}
|
|
return { r: 0, g: 0, b: 0 };
|
|
}
|
|
function luminance(hex) {
|
|
const { r, g, b } = hexToRgb(hex || '#000');
|
|
const norm = [r,g,b].map(v => {
|
|
const c = v / 255;
|
|
return c <= 0.03928 ? c/12.92 : Math.pow((c+0.055)/1.055, 2.4);
|
|
});
|
|
return 0.2126*norm[0] + 0.7152*norm[1] + 0.0722*norm[2];
|
|
}
|
|
function shineStyle(colorHex) {
|
|
const hex = normalizeHex(colorHex);
|
|
const isRetroWhite = hex === '#e8e3d9';
|
|
const isPureWhite = hex === '#ffffff';
|
|
const lum = luminance(hex);
|
|
if (isPureWhite || isRetroWhite) {
|
|
// subtle gray shine on pure white
|
|
return { fill: 'rgba(220,220,220,0.22)', stroke: null };
|
|
}
|
|
if (lum > 0.7) {
|
|
const t = clamp01((lum - 0.7) / 0.3);
|
|
const fillAlpha = 0.08 + (0.04 - 0.08) * t;
|
|
return { fill: `rgba(0,0,0,${fillAlpha})`, stroke: null };
|
|
}
|
|
const base = SHINE_ALPHA;
|
|
const softened = lum > 0.4 ? base * 0.7 : base;
|
|
const finalAlpha = isRetroWhite ? softened * 0.6 : softened;
|
|
return { fill: `rgba(255,255,255,${finalAlpha})`, stroke: null };
|
|
}
|
|
function clampViewScale() {
|
|
view.s = clamp(view.s, VIEW_MIN_SCALE, VIEW_MAX_SCALE);
|
|
}
|
|
function inchesToRadiusPx(diam) { return (diam * PX_PER_INCH) / 2; }
|
|
function radiusPxToInches(r) { return (r * 2) / PX_PER_INCH; }
|
|
function fmtInches(val) {
|
|
const v = Math.round(val * 10) / 10;
|
|
return `${String(v).replace(/\.0$/, '')}"`;
|
|
}
|
|
function radiusToSizeIndex(r) {
|
|
let best = 0, bestDiff = Infinity;
|
|
for (let i = 0; i < SIZE_PRESETS.length; i++) {
|
|
const diff = Math.abs(inchesToRadiusPx(SIZE_PRESETS[i]) - r);
|
|
if (diff < bestDiff) { best = i; bestDiff = diff; }
|
|
}
|
|
return best;
|
|
}
|
|
function showModal(msg, opts = {}) {
|
|
if (window.Swal) {
|
|
Swal.fire({
|
|
title: opts.title || 'Notice',
|
|
text: msg,
|
|
icon: opts.icon || 'info',
|
|
confirmButtonText: opts.confirmText || 'OK'
|
|
});
|
|
return;
|
|
}
|
|
if (!messageModal || !modalText) { window.alert?.(msg); return; }
|
|
modalText.textContent = msg;
|
|
messageModal.classList.remove('hidden');
|
|
}
|
|
function hideModal() {
|
|
if (window.Swal) { Swal.close?.(); return; }
|
|
if (!messageModal) return;
|
|
messageModal.classList.add('hidden');
|
|
}
|
|
function showCopyMessage() { if (!copyMessage) return; copyMessage.classList.add('show'); setTimeout(() => copyMessage.classList.remove('show'), 2000); }
|
|
function getMousePos(e) {
|
|
const r = canvas.getBoundingClientRect();
|
|
return {
|
|
x: (e.clientX - r.left) / view.s - view.tx,
|
|
y: (e.clientY - r.top) / view.s - view.ty
|
|
};
|
|
}
|
|
|
|
// ====== Global shine sync (shared with Classic)
|
|
window.syncAppShine = function(isEnabled) {
|
|
isShineEnabled = isEnabled;
|
|
|
|
// mirror both UIs
|
|
const organicBtn = document.getElementById('toggle-shine-btn');
|
|
const classicCb = document.getElementById('classic-shine-enabled');
|
|
if (organicBtn) organicBtn.textContent = isEnabled ? 'Turn Off Shine' : 'Turn On Shine';
|
|
if (classicCb) classicCb.checked = isEnabled;
|
|
|
|
try { localStorage.setItem('app:shineEnabled:v1', JSON.stringify(isEnabled)); } catch {}
|
|
|
|
// push into Classic engine if available
|
|
if (window.ClassicDesigner?.api?.setShineEnabled) {
|
|
window.ClassicDesigner.api.setShineEnabled(isEnabled);
|
|
}
|
|
|
|
// redraw both tabs (cheap + robust)
|
|
try { draw?.(); } catch {}
|
|
try { window.ClassicDesigner?.redraw?.(); } catch {}
|
|
};
|
|
|
|
function setMode(next) {
|
|
if (mode === 'garland' && next !== 'garland') {
|
|
garlandPath = [];
|
|
}
|
|
mode = next;
|
|
toolDrawBtn?.setAttribute('aria-pressed', String(mode === 'draw'));
|
|
toolGarlandBtn?.setAttribute('aria-pressed', String(mode === 'garland'));
|
|
toolEraseBtn?.setAttribute('aria-pressed', String(mode === 'erase'));
|
|
toolSelectBtn?.setAttribute('aria-pressed', String(mode === 'select'));
|
|
toolEyedropperBtn?.setAttribute('aria-pressed', String(mode === 'eyedropper'));
|
|
|
|
eraserControls?.classList.toggle('hidden', mode !== 'erase');
|
|
selectControls?.classList.toggle('hidden', mode !== 'select');
|
|
garlandControls?.classList.toggle('hidden', mode !== 'garland');
|
|
|
|
if (mode === 'erase') canvas.style.cursor = 'none';
|
|
else if (mode === 'select') canvas.style.cursor = 'default'; // will be move over items
|
|
else if (mode === 'garland') canvas.style.cursor = 'crosshair';
|
|
else if (mode === 'eyedropper') canvas.style.cursor = 'cell';
|
|
else canvas.style.cursor = 'crosshair';
|
|
|
|
draw();
|
|
persist();
|
|
}
|
|
|
|
function selectionArray() { return Array.from(selectedIds); }
|
|
function selectionBalloons() {
|
|
const set = new Set(selectedIds);
|
|
return balloons.filter(b => set.has(b.id));
|
|
}
|
|
function setSelection(ids, { additive = false } = {}) {
|
|
if (!additive) selectedIds.clear();
|
|
ids.forEach(id => selectedIds.add(id));
|
|
updateSelectButtons();
|
|
draw();
|
|
}
|
|
function primarySelection() {
|
|
const first = selectedIds.values().next();
|
|
return first.done ? null : first.value;
|
|
}
|
|
function clearSelection() { selectedIds.clear(); updateSelectButtons(); draw(); }
|
|
function updateSelectButtons() {
|
|
const has = selectedIds.size > 0;
|
|
if (deleteSelectedBtn) deleteSelectedBtn.disabled = !has;
|
|
if (duplicateSelectedBtn) duplicateSelectedBtn.disabled = !has;
|
|
if (selectedSizeInput) {
|
|
selectedSizeInput.disabled = !has;
|
|
selectedSizeInput.min = '5';
|
|
selectedSizeInput.max = '32';
|
|
selectedSizeInput.step = '0.5';
|
|
}
|
|
if (bringForwardBtn) bringForwardBtn.disabled = !has;
|
|
if (sendBackwardBtn) sendBackwardBtn.disabled = !has;
|
|
if (applyColorBtn) applyColorBtn.disabled = !has;
|
|
if (selectedSizeInput && selectedSizeLabel) {
|
|
if (has) {
|
|
const first = balloons.find(bb => selectedIds.has(bb.id));
|
|
if (first) {
|
|
const diam = radiusPxToInches(first.radius);
|
|
selectedSizeInput.value = String(Math.min(32, Math.max(5, diam)));
|
|
selectedSizeLabel.textContent = fmtInches(diam);
|
|
}
|
|
} else {
|
|
selectedSizeLabel.textContent = '0"';
|
|
}
|
|
}
|
|
}
|
|
|
|
// ====== Pointer Events ======
|
|
let pointerDown = false;
|
|
let isDragging = false;
|
|
let dragStartPos = { x: 0, y: 0 };
|
|
let initialBalloonPos = { x: 0, y: 0 };
|
|
let eraseChanged = false;
|
|
let dragMoved = false;
|
|
let resizeChanged = false;
|
|
let resizeSaveTimer = null;
|
|
let erasingActive = false;
|
|
let drawPending = false;
|
|
let dragOffsets = [];
|
|
let marqueeActive = false;
|
|
let marqueeStart = { x: 0, y: 0 };
|
|
let marqueeEnd = { x: 0, y: 0 };
|
|
|
|
function requestDraw() {
|
|
if (drawPending) return;
|
|
drawPending = true;
|
|
requestAnimationFrame(() => {
|
|
drawPending = false;
|
|
draw();
|
|
});
|
|
}
|
|
|
|
canvas.addEventListener('pointerdown', e => {
|
|
e.preventDefault();
|
|
canvas.setPointerCapture?.(e.pointerId);
|
|
mouseInside = true;
|
|
mousePos = getMousePos(e);
|
|
|
|
if (e.altKey || mode === 'eyedropper') {
|
|
pickColorAt(mousePos.x, mousePos.y);
|
|
if (mode === 'eyedropper') setMode('draw'); // Auto-switch back? or stay? Let's stay for multi-pick, or switch for quick workflow. Let's switch back for now.
|
|
return;
|
|
}
|
|
|
|
if (mode === 'erase') {
|
|
pointerDown = true;
|
|
erasingActive = true;
|
|
eraseChanged = eraseAt(mousePos.x, mousePos.y);
|
|
return;
|
|
}
|
|
|
|
if (mode === 'garland') {
|
|
pointerDown = true;
|
|
garlandPath = [{ ...mousePos }];
|
|
requestDraw();
|
|
return;
|
|
}
|
|
|
|
if (mode === 'select') {
|
|
pointerDown = true;
|
|
const clickedIdx = findBalloonIndexAt(mousePos.x, mousePos.y);
|
|
if (clickedIdx !== -1) {
|
|
const b = balloons[clickedIdx];
|
|
if (e.shiftKey) {
|
|
if (selectedIds.has(b.id)) selectedIds.delete(b.id);
|
|
else selectedIds.add(b.id);
|
|
} else if (!selectedIds.has(b.id)) {
|
|
selectedIds.clear();
|
|
selectedIds.add(b.id);
|
|
}
|
|
updateSelectButtons();
|
|
draw();
|
|
isDragging = true;
|
|
dragStartPos = { ...mousePos };
|
|
dragOffsets = selectionBalloons().map(bb => ({ id: bb.id, dx: bb.x - mousePos.x, dy: bb.y - mousePos.y }));
|
|
dragMoved = false;
|
|
} else {
|
|
if (!e.shiftKey) selectedIds.clear();
|
|
updateSelectButtons();
|
|
marqueeActive = true;
|
|
marqueeStart = { ...mousePos };
|
|
marqueeEnd = { ...mousePos };
|
|
requestDraw();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// draw mode: add
|
|
addBalloon(mousePos.x, mousePos.y);
|
|
pointerDown = true; // track for potential continuous drawing or other gestures?
|
|
}, { passive: false });
|
|
|
|
canvas.addEventListener('pointermove', e => {
|
|
mouseInside = true;
|
|
mousePos = getMousePos(e);
|
|
|
|
if (mode === 'select') {
|
|
if (isDragging && selectedIds.size) {
|
|
const dx = mousePos.x - dragStartPos.x;
|
|
const dy = mousePos.y - dragStartPos.y;
|
|
dragOffsets.forEach(off => {
|
|
const b = balloons.find(bb => bb.id === off.id);
|
|
if (b) {
|
|
b.x = mousePos.x + off.dx;
|
|
b.y = mousePos.y + off.dy;
|
|
}
|
|
});
|
|
requestDraw();
|
|
dragMoved = true;
|
|
} else if (marqueeActive) {
|
|
marqueeEnd = { ...mousePos };
|
|
requestDraw();
|
|
} else {
|
|
const hoverIdx = findBalloonIndexAt(mousePos.x, mousePos.y);
|
|
canvas.style.cursor = (hoverIdx !== -1) ? 'move' : 'default';
|
|
}
|
|
}
|
|
|
|
if (mode === 'garland') {
|
|
if (pointerDown) {
|
|
const last = garlandPath[garlandPath.length - 1];
|
|
if (!last || Math.hypot(mousePos.x - last.x, mousePos.y - last.y) >= GARLAND_POINT_STEP) {
|
|
garlandPath.push({ ...mousePos });
|
|
requestDraw();
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (mode === 'erase') {
|
|
if (pointerDown) {
|
|
eraseChanged = eraseAt(mousePos.x, mousePos.y) || eraseChanged;
|
|
if (eraseChanged) requestDraw();
|
|
} else {
|
|
requestDraw();
|
|
}
|
|
}
|
|
}, { passive: true });
|
|
|
|
canvas.addEventListener('pointerenter', () => {
|
|
mouseInside = true;
|
|
if (mode === 'erase') requestDraw();
|
|
});
|
|
|
|
canvas.addEventListener('pointerup', e => {
|
|
pointerDown = false;
|
|
isDragging = false;
|
|
if (mode === 'garland') {
|
|
if (garlandPath.length > 1) addGarlandFromPath(garlandPath);
|
|
garlandPath = [];
|
|
requestDraw();
|
|
canvas.releasePointerCapture?.(e.pointerId);
|
|
return;
|
|
}
|
|
if (mode === 'select' && dragMoved) {
|
|
refreshAll();
|
|
pushHistory();
|
|
}
|
|
if (mode === 'select' && marqueeActive) {
|
|
const minX = Math.min(marqueeStart.x, marqueeEnd.x);
|
|
const maxX = Math.max(marqueeStart.x, marqueeEnd.x);
|
|
const minY = Math.min(marqueeStart.y, marqueeEnd.y);
|
|
const maxY = Math.max(marqueeStart.y, marqueeEnd.y);
|
|
const ids = balloons.filter(b => b.x >= minX && b.x <= maxX && b.y >= minY && b.y <= maxY).map(b => b.id);
|
|
if (!e.shiftKey) selectedIds.clear();
|
|
ids.forEach(id => selectedIds.add(id));
|
|
marqueeActive = false;
|
|
updateSelectButtons();
|
|
requestDraw();
|
|
}
|
|
if (mode === 'erase' && eraseChanged) {
|
|
refreshAll(); // update palette/persist once after the stroke
|
|
pushHistory();
|
|
}
|
|
erasingActive = false;
|
|
dragMoved = false;
|
|
eraseChanged = false;
|
|
marqueeActive = false;
|
|
canvas.releasePointerCapture?.(e.pointerId);
|
|
}, { passive: true });
|
|
|
|
canvas.addEventListener('pointerleave', () => {
|
|
mouseInside = false;
|
|
marqueeActive = false;
|
|
if (mode === 'garland') {
|
|
pointerDown = false;
|
|
garlandPath = [];
|
|
requestDraw();
|
|
}
|
|
if (mode === 'erase') requestDraw();
|
|
}, { passive: true });
|
|
|
|
// ====== Canvas & Drawing ======
|
|
function resizeCanvas() {
|
|
const rect = canvas.getBoundingClientRect();
|
|
dpr = Math.max(1, window.devicePixelRatio || 1);
|
|
canvas.width = Math.round(rect.width * dpr);
|
|
canvas.height = Math.round(rect.height * dpr);
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
fitView();
|
|
draw();
|
|
}
|
|
function clearCanvasArea() {
|
|
ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr);
|
|
}
|
|
|
|
function draw() {
|
|
clearCanvasArea();
|
|
ctx.save();
|
|
ctx.scale(view.s, view.s);
|
|
ctx.translate(view.tx, view.ty);
|
|
|
|
balloons.forEach(b => {
|
|
if (b.image) {
|
|
const img = getImage(b.image);
|
|
if (img && img.complete && img.naturalWidth > 0) {
|
|
const meta = FLAT_COLORS[b.colorIdx] || {};
|
|
const zoom = Math.max(1, meta.imageZoom ?? TEXTURE_ZOOM_DEFAULT);
|
|
const fx = clamp01(meta.imageFocus?.x ?? TEXTURE_FOCUS_DEFAULT.x);
|
|
const fy = clamp01(meta.imageFocus?.y ?? TEXTURE_FOCUS_DEFAULT.y);
|
|
|
|
const srcW = img.naturalWidth / zoom;
|
|
const srcH = img.naturalHeight / zoom;
|
|
const srcX = clamp(fx * img.naturalWidth - srcW/2, 0, img.naturalWidth - srcW);
|
|
const srcY = clamp(fy * img.naturalHeight - srcH/2, 0, img.naturalHeight - srcH);
|
|
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.arc(b.x, b.y, b.radius, 0, Math.PI * 2);
|
|
ctx.clip();
|
|
const lum = luminance(meta.hex || b.color);
|
|
if (lum > 0.6) {
|
|
const strength = clamp01((lum - 0.6) / 0.4); // more shadow for lighter colors
|
|
ctx.shadowColor = `rgba(0,0,0,${0.05 + 0.07 * strength})`;
|
|
ctx.shadowBlur = 4 + 4 * strength;
|
|
ctx.shadowOffsetY = 1 + 2 * strength;
|
|
}
|
|
ctx.drawImage(img, srcX, srcY, srcW, srcH, b.x - b.radius, b.y - b.radius, b.radius * 2, b.radius * 2);
|
|
if (isBorderEnabled) {
|
|
ctx.strokeStyle = '#111827';
|
|
ctx.lineWidth = Math.max(0.35, 0.5 / view.s);
|
|
ctx.stroke();
|
|
}
|
|
ctx.restore();
|
|
} else {
|
|
// fallback solid
|
|
ctx.beginPath();
|
|
ctx.arc(b.x, b.y, b.radius, 0, Math.PI * 2);
|
|
ctx.fillStyle = b.color;
|
|
ctx.shadowColor = 'rgba(0,0,0,0.2)';
|
|
ctx.shadowBlur = 10;
|
|
ctx.fill();
|
|
if (isBorderEnabled) {
|
|
ctx.strokeStyle = '#111827';
|
|
ctx.lineWidth = Math.max(0.35, 0.5 / view.s);
|
|
ctx.stroke();
|
|
}
|
|
ctx.shadowBlur = 0;
|
|
}
|
|
} else {
|
|
// solid fill
|
|
ctx.beginPath();
|
|
ctx.arc(b.x, b.y, b.radius, 0, Math.PI * 2);
|
|
ctx.fillStyle = b.color;
|
|
ctx.shadowColor = 'rgba(0,0,0,0.2)';
|
|
ctx.shadowBlur = 10;
|
|
ctx.fill();
|
|
if (isBorderEnabled) {
|
|
ctx.strokeStyle = '#111827';
|
|
ctx.lineWidth = Math.max(0.35, 0.5 / view.s);
|
|
ctx.stroke();
|
|
}
|
|
ctx.shadowBlur = 0;
|
|
}
|
|
|
|
if (isShineEnabled) {
|
|
const { fill: shineFill, stroke: shineStroke } = shineStyle(b.color);
|
|
const sx = b.x - b.radius * SHINE_OFFSET;
|
|
const sy = b.y - b.radius * SHINE_OFFSET;
|
|
const rx = b.radius * SHINE_RX;
|
|
const ry = b.radius * SHINE_RY;
|
|
const rotRad = SHINE_ROT * Math.PI / 180;
|
|
ctx.save();
|
|
ctx.shadowColor = 'rgba(0,0,0,0.1)';
|
|
ctx.shadowBlur = 3; // SHINE_BLUR
|
|
ctx.beginPath();
|
|
if (ctx.ellipse) {
|
|
ctx.ellipse(sx, sy, rx, ry, rotRad, 0, Math.PI * 2);
|
|
} else {
|
|
ctx.translate(sx, sy);
|
|
ctx.rotate(rotRad);
|
|
ctx.scale(rx / ry, 1);
|
|
ctx.arc(0, 0, ry, 0, Math.PI * 2);
|
|
}
|
|
ctx.fillStyle = shineFill;
|
|
if (shineStroke) {
|
|
ctx.strokeStyle = shineStroke;
|
|
ctx.lineWidth = 1.5;
|
|
ctx.stroke();
|
|
}
|
|
ctx.fill();
|
|
ctx.restore();
|
|
}
|
|
});
|
|
|
|
// garland path preview
|
|
if (mode === 'garland' && garlandPath.length) {
|
|
ctx.save();
|
|
ctx.lineWidth = 1.5 / view.s;
|
|
ctx.strokeStyle = 'rgba(59,130,246,0.7)';
|
|
ctx.setLineDash([8 / view.s, 6 / view.s]);
|
|
ctx.beginPath();
|
|
garlandPath.forEach((p, idx) => {
|
|
if (idx === 0) ctx.moveTo(p.x, p.y);
|
|
else ctx.lineTo(p.x, p.y);
|
|
});
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
const previewNodes = computeGarlandNodes(garlandPath).sort((a, b) => b.radius - a.radius);
|
|
ctx.strokeStyle = 'rgba(59,130,246,0.28)';
|
|
previewNodes.forEach(n => {
|
|
ctx.beginPath();
|
|
ctx.arc(n.x, n.y, n.radius, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
});
|
|
ctx.restore();
|
|
}
|
|
|
|
// selection ring(s)
|
|
if (selectedIds.size) {
|
|
ctx.save();
|
|
selectedIds.forEach(id => {
|
|
const b = balloons.find(bb => bb.id === id);
|
|
if (!b) return;
|
|
ctx.beginPath();
|
|
ctx.arc(b.x, b.y, b.radius + 3, 0, Math.PI * 2);
|
|
ctx.lineWidth = 4 / view.s;
|
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
|
|
ctx.stroke();
|
|
ctx.lineWidth = 2 / view.s;
|
|
ctx.strokeStyle = '#3b82f6';
|
|
ctx.stroke();
|
|
});
|
|
ctx.restore();
|
|
}
|
|
|
|
// marquee preview
|
|
if (mode === 'select' && marqueeActive) {
|
|
ctx.save();
|
|
ctx.setLineDash([6 / view.s, 4 / view.s]);
|
|
ctx.lineWidth = 1.5 / view.s;
|
|
ctx.strokeStyle = 'rgba(59,130,246,0.8)';
|
|
ctx.fillStyle = 'rgba(59,130,246,0.12)';
|
|
const x = Math.min(marqueeStart.x, marqueeEnd.x);
|
|
const y = Math.min(marqueeStart.y, marqueeEnd.y);
|
|
const w = Math.abs(marqueeStart.x - marqueeEnd.x);
|
|
const h = Math.abs(marqueeStart.y - marqueeEnd.y);
|
|
ctx.strokeRect(x, y, w, h);
|
|
ctx.fillRect(x, y, w, h);
|
|
ctx.restore();
|
|
}
|
|
|
|
// eraser preview
|
|
if (mode === 'erase' && mouseInside) {
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.arc(mousePos.x, mousePos.y, eraserRadius, 0, Math.PI * 2);
|
|
ctx.lineWidth = 1.5 / view.s;
|
|
ctx.strokeStyle = 'rgba(31,41,55,0.8)';
|
|
ctx.setLineDash([4 / view.s, 4 / view.s]);
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
|
|
// eyedropper preview
|
|
if (mode === 'eyedropper' && mouseInside) {
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.arc(mousePos.x, mousePos.y, 10 / view.s, 0, Math.PI * 2);
|
|
ctx.lineWidth = 2 / view.s;
|
|
ctx.strokeStyle = '#fff';
|
|
ctx.stroke();
|
|
ctx.lineWidth = 1 / view.s;
|
|
ctx.strokeStyle = '#000';
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
new ResizeObserver(() => resizeCanvas()).observe(canvas.parentElement);
|
|
canvas.style.touchAction = 'none';
|
|
|
|
// ====== State Persistence ======
|
|
const APP_STATE_KEY = 'obd:state:v3';
|
|
|
|
function saveAppState() {
|
|
// Note: isShineEnabled is managed globally.
|
|
const state = {
|
|
balloons,
|
|
selectedColorIdx,
|
|
currentDiameterInches,
|
|
eraserRadius,
|
|
view,
|
|
usedSortDesc,
|
|
garlandDensity,
|
|
garlandMainIdx,
|
|
garlandAccentIdx,
|
|
isBorderEnabled
|
|
};
|
|
try { localStorage.setItem(APP_STATE_KEY, JSON.stringify(state)); } catch {}
|
|
}
|
|
const persist = (() => { let t; return () => { clearTimeout(t); t = setTimeout(saveAppState, 120); }; })();
|
|
|
|
function loadAppState() {
|
|
try {
|
|
const s = JSON.parse(localStorage.getItem(APP_STATE_KEY) || '{}');
|
|
if (Array.isArray(s.balloons)) balloons = s.balloons;
|
|
if (typeof s.selectedColorIdx === 'number') selectedColorIdx = s.selectedColorIdx;
|
|
if (typeof s.currentDiameterInches === 'number') {
|
|
currentDiameterInches = s.currentDiameterInches;
|
|
currentRadius = inchesToRadiusPx(currentDiameterInches);
|
|
}
|
|
if (typeof s.eraserRadius === 'number') {
|
|
eraserRadius = s.eraserRadius;
|
|
if (eraserSizeInput) eraserSizeInput.value = eraserRadius;
|
|
if (eraserSizeLabel) eraserSizeLabel.textContent = eraserRadius;
|
|
}
|
|
if (s.view && typeof s.view.s === 'number') { view = s.view; clampViewScale(); }
|
|
if (typeof s.usedSortDesc === 'boolean') {
|
|
usedSortDesc = s.usedSortDesc;
|
|
if (sortUsedToggle) sortUsedToggle.textContent = usedSortDesc ? 'Sort: Most → Least' : 'Sort: Least → Most';
|
|
}
|
|
if (typeof s.garlandDensity === 'number') {
|
|
garlandDensity = clamp(s.garlandDensity, 0.6, 1.6);
|
|
if (garlandDensityInput) garlandDensityInput.value = garlandDensity;
|
|
if (garlandDensityLabel) garlandDensityLabel.textContent = garlandDensity.toFixed(1);
|
|
}
|
|
if (Array.isArray(s.garlandMainIdx)) {
|
|
garlandMainIdx = s.garlandMainIdx.slice(0, 4).map(v => Number(v) || -1);
|
|
while (garlandMainIdx.length < 4) garlandMainIdx.push(-1);
|
|
}
|
|
if (typeof s.garlandAccentIdx === 'number') garlandAccentIdx = s.garlandAccentIdx;
|
|
if (typeof s.isBorderEnabled === 'boolean') isBorderEnabled = s.isBorderEnabled;
|
|
if (toggleBorderCheckbox) toggleBorderCheckbox.checked = isBorderEnabled;
|
|
updateCurrentColorChip();
|
|
} catch {}
|
|
}
|
|
|
|
loadAppState();
|
|
resetHistory(); // establish initial history state for undo/redo controls
|
|
|
|
// ====== UI Rendering (Palettes) ======
|
|
function renderAllowedPalette() {
|
|
if (!paletteBox) return;
|
|
paletteBox.innerHTML = '';
|
|
(window.PALETTE || []).forEach(group => {
|
|
const title = document.createElement('div');
|
|
title.className = 'family-title';
|
|
title.textContent = group.family;
|
|
paletteBox.appendChild(title);
|
|
|
|
const row = document.createElement('div');
|
|
row.className = 'swatch-row';
|
|
(group.colors || []).forEach(c => {
|
|
const idx =
|
|
FLAT_COLORS.find(fc => fc.name === c.name && fc.hex === c.hex && fc.family === group.family)?._idx
|
|
?? HEX_TO_FIRST_IDX.get(normalizeHex(c.hex));
|
|
const sw = document.createElement('button');
|
|
sw.type = 'button';
|
|
sw.className = 'swatch';
|
|
sw.setAttribute('aria-label', c.name);
|
|
|
|
if (c.image) {
|
|
const meta = FLAT_COLORS[idx] || {};
|
|
sw.style.backgroundImage = `url("${c.image}")`;
|
|
sw.style.backgroundSize = `${100 * SWATCH_TEXTURE_ZOOM}%`;
|
|
sw.style.backgroundPosition = `${(meta.imageFocus?.x ?? 0.5) * 100}% ${(meta.imageFocus?.y ?? 0.5) * 100}%`;
|
|
} else {
|
|
sw.style.backgroundColor = c.hex;
|
|
}
|
|
|
|
if (idx === selectedColorIdx) sw.classList.add('active');
|
|
sw.title = c.name;
|
|
|
|
sw.addEventListener('click', () => {
|
|
selectedColorIdx = idx ?? 0;
|
|
renderAllowedPalette();
|
|
updateCurrentColorChip();
|
|
persist();
|
|
});
|
|
row.appendChild(sw);
|
|
});
|
|
paletteBox.appendChild(row);
|
|
});
|
|
}
|
|
|
|
function getUsedColors() {
|
|
const map = new Map();
|
|
balloons.forEach(b => {
|
|
const key = normalizeHex(b.color);
|
|
if (!allowedSet.has(key)) return;
|
|
if (!map.has(key)) {
|
|
const meta = FLAT_COLORS[HEX_TO_FIRST_IDX.get(key)] || {};
|
|
map.set(key, { hex: key, count: 0, image: meta.image, name: meta.name });
|
|
}
|
|
map.get(key).count++;
|
|
});
|
|
const arr = [...map.values()];
|
|
arr.sort((a, b) => (usedSortDesc ? (b.count - a.count) : (a.count - b.count)));
|
|
return arr;
|
|
}
|
|
|
|
function updateCurrentColorChip() {
|
|
const meta = FLAT_COLORS[selectedColorIdx] || FLAT_COLORS[0];
|
|
const updateChip = (chipId, labelId, { showLabel = true } = {}) => {
|
|
const chip = document.getElementById(chipId);
|
|
const label = document.getElementById(labelId);
|
|
if (!chip || !meta) return;
|
|
if (meta.image) {
|
|
const fx = clamp01(meta.imageFocus?.x ?? TEXTURE_FOCUS_DEFAULT.x);
|
|
const fy = clamp01(meta.imageFocus?.y ?? TEXTURE_FOCUS_DEFAULT.y);
|
|
chip.style.backgroundImage = `url("${meta.image}")`;
|
|
chip.style.backgroundSize = `${100 * SWATCH_TEXTURE_ZOOM}%`;
|
|
chip.style.backgroundPosition = `${fx * 100}% ${fy * 100}%`;
|
|
chip.style.backgroundColor = '#fff';
|
|
} else {
|
|
chip.style.backgroundImage = 'none';
|
|
chip.style.backgroundColor = meta.hex || '#fff';
|
|
}
|
|
if (label) {
|
|
label.textContent = showLabel ? (meta.name || meta.hex || 'Current') : '';
|
|
label.title = meta.name || meta.hex || 'Current';
|
|
}
|
|
chip.title = meta.name || meta.hex || 'Current';
|
|
};
|
|
updateChip('current-color-chip', 'current-color-label', { showLabel: true });
|
|
updateChip('current-color-chip-global', 'current-color-label-global', { showLabel: false });
|
|
}
|
|
|
|
function renderUsedPalette() {
|
|
if (!usedPaletteBox) return;
|
|
usedPaletteBox.innerHTML = '';
|
|
const used = getUsedColors();
|
|
if (used.length === 0) {
|
|
usedPaletteBox.innerHTML = '<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 buildBalloon(meta, x, y, radius) {
|
|
return {
|
|
x, y,
|
|
radius,
|
|
color: meta.hex,
|
|
image: meta.image || null,
|
|
colorIdx: meta._idx,
|
|
id: crypto.randomUUID()
|
|
};
|
|
}
|
|
|
|
function addBalloon(x, y) {
|
|
const meta = FLAT_COLORS[selectedColorIdx] || FLAT_COLORS[0];
|
|
if (balloons.length >= MAX_BALLOONS) { showModal(`Balloon limit reached (${MAX_BALLOONS}). Delete some to add more.`); return; }
|
|
balloons.push(buildBalloon(meta, x, y, currentRadius));
|
|
ensureVisibleAfterAdd(balloons[balloons.length - 1]);
|
|
refreshAll();
|
|
pushHistory();
|
|
}
|
|
|
|
function garlandSeed(path) {
|
|
let h = 2166136261 >>> 0;
|
|
path.forEach(p => {
|
|
h ^= Math.round(p.x * 10) + 0x9e3779b9;
|
|
h = Math.imul(h, 16777619);
|
|
h ^= Math.round(p.y * 10);
|
|
h = Math.imul(h, 16777619);
|
|
});
|
|
return h >>> 0 || 1;
|
|
}
|
|
|
|
function computeGarlandNodes(path) {
|
|
if (!Array.isArray(path) || path.length < 2) return [];
|
|
const baseRadius = inchesToRadiusPx(GARLAND_BASE_DIAM);
|
|
const fillerRadii = GARLAND_FILLER_DIAMS.map(inchesToRadiusPx);
|
|
const accentRadius = inchesToRadiusPx(GARLAND_ACCENT_DIAM);
|
|
const spacing = Math.max(10, baseRadius * (GARLAND_SPACING_RATIO / garlandDensity));
|
|
const nodes = [];
|
|
let carry = 0;
|
|
const rng = makeSeededRng(garlandSeed(path));
|
|
|
|
for (let i = 0; i < path.length - 1; i++) {
|
|
const a = path[i];
|
|
const b = path[i + 1];
|
|
const dx = b.x - a.x;
|
|
const dy = b.y - a.y;
|
|
const segLen = Math.hypot(dx, dy);
|
|
if (segLen < 1) continue;
|
|
|
|
let dist = carry;
|
|
const nx = segLen > 0 ? (dy / segLen) : 0;
|
|
const ny = segLen > 0 ? (-dx / segLen) : 0;
|
|
|
|
while (dist <= segLen) {
|
|
const t = dist / segLen;
|
|
const px = a.x + dx * t;
|
|
const py = a.y + dy * t;
|
|
|
|
const side = rng() > 0.5 ? 1 : -1;
|
|
const wobble = (rng() * 2 - 1) * baseRadius * GARLAND_WOBBLE_RATIO;
|
|
const sizeJitter = 1 + (rng() * 2 - 1) * GARLAND_SIZE_JITTER;
|
|
const r = clamp(baseRadius * sizeJitter, baseRadius * 0.75, baseRadius * 1.35);
|
|
const baseX = px + nx * wobble;
|
|
const baseY = py + ny * wobble;
|
|
nodes.push({ x: baseX, y: baseY, radius: r, type: 'base' });
|
|
|
|
// filler balloons hugging the base to thicken the line
|
|
const fillerR = fillerRadii[Math.floor(rng() * fillerRadii.length)] || baseRadius * 0.7;
|
|
const offset1 = r * 0.7;
|
|
nodes.push({
|
|
x: baseX + nx * offset1 * side,
|
|
y: baseY + ny * offset1 * side,
|
|
radius: fillerR * (0.9 + rng() * 0.2),
|
|
type: 'filler'
|
|
});
|
|
|
|
const tangentSide = side * (rng() > 0.5 ? 1 : -1);
|
|
const offset2 = r * 0.5;
|
|
nodes.push({
|
|
x: baseX + (-ny) * offset2 * tangentSide,
|
|
y: baseY + (nx) * offset2 * tangentSide,
|
|
radius: fillerR * (0.8 + rng() * 0.25),
|
|
type: 'filler'
|
|
});
|
|
|
|
// Tight cluster of three 5" balloons, slightly more open
|
|
{
|
|
const clusterCenterX = baseX + nx * r * 0.25 * side;
|
|
const clusterCenterY = baseY + ny * r * 0.25 * side;
|
|
const baseAng = rng() * Math.PI * 2;
|
|
const mags = [0.7, 0.95, 0.8].map(m => m * accentRadius);
|
|
const angs = [baseAng, baseAng + (2 * Math.PI / 3), baseAng + (4 * Math.PI / 3)];
|
|
for (let c = 0; c < 3; c++) {
|
|
const mag = mags[c] * (0.98 + rng() * 0.08);
|
|
const jitterAng = angs[c] + (rng() * 0.25 - 0.125);
|
|
nodes.push({
|
|
x: clusterCenterX + Math.cos(jitterAng) * mag,
|
|
y: clusterCenterY + Math.sin(jitterAng) * mag,
|
|
radius: accentRadius * (0.88 + rng() * 0.15),
|
|
type: 'accent'
|
|
});
|
|
}
|
|
}
|
|
|
|
dist += spacing;
|
|
}
|
|
carry = dist - segLen;
|
|
}
|
|
|
|
return nodes;
|
|
}
|
|
|
|
function addGarlandFromPath(path) {
|
|
const meta = FLAT_COLORS[selectedColorIdx] || FLAT_COLORS[0];
|
|
if (!meta) return;
|
|
const nodes = computeGarlandNodes(path).sort((a, b) => b.radius - a.radius); // draw larger first so small accents sit on top
|
|
if (!nodes.length) return;
|
|
const available = Math.max(0, MAX_BALLOONS - balloons.length);
|
|
const limitedNodes = available ? nodes.slice(0, available) : [];
|
|
if (!limitedNodes.length) { showModal(`Balloon limit reached (${MAX_BALLOONS}). Delete some to add more.`); return; }
|
|
const newIds = [];
|
|
const rng = makeSeededRng(garlandSeed(path) + 101);
|
|
const metaFromIdx = idx => {
|
|
const m = FLAT_COLORS[idx];
|
|
return m ? m : meta;
|
|
};
|
|
const pickMainMeta = () => {
|
|
const choices = garlandMainIdx.filter(v => Number.isFinite(v) && v >= 0 && FLAT_COLORS[v]);
|
|
if (!choices.length) return meta;
|
|
const pick = choices.length === 1 ? choices[0] : choices[Math.floor(rng() * choices.length)];
|
|
return metaFromIdx(pick);
|
|
};
|
|
const accentMeta = (garlandAccentIdx >= 0 && FLAT_COLORS[garlandAccentIdx])
|
|
? FLAT_COLORS[garlandAccentIdx]
|
|
: metaFromIdx(garlandMainIdx.find(v => v >= 0));
|
|
|
|
limitedNodes.forEach(n => {
|
|
const m = n.type === 'accent' ? accentMeta : pickMainMeta();
|
|
const b = buildBalloon(m, n.x, n.y, n.radius);
|
|
balloons.push(b);
|
|
newIds.push(b.id);
|
|
});
|
|
if (newIds.length) {
|
|
selectedIds.clear();
|
|
updateSelectButtons();
|
|
}
|
|
refreshAll();
|
|
pushHistory();
|
|
}
|
|
|
|
function findBalloonIndexAt(x, y) {
|
|
for (let i = balloons.length - 1; i >= 0; i--) {
|
|
const b = balloons[i];
|
|
if (Math.hypot(x - b.x, y - b.y) <= b.radius) return i;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
function selectAt(x, y) {
|
|
const i = findBalloonIndexAt(x, y);
|
|
selectedIds.clear();
|
|
if (i !== -1) selectedIds.add(balloons[i].id);
|
|
updateSelectButtons();
|
|
draw();
|
|
}
|
|
|
|
function moveSelected(dx, dy) {
|
|
const sel = selectionBalloons();
|
|
if (!sel.length) return;
|
|
sel.forEach(b => { b.x += dx; b.y += dy; });
|
|
refreshAll();
|
|
pushHistory();
|
|
}
|
|
|
|
function resizeSelected(newDiamInches) {
|
|
const sel = selectionBalloons();
|
|
if (!sel.length) return;
|
|
const diam = clamp(newDiamInches, 5, 32);
|
|
const newRadius = inchesToRadiusPx(diam);
|
|
sel.forEach(b => { b.radius = newRadius; });
|
|
refreshAll();
|
|
updateSelectButtons();
|
|
resizeChanged = true;
|
|
clearTimeout(resizeSaveTimer);
|
|
resizeSaveTimer = setTimeout(() => {
|
|
if (resizeChanged) {
|
|
pushHistory();
|
|
resizeChanged = false;
|
|
}
|
|
}, 200);
|
|
}
|
|
|
|
function bringSelectedForward() {
|
|
const sel = selectionArray();
|
|
if (!sel.length) return;
|
|
const set = new Set(sel);
|
|
const kept = balloons.filter(b => !set.has(b.id));
|
|
const moving = balloons.filter(b => set.has(b.id));
|
|
balloons = kept.concat(moving);
|
|
refreshAll({ autoFit: true });
|
|
pushHistory();
|
|
}
|
|
|
|
function sendSelectedBackward() {
|
|
const sel = selectionArray();
|
|
if (!sel.length) return;
|
|
const set = new Set(sel);
|
|
const moving = balloons.filter(b => set.has(b.id));
|
|
const kept = balloons.filter(b => !set.has(b.id));
|
|
balloons = moving.concat(kept);
|
|
refreshAll({ autoFit: true });
|
|
pushHistory();
|
|
}
|
|
|
|
function applyColorToSelected() {
|
|
const meta = FLAT_COLORS[selectedColorIdx] || FLAT_COLORS[0];
|
|
if (!meta) return;
|
|
let changed = false;
|
|
selectionBalloons().forEach(b => {
|
|
b.color = meta.hex;
|
|
b.image = meta.image || null;
|
|
b.colorIdx = meta._idx;
|
|
changed = true;
|
|
});
|
|
if (!changed) return;
|
|
refreshAll();
|
|
pushHistory();
|
|
}
|
|
|
|
function deleteSelected() {
|
|
if (!selectedIds.size) return;
|
|
balloons = balloons.filter(b => !selectedIds.has(b.id));
|
|
selectedIds.clear();
|
|
updateSelectButtons();
|
|
refreshAll({ autoFit: true });
|
|
pushHistory();
|
|
}
|
|
|
|
function duplicateSelected() {
|
|
const sel = selectionBalloons();
|
|
if (!sel.length) return;
|
|
const copies = sel.map(b => ({ ...b, x: b.x + 10, y: b.y + 10, id: crypto.randomUUID() }));
|
|
copies.forEach(c => balloons.push(c));
|
|
selectedIds = new Set(copies.map(c => c.id));
|
|
refreshAll({ autoFit: true });
|
|
updateSelectButtons();
|
|
pushHistory();
|
|
}
|
|
|
|
function eraseAt(x, y) {
|
|
const before = balloons.length;
|
|
balloons = balloons.filter(b => Math.hypot(x - b.x, y - b.y) > eraserRadius);
|
|
const removed = balloons.length !== before;
|
|
if (selectedIds.size) {
|
|
const set = new Set(balloons.map(b => b.id));
|
|
let changed = false;
|
|
selectedIds.forEach(id => { if (!set.has(id)) { selectedIds.delete(id); changed = true; } });
|
|
if (changed) updateSelectButtons();
|
|
}
|
|
if (removed && !erasingActive) requestDraw();
|
|
return removed;
|
|
}
|
|
|
|
function pickColorAt(x, y) {
|
|
const i = findBalloonIndexAt(x, y);
|
|
if (i !== -1) {
|
|
selectedColorIdx = HEX_TO_FIRST_IDX.get(normalizeHex(balloons[i].color)) ?? 0;
|
|
renderAllowedPalette();
|
|
renderUsedPalette();
|
|
updateCurrentColorChip();
|
|
}
|
|
}
|
|
|
|
function promptForFilename(suggested) {
|
|
const m = suggested.match(/\.([a-z0-9]+)$/i);
|
|
const ext = m ? m[1].toLowerCase() : '';
|
|
const defaultBase = suggested.replace(/\.[^.]+$/, '');
|
|
const lsKey = ext ? `lastFilenameBase.${ext}` : `lastFilenameBase`;
|
|
const last = localStorage.getItem(lsKey) || defaultBase;
|
|
|
|
const input = window.prompt(ext ? `File name (.${ext} will be added)` : 'File name', last);
|
|
if (input === null) return null;
|
|
|
|
let base = (input.trim() || defaultBase)
|
|
.replace(/[<>:"/\\|?*\x00-\x1F]/g, '')
|
|
.replace(/\.+$/, '')
|
|
.replace(/\.[^.]+$/, '');
|
|
try { localStorage.setItem(lsKey, base); } catch {}
|
|
|
|
return ext ? `${base}.${ext}` : base;
|
|
}
|
|
|
|
function download(href, suggestedFilename) {
|
|
const finalName = promptForFilename(suggestedFilename);
|
|
if (!finalName) return;
|
|
const a = document.createElement('a');
|
|
a.href = href;
|
|
a.download = finalName;
|
|
a.click();
|
|
a.remove();
|
|
}
|
|
|
|
function saveJson() {
|
|
download('data:text/json;charset=utf-8,' + encodeURIComponent(JSON.stringify({ balloons })), 'balloon_design.json');
|
|
}
|
|
|
|
function loadJson(e) {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
const reader = new FileReader();
|
|
reader.onload = ev => {
|
|
try {
|
|
const data = JSON.parse(ev.target.result);
|
|
const loaded = Array.isArray(data.balloons)
|
|
? data.balloons.map(b => {
|
|
const idx = b.colorIdx ?? (HEX_TO_FIRST_IDX.get(normalizeHex(b.color)) ?? 0);
|
|
const meta = FLAT_COLORS[idx] || {};
|
|
return {
|
|
x: b.x, y: b.y, radius: b.radius,
|
|
color: meta.hex || b.color,
|
|
image: meta.image || null,
|
|
colorIdx: idx,
|
|
id: crypto.randomUUID()
|
|
};
|
|
})
|
|
: [];
|
|
balloons = loaded.slice(0, MAX_BALLOONS);
|
|
selectedIds.clear();
|
|
updateSelectButtons();
|
|
refreshAll({ refit: true });
|
|
resetHistory();
|
|
persist();
|
|
if (loaded.length > MAX_BALLOONS) {
|
|
showModal(`Design loaded (trimmed to ${MAX_BALLOONS} balloons).`);
|
|
}
|
|
} catch {
|
|
showModal('Error parsing JSON file.');
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
}
|
|
|
|
// ====== Export helpers ======
|
|
const DATA_URL_CACHE = new Map();
|
|
const XLINK_NS = 'http://www.w3.org/1999/xlink';
|
|
let lastActiveTab = '#tab-organic';
|
|
|
|
function getImageHref(el) {
|
|
return el.getAttribute('href') || el.getAttributeNS(XLINK_NS, 'href');
|
|
}
|
|
function setImageHref(el, val) {
|
|
el.setAttribute('href', val);
|
|
el.setAttributeNS(XLINK_NS, 'xlink:href', val);
|
|
}
|
|
const blobToDataUrl = blob => new Promise((resolve, reject) => {
|
|
const r = new FileReader();
|
|
r.onloadend = () => resolve(r.result);
|
|
r.onerror = reject;
|
|
r.readAsDataURL(blob);
|
|
});
|
|
|
|
function imageToDataUrl(img) {
|
|
if (!img || !img.complete || img.naturalWidth === 0) return null;
|
|
try {
|
|
const c = document.createElement('canvas');
|
|
c.width = img.naturalWidth;
|
|
c.height = img.naturalHeight;
|
|
c.getContext('2d').drawImage(img, 0, 0);
|
|
return c.toDataURL('image/png');
|
|
} catch (err) {
|
|
console.warn('[Export] imageToDataUrl failed:', err);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function imageUrlToDataUrl(src) {
|
|
if (!src || src.startsWith('data:')) return src;
|
|
if (DATA_URL_CACHE.has(src)) return DATA_URL_CACHE.get(src);
|
|
const cachedImg = IMG_CACHE.get(src);
|
|
const cachedUrl = imageToDataUrl(cachedImg);
|
|
if (cachedUrl) {
|
|
DATA_URL_CACHE.set(src, cachedUrl);
|
|
return cachedUrl;
|
|
}
|
|
const abs = (() => { try { return new URL(src, window.location.href).href; } catch { return src; } })();
|
|
let dataUrl = null;
|
|
try {
|
|
const resp = await fetch(abs);
|
|
if (!resp.ok) throw new Error(`Status ${resp.status}`);
|
|
dataUrl = await blobToDataUrl(await resp.blob());
|
|
} catch (err) {
|
|
console.warn('[Export] Fetch failed for', abs, err);
|
|
// Fallback: draw to a canvas to capture even when fetch is blocked (e.g., file://)
|
|
dataUrl = await new Promise(resolve => {
|
|
const img = new Image();
|
|
img.crossOrigin = 'anonymous';
|
|
img.onload = () => {
|
|
try {
|
|
const c = document.createElement('canvas');
|
|
c.width = img.naturalWidth || 1;
|
|
c.height = img.naturalHeight || 1;
|
|
c.getContext('2d').drawImage(img, 0, 0);
|
|
resolve(c.toDataURL('image/png'));
|
|
} catch (e) {
|
|
console.error('[Export] Canvas fallback failed for', abs, e);
|
|
resolve(null);
|
|
}
|
|
};
|
|
img.onerror = () => resolve(null);
|
|
img.src = abs;
|
|
});
|
|
}
|
|
if (!dataUrl) dataUrl = abs;
|
|
DATA_URL_CACHE.set(src, dataUrl);
|
|
return dataUrl;
|
|
}
|
|
|
|
async function embedImagesInSvg(svgEl) {
|
|
const images = Array.from(svgEl.querySelectorAll('image'));
|
|
const hrefs = [...new Set(images.map(getImageHref).filter(h => h && !h.startsWith('data:')))];
|
|
const urlMap = new Map();
|
|
await Promise.all(hrefs.map(async (href) => {
|
|
urlMap.set(href, await imageUrlToDataUrl(href));
|
|
}));
|
|
images.forEach(img => {
|
|
const orig = getImageHref(img);
|
|
const val = urlMap.get(orig);
|
|
if (val) setImageHref(img, val);
|
|
});
|
|
return svgEl;
|
|
}
|
|
|
|
async function buildOrganicSvgPayload() {
|
|
if (balloons.length === 0) throw new Error('Canvas is empty. Add some balloons first.');
|
|
|
|
const uniqueImageUrls = [...new Set(balloons.map(b => b.image).filter(Boolean))];
|
|
const dataUrlMap = new Map();
|
|
await Promise.all(uniqueImageUrls.map(async (url) => dataUrlMap.set(url, await imageUrlToDataUrl(url))));
|
|
|
|
const bounds = balloonsBounds();
|
|
const pad = 20;
|
|
const width = bounds.w + pad * 2;
|
|
const height = bounds.h + pad * 2;
|
|
const vb = [bounds.minX - pad, bounds.minY - pad, width, height].join(' ');
|
|
|
|
let defs = '';
|
|
let elements = '';
|
|
const patterns = new Map();
|
|
|
|
balloons.forEach(b => {
|
|
let fill = b.color;
|
|
if (b.image) {
|
|
const patternKey = `${b.colorIdx}|${b.image}`;
|
|
if (!patterns.has(patternKey)) {
|
|
const patternId = `p${patterns.size}`;
|
|
patterns.set(patternKey, patternId);
|
|
const meta = FLAT_COLORS[b.colorIdx] || {};
|
|
const zoom = Math.max(1, meta.imageZoom ?? TEXTURE_ZOOM_DEFAULT);
|
|
const fx = clamp01(meta.imageFocus?.x ?? TEXTURE_FOCUS_DEFAULT.x);
|
|
const fy = clamp01(meta.imageFocus?.y ?? TEXTURE_FOCUS_DEFAULT.y);
|
|
const imgW = zoom, imgH = zoom;
|
|
const imgX = 0.5 - (fx * zoom);
|
|
const imgY = 0.5 - (fy * zoom);
|
|
const imageHref = dataUrlMap.get(b.image) || b.image;
|
|
defs += `<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)})`;
|
|
}
|
|
const strokeAttr = isBorderEnabled ? ` stroke="#111827" stroke-width="0.5"` : ` stroke="none" stroke-width="0"`;
|
|
elements += `<circle cx="${b.x}" cy="${b.y}" r="${b.radius}" fill="${fill}"${strokeAttr} />`;
|
|
|
|
if (isShineEnabled) {
|
|
const sx = b.x - b.radius * SHINE_OFFSET;
|
|
const sy = b.y - b.radius * SHINE_OFFSET;
|
|
const rx = b.radius * SHINE_RX;
|
|
const ry = b.radius * SHINE_RY;
|
|
const { fill: shineFill, stroke: shineStroke } = shineStyle(b.color);
|
|
const stroke = shineStroke ? ` stroke="${shineStroke}" stroke-width="1"` : '';
|
|
elements += `<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);
|
|
let bbox = null;
|
|
try {
|
|
const temp = clonedSvg.cloneNode(true);
|
|
temp.style.position = 'absolute';
|
|
temp.style.left = '-99999px';
|
|
temp.style.top = '-99999px';
|
|
temp.style.width = '0';
|
|
temp.style.height = '0';
|
|
document.body.appendChild(temp);
|
|
const target = temp.querySelector('g') || temp;
|
|
bbox = target.getBBox();
|
|
temp.remove();
|
|
} catch {}
|
|
|
|
// Inline pattern images and any other <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);
|
|
let vbX = isFinite(viewBox[0]) ? viewBox[0] : 0;
|
|
let vbY = isFinite(viewBox[1]) ? viewBox[1] : 0;
|
|
let vbW = isFinite(viewBox[2]) ? viewBox[2] : (svgElement.clientWidth || 1000);
|
|
let vbH = isFinite(viewBox[3]) ? viewBox[3] : (svgElement.clientHeight || 1000);
|
|
if (bbox && isFinite(bbox.x) && isFinite(bbox.y) && isFinite(bbox.width) && isFinite(bbox.height)) {
|
|
const pad = 10;
|
|
vbX = bbox.x - pad;
|
|
vbY = bbox.y - pad;
|
|
vbW = Math.max(1, bbox.width + pad * 2);
|
|
vbH = Math.max(1, bbox.height + pad * 2);
|
|
}
|
|
clonedSvg.setAttribute('width', vbW);
|
|
clonedSvg.setAttribute('height', vbH);
|
|
if (!clonedSvg.getAttribute('xmlns')) clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
|
if (!clonedSvg.getAttribute('xmlns:xlink')) clonedSvg.setAttribute('xmlns:xlink', XLINK_NS);
|
|
|
|
// Some viewers ignore external styles; bake key style attributes directly
|
|
clonedSvg.querySelectorAll('g.balloon, path.balloon, ellipse.balloon, circle.balloon').forEach(el => {
|
|
if (!el.getAttribute('stroke')) el.setAttribute('stroke', isBorderEnabled ? '#111827' : 'none');
|
|
if (!el.getAttribute('stroke-width')) el.setAttribute('stroke-width', isBorderEnabled ? '1' : '0');
|
|
if (!el.getAttribute('paint-order')) el.setAttribute('paint-order', 'stroke fill');
|
|
if (!el.getAttribute('vector-effect')) el.setAttribute('vector-effect', 'non-scaling-stroke');
|
|
});
|
|
|
|
const svgString = new XMLSerializer().serializeToString(clonedSvg);
|
|
return { svgString, width: vbW, height: vbH, minX: vbX, minY: vbY };
|
|
}
|
|
|
|
async function svgStringToPng(svgString, width, height) {
|
|
const img = new Image();
|
|
const scale = PNG_EXPORT_SCALE;
|
|
const canvasEl = document.createElement('canvas');
|
|
canvasEl.width = Math.max(1, Math.round(width * scale));
|
|
canvasEl.height = Math.max(1, Math.round(height * scale));
|
|
const ctx2 = canvasEl.getContext('2d');
|
|
if (ctx2) {
|
|
ctx2.imageSmoothingEnabled = true;
|
|
ctx2.imageSmoothingQuality = 'high';
|
|
}
|
|
const dataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgString)}`;
|
|
await new Promise((resolve, reject) => {
|
|
img.onload = resolve;
|
|
img.onerror = () => reject(new Error('Could not rasterize SVG.'));
|
|
img.src = dataUrl;
|
|
});
|
|
ctx2.drawImage(img, 0, 0, canvasEl.width, canvasEl.height);
|
|
return canvasEl.toDataURL('image/png');
|
|
}
|
|
|
|
function detectCurrentTab() {
|
|
const bodyActive = document.body?.dataset?.activeTab;
|
|
const activeBtn = document.querySelector('#mode-tabs .tab-btn.tab-active');
|
|
const classicVisible = !document.getElementById('tab-classic')?.classList.contains('hidden');
|
|
const organicVisible = !document.getElementById('tab-organic')?.classList.contains('hidden');
|
|
|
|
let id = bodyActive || activeBtn?.dataset?.target;
|
|
if (!id) {
|
|
if (classicVisible && !organicVisible) id = '#tab-classic';
|
|
else if (organicVisible && !classicVisible) id = '#tab-organic';
|
|
}
|
|
if (!id) id = lastActiveTab || '#tab-organic';
|
|
lastActiveTab = id;
|
|
if (document.body) document.body.dataset.activeTab = id;
|
|
return id;
|
|
}
|
|
|
|
function updateSheets() {
|
|
const tab = detectCurrentTab();
|
|
const hide = !window.matchMedia('(min-width: 1024px)').matches && document.body?.dataset?.controlsHidden === '1';
|
|
if (orgSheet) orgSheet.classList.toggle('hidden', hide || tab !== '#tab-organic');
|
|
if (claSheet) claSheet.classList.toggle('hidden', hide || tab !== '#tab-classic');
|
|
}
|
|
|
|
async function exportPng() {
|
|
try {
|
|
const currentTab = detectCurrentTab();
|
|
|
|
if (currentTab === '#tab-classic') {
|
|
const { svgString, width, height } = await buildClassicSvgPayload();
|
|
const pngUrl = await svgStringToPng(svgString, width, height);
|
|
download(pngUrl, 'classic_design.png');
|
|
return;
|
|
}
|
|
|
|
const { svgString, width, height } = await buildOrganicSvgPayload();
|
|
const pngUrl = await svgStringToPng(svgString, width, height);
|
|
download(pngUrl, 'balloon_design.png');
|
|
} catch (err) {
|
|
console.error('[Export PNG] Failed:', err);
|
|
showModal(err.message || 'Could not export PNG. Check console for details.');
|
|
}
|
|
}
|
|
|
|
function downloadSvg(svgString, filename) {
|
|
const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
|
|
const url = URL.createObjectURL(blob);
|
|
download(url, filename);
|
|
setTimeout(() => URL.revokeObjectURL(url), 20000);
|
|
}
|
|
|
|
async function exportSvg() {
|
|
try {
|
|
const currentTab = detectCurrentTab();
|
|
|
|
if (currentTab === '#tab-classic') {
|
|
const { svgString, width, height } = await buildClassicSvgPayload();
|
|
try {
|
|
const pngUrl = await svgStringToPng(svgString, width, height);
|
|
const cleanSvg = `<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);
|
|
const loaded = Array.isArray(data.balloons)
|
|
? data.balloons.map(b => {
|
|
const idx = b.colorIdx ?? (HEX_TO_FIRST_IDX.get(normalizeHex(b.color)) ?? 0);
|
|
const meta = FLAT_COLORS[idx] || {};
|
|
return { x: b.x, y: b.y, radius: b.radius, color: meta.hex, image: meta.image, colorIdx: idx, id: crypto.randomUUID() };
|
|
})
|
|
: compactToDesign(data);
|
|
balloons = loaded.slice(0, MAX_BALLOONS);
|
|
refreshAll({ refit: true });
|
|
resetHistory();
|
|
persist();
|
|
updateCurrentColorChip();
|
|
if (loaded.length > MAX_BALLOONS) {
|
|
showModal(`Design loaded (trimmed to ${MAX_BALLOONS} balloons).`);
|
|
} else {
|
|
showModal('Design loaded from link!');
|
|
}
|
|
} catch {
|
|
showModal('Could not load design from URL.');
|
|
}
|
|
}
|
|
|
|
// ====== Fit/Camera helpers ======
|
|
function balloonsBounds() {
|
|
if (balloons.length === 0) return { minX: 0, minY: 0, maxX: 500, maxY: 500, w: 500, h: 500 };
|
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
for (const b of balloons) {
|
|
minX = Math.min(minX, b.x - b.radius);
|
|
minY = Math.min(minY, b.y - b.radius);
|
|
maxX = Math.max(maxX, b.x + b.radius);
|
|
maxY = Math.max(maxY, b.y + b.radius);
|
|
}
|
|
return { minX, minY, maxX, maxY, w: maxX - minX, h: maxY - minY };
|
|
}
|
|
|
|
function fitView() {
|
|
const box = balloonsBounds();
|
|
const cw = canvas.width / dpr; // CSS px
|
|
const ch = canvas.height / dpr;
|
|
if (balloons.length === 0) { view.s = 1; clampViewScale(); view.tx = 0; view.ty = 0; return; }
|
|
|
|
const pad = FIT_PADDING_PX;
|
|
const w = Math.max(1, box.w);
|
|
const h = Math.max(1, box.h);
|
|
|
|
const sFit = Math.min((cw - 2*pad) / w, (ch - 2*pad) / h);
|
|
view.s = Math.min(VIEW_MAX_SCALE, isFinite(sFit) && sFit > 0 ? sFit : 1);
|
|
clampViewScale();
|
|
|
|
const worldW = cw / view.s;
|
|
const worldH = ch / view.s;
|
|
view.tx = (worldW - w) * 0.5 - box.minX;
|
|
view.ty = (worldH - h) * 0.5 - box.minY;
|
|
}
|
|
|
|
function balloonScreenBounds(b) {
|
|
const left = (b.x - b.radius + view.tx) * view.s;
|
|
const right = (b.x + b.radius + view.tx) * view.s;
|
|
const top = (b.y - b.radius + view.ty) * view.s;
|
|
const bottom = (b.y + b.radius + view.ty) * view.s;
|
|
return { left, right, top, bottom };
|
|
}
|
|
|
|
function ensureVisibleAfterAdd(b) {
|
|
const pad = FIT_PADDING_PX;
|
|
const cw = canvas.width / dpr;
|
|
const ch = canvas.height / dpr;
|
|
|
|
// zoom out only if needed to keep the new balloon visible
|
|
const needSx = (cw - 2*pad) / (2*b.radius);
|
|
const needSy = (ch - 2*pad) / (2*b.radius);
|
|
const sNeeded = Math.min(needSx, needSy);
|
|
if (isFinite(sNeeded) && sNeeded > 0 && sNeeded < view.s) {
|
|
view.s = Math.max(VIEW_MIN_SCALE, sNeeded);
|
|
}
|
|
clampViewScale();
|
|
|
|
const r = balloonScreenBounds(b);
|
|
let dx = 0, dy = 0;
|
|
if (r.left < pad) dx = (pad - r.left) / view.s;
|
|
else if (r.right > cw-pad) dx = ((cw - pad) - r.right) / view.s;
|
|
|
|
if (r.top < pad) dy = (pad - r.top) / view.s;
|
|
else if (r.bottom > ch-pad) dy = ((ch - pad) - r.bottom) / view.s;
|
|
|
|
view.tx += dx;
|
|
view.ty += dy;
|
|
return (dx !== 0 || dy !== 0);
|
|
}
|
|
|
|
// ====== Refresh & Events ======
|
|
function refreshAll({ refit = false, autoFit = false } = {}) {
|
|
if (refit) fitView();
|
|
else if (autoFit) fitView();
|
|
draw();
|
|
renderUsedPalette();
|
|
persist();
|
|
if(window.updateExportButtonVisibility) window.updateExportButtonVisibility();
|
|
}
|
|
|
|
// --- UI bindings ---
|
|
modalCloseBtn?.addEventListener('click', hideModal);
|
|
|
|
toolDrawBtn?.addEventListener('click', () => setMode('draw'));
|
|
toolGarlandBtn?.addEventListener('click', () => setMode('garland'));
|
|
toolEraseBtn?.addEventListener('click', () => setMode('erase'));
|
|
toolSelectBtn?.addEventListener('click', () => setMode('select'));
|
|
|
|
eraserSizeInput?.addEventListener('input', e => {
|
|
eraserRadius = parseInt(e.target.value, 10);
|
|
if (eraserSizeLabel) eraserSizeLabel.textContent = eraserRadius;
|
|
if (mode === 'erase') draw();
|
|
persist();
|
|
});
|
|
toggleBorderCheckbox?.addEventListener('change', e => {
|
|
isBorderEnabled = !!e.target.checked;
|
|
draw();
|
|
persist();
|
|
});
|
|
garlandDensityInput?.addEventListener('input', e => {
|
|
garlandDensity = clamp(parseFloat(e.target.value) || 1, 0.6, 1.6);
|
|
if (garlandDensityLabel) garlandDensityLabel.textContent = garlandDensity.toFixed(1);
|
|
if (mode === 'garland') requestDraw();
|
|
persist();
|
|
});
|
|
const handleGarlandColorChange = () => {
|
|
updateGarlandSwatches();
|
|
persist();
|
|
if (mode === 'garland') requestDraw();
|
|
};
|
|
garlandColorMain1Sel?.addEventListener('change', e => {
|
|
garlandMainIdx[0] = parseInt(e.target.value, 10) || -1;
|
|
handleGarlandColorChange();
|
|
});
|
|
garlandColorMain2Sel?.addEventListener('change', e => {
|
|
garlandMainIdx[1] = parseInt(e.target.value, 10) || -1;
|
|
handleGarlandColorChange();
|
|
});
|
|
garlandColorMain3Sel?.addEventListener('change', e => {
|
|
garlandMainIdx[2] = parseInt(e.target.value, 10) || -1;
|
|
handleGarlandColorChange();
|
|
});
|
|
garlandColorMain4Sel?.addEventListener('change', e => {
|
|
garlandMainIdx[3] = parseInt(e.target.value, 10) || -1;
|
|
handleGarlandColorChange();
|
|
});
|
|
garlandColorAccentSel?.addEventListener('change', e => {
|
|
garlandAccentIdx = parseInt(e.target.value, 10) || -1;
|
|
handleGarlandColorChange();
|
|
});
|
|
|
|
deleteSelectedBtn?.addEventListener('click', deleteSelected);
|
|
duplicateSelectedBtn?.addEventListener('click', duplicateSelected);
|
|
nudgeSelectedBtns.forEach(btn => btn.addEventListener('click', () => {
|
|
const dx = Number(btn.dataset.dx || 0);
|
|
const dy = Number(btn.dataset.dy || 0);
|
|
moveSelected(dx, dy);
|
|
}));
|
|
|
|
selectedSizeInput?.addEventListener('input', e => {
|
|
resizeSelected(parseFloat(e.target.value) || 0);
|
|
});
|
|
selectedSizeInput?.addEventListener('pointerdown', () => {
|
|
resizeChanged = false;
|
|
clearTimeout(resizeSaveTimer);
|
|
});
|
|
selectedSizeInput?.addEventListener('pointerup', () => {
|
|
clearTimeout(resizeSaveTimer);
|
|
if (resizeChanged) {
|
|
pushHistory();
|
|
resizeChanged = false;
|
|
}
|
|
});
|
|
|
|
bringForwardBtn?.addEventListener('click', bringSelectedForward);
|
|
sendBackwardBtn?.addEventListener('click', sendSelectedBackward);
|
|
applyColorBtn?.addEventListener('click', applyColorToSelected);
|
|
fitViewBtn?.addEventListener('click', () => refreshAll({ refit: true }));
|
|
|
|
document.addEventListener('keydown', e => {
|
|
if (document.activeElement && document.activeElement.tagName === 'INPUT') return;
|
|
if (e.key === 'e' || e.key === 'E') setMode('erase');
|
|
else if (e.key === 'v' || e.key === 'V') setMode('draw');
|
|
else if (e.key === 's' || e.key === 'S') setMode('select');
|
|
else if (e.key === 'g' || e.key === 'G') setMode('garland');
|
|
else if (e.key === 'Escape') {
|
|
if (selectedIds.size) {
|
|
clearSelection();
|
|
} else if (mode !== 'draw') {
|
|
setMode('draw');
|
|
}
|
|
} else if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
if (selectedIds.size) { e.preventDefault(); deleteSelected(); }
|
|
} else if ((e.ctrlKey || e.metaKey) && (e.key === 'z' || e.key === 'Z')) {
|
|
e.preventDefault();
|
|
undo();
|
|
} else if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || e.key === 'Y')) {
|
|
e.preventDefault();
|
|
redo();
|
|
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'd') {
|
|
e.preventDefault();
|
|
duplicateSelected();
|
|
}
|
|
});
|
|
|
|
async function confirmAndClear() {
|
|
let ok = true;
|
|
if (window.Swal) {
|
|
const res = await Swal.fire({
|
|
title: 'Start fresh?',
|
|
text: 'This will remove all balloons from the canvas.',
|
|
icon: 'warning',
|
|
showCancelButton: true,
|
|
confirmButtonText: 'Yes, clear',
|
|
cancelButtonText: 'Cancel'
|
|
});
|
|
ok = res.isConfirmed;
|
|
} else {
|
|
ok = window.confirm('Start fresh? This will remove all balloons from the canvas.');
|
|
}
|
|
if (!ok) return;
|
|
balloons = [];
|
|
selectedIds.clear();
|
|
garlandPath = [];
|
|
updateSelectButtons();
|
|
refreshAll({ refit: true });
|
|
pushHistory();
|
|
}
|
|
|
|
clearCanvasBtn?.addEventListener('click', confirmAndClear);
|
|
clearCanvasBtnTop?.addEventListener('click', confirmAndClear);
|
|
|
|
saveJsonBtn?.addEventListener('click', saveJson);
|
|
loadJsonInput?.addEventListener('change', loadJson);
|
|
|
|
generateLinkBtn?.addEventListener('click', generateShareLink);
|
|
|
|
sortUsedToggle?.addEventListener('click', () => {
|
|
usedSortDesc = !usedSortDesc;
|
|
sortUsedToggle.textContent = usedSortDesc ? 'Sort: Most → Least' : 'Sort: Least → Most';
|
|
renderUsedPalette();
|
|
persist();
|
|
});
|
|
|
|
function populateReplaceTo() {
|
|
if (!replaceToSel) return;
|
|
replaceToSel.innerHTML = '';
|
|
(window.PALETTE || []).forEach(group => {
|
|
const og = document.createElement('optgroup');
|
|
og.label = group.family;
|
|
(group.colors || []).forEach(c => {
|
|
const idx =
|
|
FLAT_COLORS.find(fc => fc.name === c.name && fc.hex === c.hex && fc.family === group.family)?._idx
|
|
?? HEX_TO_FIRST_IDX.get(normalizeHex(c.hex)) ?? 0;
|
|
const opt = document.createElement('option');
|
|
opt.value = String(idx);
|
|
opt.textContent = c.name + (c.image ? ' (image)' : '');
|
|
og.appendChild(opt);
|
|
});
|
|
replaceToSel.appendChild(og);
|
|
});
|
|
}
|
|
|
|
function populateGarlandColorSelects() {
|
|
const addOpts = sel => {
|
|
if (!sel) return;
|
|
sel.innerHTML = '';
|
|
const noneOpt = document.createElement('option');
|
|
noneOpt.value = '-1';
|
|
noneOpt.textContent = 'None (use active color)';
|
|
sel.appendChild(noneOpt);
|
|
FLAT_COLORS.forEach((c, idx) => {
|
|
const opt = document.createElement('option');
|
|
opt.value = String(idx);
|
|
opt.textContent = c.name || c.hex;
|
|
sel.appendChild(opt);
|
|
});
|
|
};
|
|
addOpts(garlandColorMain1Sel);
|
|
addOpts(garlandColorMain2Sel);
|
|
addOpts(garlandColorMain3Sel);
|
|
addOpts(garlandColorMain4Sel);
|
|
addOpts(garlandColorAccentSel);
|
|
if (garlandColorMain1Sel) garlandColorMain1Sel.value = String(garlandMainIdx[0] ?? -1);
|
|
if (garlandColorMain2Sel) garlandColorMain2Sel.value = String(garlandMainIdx[1] ?? -1);
|
|
if (garlandColorMain3Sel) garlandColorMain3Sel.value = String(garlandMainIdx[2] ?? -1);
|
|
if (garlandColorMain4Sel) garlandColorMain4Sel.value = String(garlandMainIdx[3] ?? -1);
|
|
if (garlandColorAccentSel) garlandColorAccentSel.value = String(garlandAccentIdx ?? -1);
|
|
|
|
updateGarlandSwatches();
|
|
}
|
|
|
|
function updateGarlandSwatches() {
|
|
const setSw = (sw, idx) => {
|
|
if (!sw) return;
|
|
const meta = idx >= 0 ? FLAT_COLORS[idx] : null;
|
|
if (meta?.image) {
|
|
sw.style.backgroundImage = `url("${meta.image}")`;
|
|
sw.style.backgroundColor = meta.hex || '#fff';
|
|
sw.style.backgroundSize = 'cover';
|
|
} else {
|
|
sw.style.backgroundImage = 'none';
|
|
sw.style.backgroundColor = meta?.hex || '#f1f5f9';
|
|
}
|
|
};
|
|
setSw(garlandSwatchMain1, garlandMainIdx[0]);
|
|
setSw(garlandSwatchMain2, garlandMainIdx[1]);
|
|
setSw(garlandSwatchMain3, garlandMainIdx[2]);
|
|
setSw(garlandSwatchMain4, garlandMainIdx[3]);
|
|
setSw(garlandSwatchAccent, garlandAccentIdx);
|
|
}
|
|
|
|
replaceBtn?.addEventListener('click', () => {
|
|
const fromHex = replaceFromSel?.value;
|
|
const toIdx = parseInt(replaceToSel?.value || '', 10);
|
|
if (!fromHex || Number.isNaN(toIdx)) { if (replaceMsg) replaceMsg.textContent = 'Pick both colors.'; return; }
|
|
|
|
let count = 0;
|
|
balloons.forEach(b => {
|
|
if (normalizeHex(b.color) === normalizeHex(fromHex)) {
|
|
const toMeta = FLAT_COLORS[toIdx];
|
|
b.color = toMeta.hex;
|
|
b.image = toMeta.image || null;
|
|
b.colorIdx = toMeta._idx;
|
|
count++;
|
|
}
|
|
});
|
|
|
|
if (count > 0) {
|
|
pushHistory();
|
|
if (replaceMsg) replaceMsg.textContent = `Replaced ${count} balloon${count === 1 ? '' : 's'}.`;
|
|
if (normalizeHex(FLAT_COLORS[selectedColorIdx]?.hex) === normalizeHex(fromHex)) selectedColorIdx = toIdx;
|
|
refreshAll();
|
|
renderAllowedPalette();
|
|
} else {
|
|
if (replaceMsg) replaceMsg.textContent = 'Nothing to replace.';
|
|
}
|
|
});
|
|
|
|
// ====== Init ======
|
|
sizePresetGroup && (sizePresetGroup.innerHTML = '');
|
|
SIZE_PRESETS.forEach(di => {
|
|
const btn = document.createElement('button');
|
|
btn.className = 'tool-btn';
|
|
btn.textContent = `${di}"`;
|
|
btn.setAttribute('aria-pressed', String(di === currentDiameterInches));
|
|
btn.addEventListener('click', () => {
|
|
currentDiameterInches = di;
|
|
currentRadius = inchesToRadiusPx(di);
|
|
[...sizePresetGroup.querySelectorAll('button')].forEach(b => b.setAttribute('aria-pressed', 'false'));
|
|
btn.setAttribute('aria-pressed', 'true');
|
|
persist();
|
|
});
|
|
sizePresetGroup?.appendChild(btn);
|
|
});
|
|
|
|
toggleShineCheckbox?.addEventListener('change', e => {
|
|
const on = !!e.target.checked;
|
|
window.syncAppShine(on);
|
|
});
|
|
|
|
mode = 'draw'; // force default tool on load
|
|
renderAllowedPalette();
|
|
resizeCanvas();
|
|
loadFromUrl();
|
|
renderUsedPalette();
|
|
setMode('draw');
|
|
updateSelectButtons();
|
|
populateReplaceTo();
|
|
populateGarlandColorSelects();
|
|
|
|
// default to canvas-first on mobile; no expansion toggles remain
|
|
|
|
// Initialize shine state from localStorage for both panels
|
|
let initialShineState = true;
|
|
try {
|
|
const saved = localStorage.getItem('app:shineEnabled:v1');
|
|
if (saved !== null) initialShineState = JSON.parse(saved);
|
|
} catch {}
|
|
|
|
// Set Organic panel's internal state and UI
|
|
isShineEnabled = initialShineState;
|
|
if (toggleShineCheckbox) toggleShineCheckbox.checked = isShineEnabled;
|
|
|
|
// Set Classic panel's UI checkbox (its script will read this too)
|
|
const classicCb = document.getElementById('classic-shine-enabled');
|
|
if (classicCb) classicCb.checked = isShineEnabled;
|
|
|
|
// ===============================
|
|
// ===== TAB SWITCHING (UI) ======
|
|
// ===============================
|
|
const orgSection = document.getElementById('tab-organic');
|
|
const claSection = document.getElementById('tab-classic');
|
|
const tabBtns = document.querySelectorAll('#mode-tabs .tab-btn');
|
|
const ACTIVE_TAB_KEY = 'balloonDesigner:activeTab:v1';
|
|
|
|
function updateMobileStacks(tabName) {
|
|
const orgPanel = document.getElementById('controls-panel');
|
|
const claPanel = document.getElementById('classic-controls-panel');
|
|
const currentTab = detectCurrentTab();
|
|
const panel = currentTab === '#tab-classic' ? claPanel : orgPanel;
|
|
const target = tabName || document.body?.dataset?.mobileTab || 'controls';
|
|
const isHidden = document.body?.dataset?.controlsHidden === '1';
|
|
if (!panel) return;
|
|
const stacks = Array.from(panel.querySelectorAll('.control-stack'));
|
|
if (!stacks.length) return;
|
|
stacks.forEach(stack => {
|
|
if (isHidden) {
|
|
stack.style.display = 'none';
|
|
} else {
|
|
const show = stack.dataset.mobileTab === target;
|
|
stack.style.display = show ? 'block' : 'none';
|
|
}
|
|
});
|
|
}
|
|
|
|
function setMobileTab(tab) {
|
|
const name = tab || 'controls';
|
|
const isDesktop = window.matchMedia('(min-width: 1024px)').matches;
|
|
if (document.body) {
|
|
document.body.dataset.mobileTab = name;
|
|
delete document.body.dataset.controlsHidden;
|
|
}
|
|
updateSheets();
|
|
updateMobileStacks(name);
|
|
const buttons = document.querySelectorAll('#mobile-tabbar .mobile-tab-btn');
|
|
buttons.forEach(btn => btn.setAttribute('aria-pressed', String(btn.dataset.mobileTab === name)));
|
|
}
|
|
window.__setMobileTab = setMobileTab;
|
|
|
|
const NUDGE_POS_KEY = 'classic:nudgePos:v1';
|
|
const NUDGE_MARGIN = 12;
|
|
const NUDGE_SIZE_HINT = { w: 180, h: 200 };
|
|
let floatingNudgeCollapsed = false;
|
|
function clampNudgePos(pos, el) {
|
|
const vw = window.innerWidth || 1024;
|
|
const vh = window.innerHeight || 768;
|
|
const rect = el?.getBoundingClientRect();
|
|
const w = rect?.width || NUDGE_SIZE_HINT.w;
|
|
const h = rect?.height || NUDGE_SIZE_HINT.h;
|
|
return {
|
|
x: Math.min(Math.max(pos.x, NUDGE_MARGIN), Math.max(NUDGE_MARGIN, vw - w - NUDGE_MARGIN)),
|
|
y: Math.min(Math.max(pos.y, NUDGE_MARGIN), Math.max(NUDGE_MARGIN, vh - h - NUDGE_MARGIN))
|
|
};
|
|
}
|
|
let nudgePos = null;
|
|
let nudgePosInitialized = false;
|
|
function loadNudgePos(el) {
|
|
try {
|
|
const saved = JSON.parse(localStorage.getItem(NUDGE_POS_KEY));
|
|
if (saved && typeof saved.x === 'number' && typeof saved.y === 'number') return clampNudgePos(saved, el);
|
|
} catch {}
|
|
return clampNudgePos({ x: (window.innerWidth || 1024) - 240, y: 120 }, el);
|
|
}
|
|
function ensureNudgePos(el) {
|
|
if (!nudgePos) nudgePos = loadNudgePos(el);
|
|
return clampNudgePos(nudgePos, el);
|
|
}
|
|
function saveNudgePos(pos, el) {
|
|
nudgePos = clampNudgePos(pos, el);
|
|
try { localStorage.setItem(NUDGE_POS_KEY, JSON.stringify(nudgePos)); } catch {}
|
|
return nudgePos;
|
|
}
|
|
function applyNudgePos(el, pos) {
|
|
const p = clampNudgePos(pos || ensureNudgePos(el), el);
|
|
el.style.left = `${p.x}px`;
|
|
el.style.top = `${p.y}px`;
|
|
el.style.right = 'auto';
|
|
el.style.bottom = 'auto';
|
|
nudgePos = p;
|
|
nudgePosInitialized = true;
|
|
}
|
|
function updateFloatingNudge() {
|
|
const el = document.getElementById('floating-topper-nudge');
|
|
if (!el) return;
|
|
const classicActive = document.body?.dataset.activeTab === '#tab-classic';
|
|
const topperActive = document.body?.dataset.topperOverlay === '1';
|
|
const shouldShow = classicActive && topperActive;
|
|
const shouldShowPanel = shouldShow && !floatingNudgeCollapsed;
|
|
el.classList.toggle('hidden', !shouldShowPanel);
|
|
el.classList.toggle('collapsed', floatingNudgeCollapsed);
|
|
el.style.display = shouldShowPanel ? 'block' : 'none';
|
|
if (shouldShowPanel && !nudgePosInitialized) applyNudgePos(el, ensureNudgePos(el));
|
|
}
|
|
function showFloatingNudge() {
|
|
floatingNudgeCollapsed = false;
|
|
updateFloatingNudge();
|
|
}
|
|
function hideFloatingNudge() {
|
|
floatingNudgeCollapsed = true;
|
|
const nudge = document.getElementById('floating-topper-nudge');
|
|
if (nudge) { nudge.classList.add('hidden'); nudge.style.display = 'none'; }
|
|
const tab = document.getElementById('floating-nudge-tab');
|
|
if (tab) tab.classList.remove('hidden');
|
|
updateFloatingNudge();
|
|
}
|
|
window.__updateFloatingNudge = updateFloatingNudge;
|
|
window.__showFloatingNudge = showFloatingNudge;
|
|
window.__hideFloatingNudge = hideFloatingNudge;
|
|
|
|
if (orgSection && claSection && tabBtns.length > 0) {
|
|
let current = '#tab-organic';
|
|
|
|
function setTab(id, isInitial = false) {
|
|
if (!id || !document.querySelector(id)) id = '#tab-organic';
|
|
current = id;
|
|
lastActiveTab = id;
|
|
if (document.body) document.body.dataset.activeTab = id;
|
|
|
|
// Reset minimized state on tab switch
|
|
orgSheet?.classList.remove('minimized');
|
|
claSheet?.classList.remove('minimized');
|
|
|
|
orgSection.classList.toggle('hidden', id !== '#tab-organic');
|
|
claSection.classList.toggle('hidden', id !== '#tab-classic');
|
|
updateSheets();
|
|
updateFloatingNudge();
|
|
|
|
tabBtns.forEach(btn => {
|
|
const active = btn.dataset.target === id;
|
|
btn.classList.toggle('tab-active', active);
|
|
btn.classList.toggle('tab-idle', !active);
|
|
btn.setAttribute('aria-pressed', String(active));
|
|
});
|
|
|
|
if (!isInitial) {
|
|
try { localStorage.setItem(ACTIVE_TAB_KEY, id); } catch {}
|
|
}
|
|
|
|
if (document.body) delete document.body.dataset.controlsHidden;
|
|
// Toggle header controls based on tab
|
|
const isOrganic = id === '#tab-organic';
|
|
document.getElementById('clear-canvas-btn-top')?.classList.toggle('hidden', !isOrganic);
|
|
const headerActiveSwatch = document.getElementById('current-color-chip-global')?.closest('.flex');
|
|
headerActiveSwatch?.classList.toggle('hidden', !isOrganic);
|
|
setMobileTab(document.body?.dataset?.mobileTab || 'controls');
|
|
orgSheet?.classList.toggle('hidden', id !== '#tab-organic');
|
|
claSheet?.classList.toggle('hidden', id !== '#tab-classic');
|
|
|
|
if (window.updateExportButtonVisibility) window.updateExportButtonVisibility();
|
|
}
|
|
|
|
tabBtns.forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
const button = e.target.closest('button[data-target]');
|
|
if (button) setTab(button.dataset.target);
|
|
});
|
|
});
|
|
|
|
let savedTab = null;
|
|
try { savedTab = localStorage.getItem(ACTIVE_TAB_KEY); } catch {}
|
|
setTab(savedTab || '#tab-organic', true);
|
|
|
|
window.__whichTab = () => current;
|
|
// ensure mobile default
|
|
if (!document.body?.dataset?.mobileTab) document.body.dataset.mobileTab = 'controls';
|
|
setMobileTab(document.body.dataset.mobileTab);
|
|
updateSheets();
|
|
updateMobileStacks(document.body.dataset.mobileTab);
|
|
}
|
|
|
|
// ===============================
|
|
// ===== Mobile bottom tabs ======
|
|
// ===============================
|
|
(function initMobileTabs() {
|
|
const buttons = Array.from(document.querySelectorAll('#mobile-tabbar .mobile-tab-btn'));
|
|
if (!buttons.length) return;
|
|
|
|
buttons.forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const tab = btn.dataset.mobileTab || 'controls';
|
|
const panel = (!document.getElementById('tab-classic')?.classList.contains('hidden')
|
|
? document.getElementById('classic-controls-panel')
|
|
: document.getElementById('controls-panel'));
|
|
|
|
const currentTab = document.body.dataset.mobileTab;
|
|
|
|
if (tab === currentTab) {
|
|
// Toggle minimized state
|
|
panel.classList.toggle('minimized');
|
|
} else {
|
|
// Switch tab and ensure expanded
|
|
panel.classList.remove('minimized');
|
|
setMobileTab(tab);
|
|
panel.scrollTop = 0;
|
|
}
|
|
});
|
|
});
|
|
|
|
const mq = window.matchMedia('(min-width: 1024px)');
|
|
const sync = () => {
|
|
if (mq.matches) {
|
|
document.body?.removeAttribute('data-mobile-tab');
|
|
updateMobileStacks('controls'); // keep controls visible on desktop
|
|
} else {
|
|
setMobileTab(document.body?.dataset?.mobileTab || 'controls');
|
|
}
|
|
updateFloatingNudge();
|
|
};
|
|
mq.addEventListener('change', sync);
|
|
setMobileTab(document.body?.dataset?.mobileTab || 'controls');
|
|
sync();
|
|
|
|
const nudgeToggle = document.getElementById('floating-nudge-toggle');
|
|
nudgeToggle?.addEventListener('click', () => {
|
|
hideFloatingNudge();
|
|
});
|
|
|
|
// Dragging for floating nudge
|
|
const nudge = document.getElementById('floating-topper-nudge');
|
|
const nudgeHeader = document.querySelector('#floating-topper-nudge .floating-nudge-header');
|
|
if (nudge && nudgeHeader) {
|
|
let dragId = null;
|
|
let start = null;
|
|
nudgeHeader.addEventListener('pointerdown', (e) => {
|
|
if (e.target.closest('#floating-nudge-toggle')) return; // don't start drag when clicking close
|
|
dragId = e.pointerId;
|
|
start = { x: e.clientX, y: e.clientY, pos: loadNudgePos(nudge) };
|
|
nudge.classList.add('dragging');
|
|
nudgeHeader.setPointerCapture(dragId);
|
|
});
|
|
nudgeHeader.addEventListener('pointermove', (e) => {
|
|
if (dragId === null || e.pointerId !== dragId) return;
|
|
if (!start) return;
|
|
const dx = e.clientX - start.x;
|
|
const dy = e.clientY - start.y;
|
|
const next = clampNudgePos({ x: start.pos.x + dx, y: start.pos.y + dy });
|
|
applyNudgePos(nudge, next);
|
|
});
|
|
const endDrag = (e) => {
|
|
if (dragId === null || e.pointerId !== dragId) return;
|
|
const rect = nudge.getBoundingClientRect();
|
|
const next = clampNudgePos({ x: rect.left, y: rect.top }, nudge);
|
|
saveNudgePos(next, nudge);
|
|
nudge.classList.remove('dragging');
|
|
dragId = null;
|
|
start = null;
|
|
try { nudgeHeader.releasePointerCapture(e.pointerId); } catch {}
|
|
};
|
|
nudgeHeader.addEventListener('pointerup', endDrag);
|
|
nudgeHeader.addEventListener('pointercancel', endDrag);
|
|
window.addEventListener('resize', () => {
|
|
const pos = clampNudgePos(loadNudgePos(nudge), nudge);
|
|
saveNudgePos(pos, nudge);
|
|
nudgePosInitialized = false;
|
|
applyNudgePos(nudge, pos);
|
|
});
|
|
}
|
|
})();
|
|
});
|
|
})();
|