419 lines
18 KiB
JavaScript
419 lines
18 KiB
JavaScript
// script.js - shared tab/UI logic
|
|
(() => {
|
|
'use strict';
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const ACTIVE_TAB_KEY = 'balloonDesigner:activeTab:v1';
|
|
|
|
// Ensure shared helpers are ready
|
|
if (!window.shared) return;
|
|
const { clamp, clamp01 } = window.shared;
|
|
|
|
// Modal helpers
|
|
const messageModal = document.getElementById('message-modal');
|
|
const modalText = document.getElementById('modal-text');
|
|
const modalCloseBtn = document.getElementById('modal-close-btn');
|
|
function showModal(msg) {
|
|
if (!messageModal || !modalText) return;
|
|
modalText.textContent = msg;
|
|
messageModal.classList.remove('hidden');
|
|
}
|
|
function hideModal() {
|
|
messageModal?.classList.add('hidden');
|
|
}
|
|
modalCloseBtn?.addEventListener('click', hideModal);
|
|
|
|
// Tab elements
|
|
const orgSheet = document.getElementById('controls-panel');
|
|
const claSheet = document.getElementById('classic-controls-panel');
|
|
const wallSheet = document.getElementById('wall-controls-panel');
|
|
const orgSection = document.getElementById('tab-organic');
|
|
const claSection = document.getElementById('tab-classic');
|
|
const wallSection = document.getElementById('tab-wall');
|
|
const tabBtns = Array.from(document.querySelectorAll('#mode-tabs .tab-btn'));
|
|
|
|
// Export buttons
|
|
const exportPngBtn = document.querySelector('[data-export="png"]');
|
|
const exportSvgBtn = document.querySelector('[data-export="svg"]');
|
|
|
|
// Update export buttons visibility depending on tab/design
|
|
window.updateExportButtonVisibility = () => {
|
|
const tab = detectCurrentTab();
|
|
const isWall = tab === '#tab-wall';
|
|
const isClassic = tab === '#tab-classic';
|
|
const hasClassic = !!document.querySelector('#classic-display svg');
|
|
const hasWall = !!document.querySelector('#wall-display svg');
|
|
const enable = (btn, on) => { if (btn) btn.disabled = !on; };
|
|
if (isClassic) enable(exportPngBtn, hasClassic); else enable(exportPngBtn, true);
|
|
if (isClassic) enable(exportSvgBtn, hasClassic); else if (isWall) enable(exportSvgBtn, hasWall); else enable(exportSvgBtn, true);
|
|
};
|
|
|
|
// Export routing
|
|
async function svgStringToPng(svgString, width, height) {
|
|
const { PNG_EXPORT_SCALE } = window.shared;
|
|
const img = new Image();
|
|
img.crossOrigin = 'anonymous';
|
|
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 loadSrc = (src) => new Promise((resolve, reject) => {
|
|
img.onload = resolve;
|
|
img.onerror = () => reject(new Error('Could not rasterize SVG.'));
|
|
img.src = src;
|
|
});
|
|
let blobUrl = null;
|
|
try {
|
|
const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
|
|
blobUrl = URL.createObjectURL(blob);
|
|
await loadSrc(blobUrl);
|
|
} catch (e) {
|
|
const dataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgString)}`;
|
|
await loadSrc(dataUrl);
|
|
} finally {
|
|
if (blobUrl) URL.revokeObjectURL(blobUrl);
|
|
}
|
|
ctx2.drawImage(img, 0, 0, canvasEl.width, canvasEl.height);
|
|
return canvasEl.toDataURL('image/png');
|
|
}
|
|
|
|
function downloadSvg(svgString, filename) {
|
|
const { download } = window.shared;
|
|
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 exportPng() {
|
|
try {
|
|
const tab = detectCurrentTab();
|
|
if (tab === '#tab-classic') {
|
|
const { svgString, width, height } = await window.ClassicExport.buildClassicSvgPayload();
|
|
const pngUrl = await svgStringToPng(svgString, width, height);
|
|
window.shared.download(pngUrl, 'classic_design.png');
|
|
return;
|
|
}
|
|
if (tab === '#tab-wall') {
|
|
const { svgString, width, height } = await window.WallDesigner.buildWallSvgPayload(true);
|
|
const pngUrl = await svgStringToPng(svgString, width, height);
|
|
window.shared.download(pngUrl, 'wall_design.png');
|
|
return;
|
|
}
|
|
const { svgString, width, height } = await window.organic.buildOrganicSvgPayload();
|
|
const pngUrl = await svgStringToPng(svgString, width, height);
|
|
window.shared.download(pngUrl, 'balloon_design.png');
|
|
} catch (err) {
|
|
console.error('[Export PNG] Failed:', err);
|
|
showModal(err.message || 'Could not export PNG. Check console for details.');
|
|
}
|
|
}
|
|
|
|
async function exportSvg() {
|
|
try {
|
|
const tab = detectCurrentTab();
|
|
if (tab === '#tab-classic') {
|
|
const { svgString, width, height } = await window.ClassicExport.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');
|
|
} catch (pngErr) {
|
|
console.warn('[Export SVG] PNG embed failed, falling back to vector-only SVG', pngErr);
|
|
downloadSvg(svgString, 'classic_design.svg');
|
|
}
|
|
return;
|
|
}
|
|
if (tab === '#tab-wall') {
|
|
const { svgString } = await window.WallDesigner.buildWallSvgPayload(true);
|
|
downloadSvg(svgString, 'wall_design.svg');
|
|
return;
|
|
}
|
|
const { svgString } = await window.organic.buildOrganicSvgPayload();
|
|
downloadSvg(svgString, 'organic_design.svg');
|
|
} catch (err) {
|
|
console.error('[Export] Failed:', err);
|
|
showModal(err.message || 'Could not export. Check console for details.');
|
|
}
|
|
}
|
|
|
|
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();
|
|
});
|
|
|
|
// Tab logic
|
|
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');
|
|
const wallVisible = !document.getElementById('tab-wall')?.classList.contains('hidden');
|
|
|
|
let id = bodyActive || activeBtn?.dataset?.target;
|
|
if (!id) {
|
|
if (classicVisible && !organicVisible && !wallVisible) id = '#tab-classic';
|
|
else if (organicVisible && !classicVisible && !wallVisible) id = '#tab-organic';
|
|
else if (wallVisible && !classicVisible && !organicVisible) id = '#tab-wall';
|
|
}
|
|
if (!id) id = '#tab-organic';
|
|
if (document.body) document.body.dataset.activeTab = id;
|
|
return id;
|
|
}
|
|
window.detectCurrentTab = detectCurrentTab;
|
|
|
|
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');
|
|
if (wallSheet) wallSheet.classList.toggle('hidden', hide || tab !== '#tab-wall');
|
|
}
|
|
|
|
function updateMobileStacks(tabName) {
|
|
const orgPanel = document.getElementById('controls-panel');
|
|
const claPanel = document.getElementById('classic-controls-panel');
|
|
const wallPanel = document.getElementById('wall-controls-panel');
|
|
const currentTab = detectCurrentTab();
|
|
const panel = currentTab === '#tab-classic' ? claPanel : (currentTab === '#tab-wall' ? wallPanel : 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;
|
|
|
|
// Tab switching
|
|
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;
|
|
if (document.body) document.body.dataset.activeTab = id;
|
|
orgSheet?.classList.remove('minimized');
|
|
claSheet?.classList.remove('minimized');
|
|
wallSheet?.classList.remove('minimized');
|
|
orgSection.classList.toggle('hidden', id !== '#tab-organic');
|
|
claSection.classList.toggle('hidden', id !== '#tab-classic');
|
|
wallSection?.classList.toggle('hidden', id !== '#tab-wall');
|
|
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;
|
|
const isOrganic = id === '#tab-organic';
|
|
const showHeaderColor = id !== '#tab-classic';
|
|
document.getElementById('clear-canvas-btn-top')?.classList.toggle('hidden', !isOrganic);
|
|
const headerActiveSwatch = document.getElementById('current-color-chip-global')?.closest('.flex');
|
|
headerActiveSwatch?.classList.toggle('hidden', !showHeaderColor);
|
|
setMobileTab(document.body?.dataset?.mobileTab || 'controls');
|
|
orgSheet?.classList.toggle('hidden', id !== '#tab-organic');
|
|
claSheet?.classList.toggle('hidden', id !== '#tab-classic');
|
|
wallSheet?.classList.toggle('hidden', id !== '#tab-wall');
|
|
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;
|
|
if (!document.body?.dataset?.mobileTab) document.body.dataset.mobileTab = 'controls';
|
|
setMobileTab(document.body.dataset.mobileTab);
|
|
updateSheets();
|
|
updateMobileStacks(document.body.dataset.mobileTab);
|
|
}
|
|
|
|
// Mobile tabbar
|
|
(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 activeTabId = detectCurrentTab();
|
|
const panel = activeTabId === '#tab-classic'
|
|
? document.getElementById('classic-controls-panel')
|
|
: (activeTabId === '#tab-wall'
|
|
? document.getElementById('wall-controls-panel')
|
|
: document.getElementById('controls-panel'));
|
|
const currentTab = document.body.dataset.mobileTab;
|
|
if (tab === currentTab) {
|
|
panel.classList.toggle('minimized');
|
|
} else {
|
|
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');
|
|
} 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();
|
|
});
|
|
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;
|
|
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);
|
|
});
|
|
}
|
|
})();
|
|
});
|
|
})();
|