717 lines
32 KiB
JavaScript
717 lines
32 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, SWATCH_TEXTURE_ZOOM } = window.shared;
|
|
const { FLAT_COLORS } = 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 heliumSheet = document.getElementById('helium-controls-panel');
|
|
const orgSection = document.getElementById('tab-organic');
|
|
const claSection = document.getElementById('tab-classic');
|
|
const wallSection = document.getElementById('tab-wall');
|
|
const heliumSection = document.getElementById('tab-helium');
|
|
const tabBtns = Array.from(document.querySelectorAll('#mode-tabs .tab-btn'));
|
|
const mobileActionBar = document.getElementById('mobile-action-bar');
|
|
|
|
// Export buttons
|
|
const exportPngBtn = document.querySelector('[data-export="png"]');
|
|
const exportSvgBtn = document.querySelector('[data-export="svg"]');
|
|
const { XLINK_NS } = window.shared || {};
|
|
const MOBILE_TAB_DEFAULT = 'controls';
|
|
const MOBILE_TAB_PREFIX = 'designer:mobileTab:';
|
|
const exportModal = document.getElementById('export-modal');
|
|
const exportModalClose = document.getElementById('export-modal-close');
|
|
|
|
// Generic color picker modal (used by replace chips)
|
|
(function initColorPickerModal() {
|
|
const modal = document.getElementById('color-picker-modal');
|
|
const grid = document.getElementById('color-picker-grid');
|
|
const titleEl = document.getElementById('color-picker-title');
|
|
const subtitleEl = document.getElementById('color-picker-subtitle');
|
|
const closeBtn = document.getElementById('color-picker-close');
|
|
if (!modal || !grid || !titleEl || !subtitleEl) return;
|
|
const close = () => modal.classList.add('hidden');
|
|
closeBtn?.addEventListener('click', close);
|
|
modal.addEventListener('click', (e) => { if (e.target === modal) close(); });
|
|
|
|
const setChipStyle = (el, meta) => {
|
|
if (!el || !meta) return;
|
|
if (meta.image) {
|
|
const zoom = Math.max(1, meta.imageZoom ?? SWATCH_TEXTURE_ZOOM ?? 2.5);
|
|
el.style.backgroundImage = `url("${meta.image}")`;
|
|
el.style.backgroundColor = meta.hex || '#fff';
|
|
el.style.backgroundSize = `${100 * zoom}%`;
|
|
el.style.backgroundPosition = `${(meta.imageFocus?.x ?? 0.5) * 100}% ${(meta.imageFocus?.y ?? 0.5) * 100}%`;
|
|
} else {
|
|
el.style.backgroundImage = 'none';
|
|
el.style.backgroundColor = meta.hex || '#fff';
|
|
}
|
|
};
|
|
|
|
window.openColorPicker = ({ title = 'Choose a color', subtitle = '', items = [], onSelect }) => {
|
|
titleEl.textContent = title;
|
|
subtitleEl.textContent = subtitle;
|
|
grid.innerHTML = '';
|
|
items.forEach(item => {
|
|
const meta = item.meta || (Number.isInteger(item.idx) ? FLAT_COLORS?.[item.idx] : null) || {};
|
|
const sw = document.createElement('button');
|
|
sw.type = 'button';
|
|
sw.className = 'color-option';
|
|
sw.setAttribute('aria-label', item.label || meta.name || meta.hex || 'Color');
|
|
const swatch = document.createElement('div');
|
|
swatch.className = 'swatch';
|
|
setChipStyle(swatch, meta.hex ? meta : { hex: item.hex });
|
|
const label = document.createElement('div');
|
|
label.className = 'label';
|
|
label.textContent = item.label || meta.name || meta.hex || 'Color';
|
|
const metaLine = document.createElement('div');
|
|
metaLine.className = 'meta';
|
|
metaLine.textContent = item.metaText || meta.hex || '';
|
|
sw.appendChild(swatch);
|
|
sw.appendChild(label);
|
|
sw.appendChild(metaLine);
|
|
sw.addEventListener('click', () => {
|
|
close();
|
|
onSelect?.(item);
|
|
});
|
|
grid.appendChild(sw);
|
|
});
|
|
modal.classList.remove('hidden');
|
|
};
|
|
})();
|
|
|
|
function getImageHref(el) {
|
|
return el.getAttribute('href') || (XLINK_NS ? el.getAttributeNS(XLINK_NS, 'href') : null);
|
|
}
|
|
function setImageHref(el, val) {
|
|
el.setAttribute('href', val);
|
|
if (XLINK_NS) el.setAttributeNS(XLINK_NS, 'xlink:href', val);
|
|
}
|
|
|
|
// 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);
|
|
};
|
|
|
|
async function buildClassicSvgExportFromDom() {
|
|
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);
|
|
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 window.shared.imageUrlToDataUrl(href);
|
|
if (dataUrl) setImageHref(img, dataUrl);
|
|
}));
|
|
if (!clonedSvg.getAttribute('xmlns')) clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
|
if (!clonedSvg.getAttribute('xmlns:xlink') && XLINK_NS) clonedSvg.setAttribute('xmlns:xlink', XLINK_NS);
|
|
|
|
let width = Number(clonedSvg.getAttribute('width')) || null;
|
|
let height = Number(clonedSvg.getAttribute('height')) || null;
|
|
const viewBox = (clonedSvg.getAttribute('viewBox') || '').split(/\s+/).map(Number);
|
|
if ((!width || !height) && viewBox.length === 4 && viewBox.every(v => Number.isFinite(v))) {
|
|
width = width || viewBox[2];
|
|
height = height || viewBox[3];
|
|
}
|
|
if (!width || !height) {
|
|
const vbW = svgElement.clientWidth || 1000;
|
|
const vbH = svgElement.clientHeight || 1000;
|
|
width = width || vbW;
|
|
height = height || vbH;
|
|
}
|
|
clonedSvg.setAttribute('width', width);
|
|
clonedSvg.setAttribute('height', height);
|
|
|
|
return { svgString: new XMLSerializer().serializeToString(clonedSvg), width, height };
|
|
}
|
|
|
|
async function buildWallSvgExportFromDom() {
|
|
const svgElement = document.querySelector('#wall-display svg');
|
|
if (!svgElement) throw new Error('Wall design not found. Please create a design first.');
|
|
const clonedSvg = svgElement.cloneNode(true);
|
|
|
|
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 window.shared.imageUrlToDataUrl(href);
|
|
if (dataUrl) setImageHref(img, dataUrl);
|
|
}));
|
|
|
|
if (!clonedSvg.getAttribute('xmlns')) clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
|
if (!clonedSvg.getAttribute('xmlns:xlink') && XLINK_NS) clonedSvg.setAttribute('xmlns:xlink', XLINK_NS);
|
|
|
|
let width = Number(clonedSvg.getAttribute('width')) || null;
|
|
let height = Number(clonedSvg.getAttribute('height')) || null;
|
|
const viewBox = (clonedSvg.getAttribute('viewBox') || '').split(/\s+/).map(Number);
|
|
if ((!width || !height) && viewBox.length === 4 && viewBox.every(v => Number.isFinite(v))) {
|
|
width = width || viewBox[2];
|
|
height = height || viewBox[3];
|
|
}
|
|
if (!width || !height) {
|
|
const vbW = svgElement.clientWidth || 1000;
|
|
const vbH = svgElement.clientHeight || 1000;
|
|
width = width || vbW;
|
|
height = height || vbH;
|
|
}
|
|
clonedSvg.setAttribute('width', width);
|
|
clonedSvg.setAttribute('height', height);
|
|
|
|
return { svgString: new XMLSerializer().serializeToString(clonedSvg), width, height };
|
|
}
|
|
|
|
// Export routing
|
|
async function buildWallSvgExportFromDom() {
|
|
const svgElement = document.querySelector('#wall-display svg');
|
|
if (!svgElement) throw new Error('Wall design not found. Please create a design first.');
|
|
const clonedSvg = svgElement.cloneNode(true);
|
|
|
|
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 window.shared.imageUrlToDataUrl(href);
|
|
if (dataUrl) setImageHref(img, dataUrl);
|
|
}));
|
|
|
|
if (!clonedSvg.getAttribute('xmlns')) clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
|
if (!clonedSvg.getAttribute('xmlns:xlink') && XLINK_NS) clonedSvg.setAttribute('xmlns:xlink', XLINK_NS);
|
|
|
|
let width = Number(clonedSvg.getAttribute('width')) || null;
|
|
let height = Number(clonedSvg.getAttribute('height')) || null;
|
|
const viewBox = (clonedSvg.getAttribute('viewBox') || '').split(/\s+/).map(Number);
|
|
if ((!width || !height) && viewBox.length === 4 && viewBox.every(v => Number.isFinite(v))) {
|
|
width = width || viewBox[2];
|
|
height = height || viewBox[3];
|
|
}
|
|
if (!width || !height) {
|
|
const vbW = svgElement.clientWidth || 1000;
|
|
const vbH = svgElement.clientHeight || 1000;
|
|
width = width || vbW;
|
|
height = height || vbH;
|
|
}
|
|
clonedSvg.setAttribute('width', width);
|
|
clonedSvg.setAttribute('height', height);
|
|
|
|
const svgString = new XMLSerializer().serializeToString(clonedSvg);
|
|
return { svgString, width, height };
|
|
}
|
|
async function inlineSvgImages(svgString) {
|
|
try {
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(svgString, 'image/svg+xml');
|
|
if (doc.querySelector('parsererror')) return svgString;
|
|
const imgs = Array.from(doc.querySelectorAll('image'));
|
|
await Promise.all(imgs.map(async img => {
|
|
const href = img.getAttribute('href') || img.getAttribute('xlink:href');
|
|
if (!href || href.startsWith('data:')) return;
|
|
const dataUrl = await window.shared.imageUrlToDataUrl(href);
|
|
if (dataUrl) {
|
|
img.setAttribute('href', dataUrl);
|
|
img.removeAttribute('xlink:href');
|
|
}
|
|
}));
|
|
return new XMLSerializer().serializeToString(doc.documentElement);
|
|
} catch (err) {
|
|
console.warn('[Export] inlineSvgImages failed', err);
|
|
return svgString;
|
|
}
|
|
}
|
|
|
|
async function svgStringToPng(svgString, width, height) {
|
|
const { PNG_EXPORT_SCALE } = window.shared;
|
|
if (!/xmlns=/.test(svgString)) {
|
|
svgString = svgString.replace('<svg', '<svg xmlns="http://www.w3.org/2000/svg"');
|
|
}
|
|
// Inline any external images to avoid CORS/fetch issues.
|
|
svgString = await inlineSvgImages(svgString);
|
|
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;
|
|
});
|
|
|
|
// Prefer base64 data URL to avoid parser issues with raw text.
|
|
const svg64 = btoa(unescape(encodeURIComponent(svgString)));
|
|
const dataUrl = `data:image/svg+xml;base64,${svg64}`;
|
|
try {
|
|
await loadSrc(dataUrl);
|
|
} catch (e) {
|
|
// Fallback to blob if base64 path fails.
|
|
let blobUrl = null;
|
|
try {
|
|
const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
|
|
blobUrl = URL.createObjectURL(blob);
|
|
await loadSrc(blobUrl);
|
|
} 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 } = window.ClassicExport?.buildClassicSvgPayload
|
|
? await window.ClassicExport.buildClassicSvgPayload()
|
|
: await buildClassicSvgExportFromDom();
|
|
const pngUrl = await svgStringToPng(svgString, width, height);
|
|
window.shared.download(pngUrl, 'classic_design.png');
|
|
return;
|
|
}
|
|
if (tab === '#tab-wall') {
|
|
const { svgString, width, height } = await buildWallSvgExportFromDom();
|
|
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 } = window.ClassicExport?.buildClassicSvgPayload
|
|
? await window.ClassicExport.buildClassicSvgPayload()
|
|
: await buildClassicSvgExportFromDom();
|
|
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, width, height } = await buildWallSvgExportFromDom();
|
|
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.');
|
|
}
|
|
}
|
|
|
|
const openExportModal = () => { exportModal?.classList.remove('hidden'); };
|
|
const closeExportModal = () => { exportModal?.classList.add('hidden'); };
|
|
exportModalClose?.addEventListener('click', closeExportModal);
|
|
exportModal?.addEventListener('click', (e) => { if (e.target === exportModal) closeExportModal(); });
|
|
document.body.addEventListener('click', e => {
|
|
const choice = e.target.closest('[data-export-choice]');
|
|
if (choice) {
|
|
const type = choice.dataset.exportChoice;
|
|
closeExportModal();
|
|
if (type === 'png') exportPng();
|
|
else if (type === 'svg') exportSvg();
|
|
return;
|
|
}
|
|
const btn = e.target.closest('[data-export]');
|
|
if (!btn) return;
|
|
e.preventDefault();
|
|
openExportModal();
|
|
});
|
|
|
|
// 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');
|
|
const heliumVisible = !document.getElementById('tab-helium')?.classList.contains('hidden');
|
|
|
|
let id = bodyActive || activeBtn?.dataset?.target;
|
|
if (!id) {
|
|
if (classicVisible && !organicVisible && !wallVisible && !heliumVisible) id = '#tab-classic';
|
|
else if (organicVisible && !classicVisible && !wallVisible && !heliumVisible) id = '#tab-organic';
|
|
else if (wallVisible && !classicVisible && !organicVisible && !heliumVisible) id = '#tab-wall';
|
|
else if (heliumVisible && !classicVisible && !organicVisible && !wallVisible) id = '#tab-helium';
|
|
}
|
|
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';
|
|
const usesOrganicWorkspace = tab === '#tab-organic' || tab === '#tab-helium';
|
|
if (orgSheet) orgSheet.classList.toggle('hidden', hide || !usesOrganicWorkspace);
|
|
if (claSheet) claSheet.classList.toggle('hidden', hide || tab !== '#tab-classic');
|
|
if (wallSheet) wallSheet.classList.toggle('hidden', hide || tab !== '#tab-wall');
|
|
if (heliumSheet) heliumSheet.classList.add('hidden');
|
|
}
|
|
|
|
function getCurrentMobilePanel(currentTab) {
|
|
const orgPanel = document.getElementById('controls-panel');
|
|
const claPanel = document.getElementById('classic-controls-panel');
|
|
const wallPanel = document.getElementById('wall-controls-panel');
|
|
const heliumPanel = document.getElementById('helium-controls-panel');
|
|
if (currentTab === '#tab-classic') return claPanel;
|
|
if (currentTab === '#tab-wall') return wallPanel;
|
|
if (currentTab === '#tab-helium') {
|
|
const heliumHasStacks = !!heliumPanel?.querySelector?.('.control-stack[data-mobile-tab]');
|
|
return heliumHasStacks ? heliumPanel : orgPanel;
|
|
}
|
|
return orgPanel;
|
|
}
|
|
|
|
function updateMobileStacks(tabName) {
|
|
const currentTab = detectCurrentTab();
|
|
const panel = getCurrentMobilePanel(currentTab);
|
|
const target = tabName || document.body?.dataset?.mobileTab || MOBILE_TAB_DEFAULT;
|
|
const isHidden = document.body?.dataset?.controlsHidden === '1';
|
|
const isDesktop = window.matchMedia('(min-width: 1024px)').matches;
|
|
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 if (isDesktop) {
|
|
stack.style.display = '';
|
|
} else {
|
|
const show = stack.dataset.mobileTab === target;
|
|
stack.style.display = show ? 'block' : 'none';
|
|
}
|
|
});
|
|
}
|
|
|
|
function setMobileTab(tab, mainTabId, skipPersist = false) {
|
|
const name = tab || MOBILE_TAB_DEFAULT;
|
|
const activeMainTab = mainTabId || detectCurrentTab();
|
|
if (!skipPersist && activeMainTab) {
|
|
try { localStorage.setItem(`${MOBILE_TAB_PREFIX}${activeMainTab}`, name); } catch {}
|
|
}
|
|
const isDesktop = window.matchMedia('(min-width: 1024px)').matches;
|
|
if (document.body) {
|
|
document.body.dataset.mobileTab = name;
|
|
delete document.body.dataset.controlsHidden;
|
|
}
|
|
// Ensure the current panel is not minimized/hidden when we select a tab.
|
|
const panel = getCurrentMobilePanel(activeMainTab);
|
|
panel?.classList.remove('minimized');
|
|
if (panel) panel.style.display = '';
|
|
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';
|
|
const usesOrganicWorkspace = () => current === '#tab-organic' || current === '#tab-helium';
|
|
const syncOrganicWorkspaceLabels = () => {
|
|
const title = document.querySelector('#controls-panel .panel-title');
|
|
if (title) title.textContent = current === '#tab-helium' ? 'Helium Controls' : 'Organic Controls';
|
|
};
|
|
const isMobileView = () => window.matchMedia('(max-width: 1023px)').matches;
|
|
const updateMobileActionBarVisibility = () => {
|
|
const modalOpen = !!document.querySelector('.color-modal:not(.hidden)');
|
|
const isMobile = isMobileView();
|
|
const showOrganic = isMobile && !modalOpen && usesOrganicWorkspace();
|
|
if (mobileActionBar) mobileActionBar.classList.toggle('hidden', !showOrganic);
|
|
};
|
|
const wireMobileActionButtons = () => {
|
|
const guardOrganic = () => usesOrganicWorkspace();
|
|
const clickBtn = (sel) => { if (!guardOrganic()) return; document.querySelector(sel)?.click(); };
|
|
const on = (id, fn) => document.getElementById(id)?.addEventListener('click', fn);
|
|
on('mobile-act-undo', () => clickBtn('#tool-undo'));
|
|
on('mobile-act-redo', () => clickBtn('#tool-redo'));
|
|
on('mobile-act-eyedrop', () => clickBtn('#tool-eyedropper'));
|
|
on('mobile-act-erase', () => {
|
|
if (!guardOrganic()) return;
|
|
const erase = document.getElementById('tool-erase');
|
|
const draw = document.getElementById('tool-draw');
|
|
const active = erase?.getAttribute('aria-pressed') === 'true';
|
|
(active ? draw : erase)?.click();
|
|
});
|
|
on('mobile-act-clear', () => clickBtn('#clear-canvas-btn-top'));
|
|
on('mobile-act-export', () => clickBtn('[data-export="png"]'));
|
|
};
|
|
wireMobileActionButtons();
|
|
|
|
window.addEventListener('resize', updateMobileActionBarVisibility);
|
|
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');
|
|
const useOrganicWorkspace = id === '#tab-organic' || id === '#tab-helium';
|
|
orgSection.classList.toggle('hidden', !useOrganicWorkspace);
|
|
claSection.classList.toggle('hidden', id !== '#tab-classic');
|
|
wallSection?.classList.toggle('hidden', id !== '#tab-wall');
|
|
heliumSection?.classList.add('hidden');
|
|
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 = useOrganicWorkspace;
|
|
const showHeaderColor = id !== '#tab-classic';
|
|
const clearTop = document.getElementById('clear-canvas-btn-top');
|
|
if (clearTop) {
|
|
clearTop.classList.toggle('hidden', !isOrganic);
|
|
clearTop.style.display = isOrganic ? '' : 'none';
|
|
}
|
|
const headerActiveSwatch = document.getElementById('current-color-chip-global')?.closest('.flex');
|
|
headerActiveSwatch?.classList.toggle('hidden', !showHeaderColor);
|
|
const savedMobile = (() => {
|
|
try { return localStorage.getItem(`${MOBILE_TAB_PREFIX}${id}`) || MOBILE_TAB_DEFAULT; } catch {}
|
|
return MOBILE_TAB_DEFAULT;
|
|
})();
|
|
if (document.body) document.body.dataset.mobileTab = savedMobile;
|
|
setMobileTab(savedMobile, id, true);
|
|
orgSheet?.classList.toggle('hidden', !useOrganicWorkspace);
|
|
claSheet?.classList.toggle('hidden', id !== '#tab-classic');
|
|
wallSheet?.classList.toggle('hidden', id !== '#tab-wall');
|
|
heliumSheet?.classList.add('hidden');
|
|
syncOrganicWorkspaceLabels();
|
|
window.updateExportButtonVisibility();
|
|
updateMobileActionBarVisibility();
|
|
}
|
|
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) {
|
|
const initialMobile = (() => {
|
|
try { return localStorage.getItem(`${MOBILE_TAB_PREFIX}${current}`) || MOBILE_TAB_DEFAULT; } catch {}
|
|
return MOBILE_TAB_DEFAULT;
|
|
})();
|
|
document.body.dataset.mobileTab = initialMobile;
|
|
}
|
|
setMobileTab(document.body.dataset.mobileTab, current, true);
|
|
updateSheets();
|
|
updateMobileStacks(document.body.dataset.mobileTab);
|
|
updateMobileActionBarVisibility();
|
|
}
|
|
|
|
// 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 = getCurrentMobilePanel(activeTabId);
|
|
const currentTab = document.body.dataset.mobileTab;
|
|
if (!panel) return;
|
|
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 = () => {
|
|
const current = document.body?.dataset?.mobileTab || MOBILE_TAB_DEFAULT;
|
|
setMobileTab(current, detectCurrentTab(), true);
|
|
updateFloatingNudge();
|
|
};
|
|
mq.addEventListener('change', sync);
|
|
setMobileTab(document.body?.dataset?.mobileTab || MOBILE_TAB_DEFAULT, detectCurrentTab(), true);
|
|
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);
|
|
});
|
|
}
|
|
})();
|
|
});
|
|
})();
|