Compare commits
No commits in common. "7e6ac4cf4b6150abe830dedcc177ae76f8040da5" and "f22319737eadade20ad9b12d71c8c4699e7920c6" have entirely different histories.
7e6ac4cf4b
...
f22319737e
65
classic.js
65
classic.js
@ -1103,69 +1103,4 @@ function distinctPaletteSlots(palette) {
|
||||
|
||||
window.ClassicDesigner = window.ClassicDesigner || { init: initClassic, api: null, redraw: null };
|
||||
document.addEventListener('DOMContentLoaded', () => { if (document.getElementById('classic-display') && !window.__classicInit) { window.__classicInit = true; initClassic(); } });
|
||||
|
||||
// Export helper for tab-level routing
|
||||
(function setupClassicExport() {
|
||||
const { imageUrlToDataUrl, XLINK_NS } = window.shared || {};
|
||||
if (!imageUrlToDataUrl || !XLINK_NS) return;
|
||||
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);
|
||||
}
|
||||
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 {}
|
||||
|
||||
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);
|
||||
}));
|
||||
|
||||
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);
|
||||
|
||||
clonedSvg.querySelectorAll('g.balloon, path.balloon, ellipse.balloon, circle.balloon').forEach(el => {
|
||||
if (!el.getAttribute('stroke')) el.setAttribute('stroke', '#111827');
|
||||
if (!el.getAttribute('stroke-width')) el.setAttribute('stroke-width', '1');
|
||||
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 };
|
||||
}
|
||||
window.ClassicExport = { buildClassicSvgPayload };
|
||||
})();
|
||||
})();
|
||||
|
||||
214
index.html
214
index.html
@ -26,7 +26,7 @@
|
||||
<body class="p-0 md:p-6 flex flex-col items-center justify-start min-h-screen bg-[conic-gradient(at_top_left,_var(--tw-gradient-stops))] from-indigo-100 via-white to-pink-100 text-slate-800 overflow-hidden">
|
||||
<div class="container mx-auto mt-2 p-4 lg:p-6 bg-white/80 lg:backdrop-blur-xl rounded-3xl border border-white/50 shadow-2xl flex flex-col gap-4 max-w-7xl lg:h-[calc(100vh-2rem)] overflow-hidden ring-1 ring-black/5">
|
||||
|
||||
<header id="app-header" class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3 px-1 lg:px-0">
|
||||
<header class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3 px-1 lg:px-0">
|
||||
<div class="flex items-center gap-3">
|
||||
|
||||
<div>
|
||||
@ -38,7 +38,6 @@
|
||||
<nav id="mode-tabs" class="flex gap-2">
|
||||
<button type="button" class="tab-btn tab-active" data-target="#tab-organic" aria-pressed="true">Organic</button>
|
||||
<button type="button" class="tab-btn tab-idle" data-target="#tab-classic" aria-pressed="false">Classic (Arch/Column)</button>
|
||||
<button type="button" class="tab-btn tab-idle" data-target="#tab-wall" aria-pressed="false">Wall</button>
|
||||
</nav>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-1 px-2 py-1 rounded-xl bg-white/70 border border-gray-200 shadow-sm" title="Active Color">
|
||||
@ -196,17 +195,14 @@
|
||||
</div>
|
||||
|
||||
<div class="panel-heading mt-4">Replace Color</div>
|
||||
<div class="panel-card space-y-3">
|
||||
<div class="flex items-center gap-2 replace-row">
|
||||
<button type="button" class="replace-chip" id="replace-from-chip" aria-label="Pick color to replace"></button>
|
||||
<span class="text-xs font-semibold text-slate-500">→</span>
|
||||
<button type="button" class="replace-chip" id="replace-to-chip" aria-label="Pick replacement color"></button>
|
||||
<span id="replace-count" class="text-xs text-slate-500 ml-auto"></span>
|
||||
</div>
|
||||
<div class="panel-card">
|
||||
<div class="grid grid-cols-1 gap-2">
|
||||
<p class="hint text-xs">Tap a chip to choose colors. “From” shows only colors used on canvas.</p>
|
||||
<select id="replace-from" class="sr-only"></select>
|
||||
<select id="replace-to" class="sr-only"></select>
|
||||
<label class="text-sm font-medium">From (in design):</label>
|
||||
<select id="replace-from" class="select"></select>
|
||||
|
||||
<label class="text-sm font-medium">To (library):</label>
|
||||
<select id="replace-to" class="select"></select>
|
||||
|
||||
<button id="replace-btn" class="btn-blue">Replace</button>
|
||||
<p id="replace-msg" class="hint"></p>
|
||||
</div>
|
||||
@ -422,130 +418,6 @@
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section id="tab-wall" class="hidden flex flex-col lg:flex-row gap-4 lg:h-[calc(100vh-10rem)]">
|
||||
<aside id="wall-controls-panel" class="control-sheet lg:static lg:w-[360px] lg:max-h-none lg:overflow-y-auto">
|
||||
<div class="panel-header-row">
|
||||
<h2 class="panel-title">Wall Controls</h2>
|
||||
</div>
|
||||
<div class="control-stack" data-mobile-tab="controls">
|
||||
<div class="panel-heading">Grid</div>
|
||||
<div class="panel-card grid grid-cols-2 gap-3">
|
||||
<label class="text-sm font-medium flex flex-col gap-1">Columns
|
||||
<input id="wall-cols" type="number" min="2" max="20" step="1" value="9" class="w-full px-2 py-1 border rounded">
|
||||
</label>
|
||||
<label class="text-sm font-medium flex flex-col gap-1">Rows
|
||||
<input id="wall-rows" type="number" min="2" max="20" step="1" value="7" class="w-full px-2 py-1 border rounded">
|
||||
</label>
|
||||
<label class="text-sm font-medium flex flex-col gap-1 col-span-2">Pattern
|
||||
<select id="wall-pattern" class="select">
|
||||
<option value="grid">Square Grid</option>
|
||||
<option value="x">X / Diamond</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="text-sm font-medium inline-flex items-center gap-2 col-span-2">
|
||||
<input id="wall-show-wire" type="checkbox" class="align-middle" checked>
|
||||
Show wireframe for empty spots
|
||||
</label>
|
||||
<label class="text-sm font-medium inline-flex items-center gap-2 col-span-2">
|
||||
<input id="wall-outline" type="checkbox" class="align-middle">
|
||||
Outline balloons
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="panel-heading mt-4">Tools</div>
|
||||
<div class="panel-card">
|
||||
<div class="wall-toolbar">
|
||||
<button type="button" id="wall-tool-paint" class="tool-btn" aria-pressed="true">
|
||||
<i class="fa-solid fa-brush"></i>
|
||||
<span>Paint</span>
|
||||
</button>
|
||||
<button type="button" id="wall-tool-erase" class="tool-btn" aria-pressed="false">
|
||||
<i class="fa-solid fa-eraser"></i>
|
||||
<span>Erase</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="hint mt-2 text-xs">Paint applies the active color; Erase clears. Hold Shift/Ctrl for temporary erase.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-stack" data-mobile-tab="colors">
|
||||
<div class="panel-heading mt-4">Active Color</div>
|
||||
<div class="panel-card">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm font-medium text-gray-700">Current</span>
|
||||
<div id="wall-active-color-chip" class="current-color-chip">
|
||||
<span id="wall-active-color-label" class="text-[10px] font-semibold text-slate-700"></span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint mt-2">Tap a swatch to set. Tap a balloon to paint; tap again (same color) to clear. Alt+click (desktop) to pick.</p>
|
||||
</div>
|
||||
<div class="panel-heading mt-4">Used Colors</div>
|
||||
<div class="panel-card">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="text-xs text-gray-600">Click to pick. Remove unused clears empty/transparent entries.</div>
|
||||
<button type="button" id="wall-remove-unused" class="btn-yellow text-xs px-2 py-1">Remove Unused</button>
|
||||
</div>
|
||||
<div id="wall-used-palette" class="palette-box min-h-[2.4rem]"></div>
|
||||
</div>
|
||||
<div class="panel-heading mt-4">Wall Palette</div>
|
||||
<div class="panel-card">
|
||||
<div id="wall-palette" class="palette-box min-h-[3rem]"></div>
|
||||
</div>
|
||||
<div class="panel-heading mt-4">Replace Colors</div>
|
||||
<div class="panel-card space-y-3">
|
||||
<div class="flex items-center gap-2 replace-row">
|
||||
<button type="button" class="replace-chip" id="wall-replace-from-chip" aria-label="Pick wall color to replace"></button>
|
||||
<span class="text-xs font-semibold text-slate-500">→</span>
|
||||
<button type="button" class="replace-chip" id="wall-replace-to-chip" aria-label="Pick wall replacement color"></button>
|
||||
<span id="wall-replace-count" class="text-xs text-slate-500 ml-auto"></span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-2">
|
||||
<p class="hint text-xs">Tap a chip to choose colors. “Replace” shows only colors used in this wall.</p>
|
||||
<select id="wall-replace-from" class="sr-only"></select>
|
||||
<select id="wall-replace-to" class="sr-only"></select>
|
||||
<button type="button" id="wall-replace-btn" class="btn-dark text-sm">Replace</button>
|
||||
<div id="wall-replace-msg" class="text-xs text-gray-500"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-stack" data-mobile-tab="save">
|
||||
<div class="panel-heading">Save & Export</div>
|
||||
<div class="panel-card space-y-3">
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button class="btn-dark bg-blue-600" data-export="png">Export PNG</button>
|
||||
<button class="btn-dark bg-blue-700" data-export="svg">Export SVG</button>
|
||||
<p class="hint w-full">Exports the current wall view.</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="panel-heading text-sm">Quick Paint (uses active color)</div>
|
||||
<div class="grid grid-cols-2 gap-2 mt-2">
|
||||
<button type="button" id="wall-paint-links" class="btn-blue text-xs px-2 py-2">Paint Links</button>
|
||||
<button type="button" id="wall-paint-small" class="btn-blue text-xs px-2 py-2">Paint 5" Nodes</button>
|
||||
<button type="button" id="wall-paint-gaps" class="btn-blue text-xs px-2 py-2">Paint 11" Gaps</button>
|
||||
</div>
|
||||
<p class="hint mt-2">Fills only that group; pattern-aware for Grid vs X.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button type="button" id="wall-clear" class="btn-danger text-sm px-3 py-2 flex-1">Clear</button>
|
||||
<button type="button" id="wall-fill-all" class="btn-blue text-sm px-3 py-2 flex-1">Fill All</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section id="wall-canvas-panel"
|
||||
class="order-1 lg:order-2 w-full lg:flex-1 flex flex-col items-stretch rounded-2xl overflow-hidden bg-white/50 shadow-inner ring-1 ring-black/5">
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-200 bg-white/70">
|
||||
<div class="text-base font-semibold text-slate-700">Balloon Wall</div>
|
||||
<div class="text-sm text-gray-500">Columns/Rows: <span id="wall-grid-label">9 × 7</span></div>
|
||||
</div>
|
||||
<div id="wall-display" class="flex-1 bg-white relative overflow-auto">
|
||||
<div class="p-6 text-gray-500 text-sm">Wall designer will load here.</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<div id="mobile-tabbar" class="mobile-tabbar">
|
||||
@ -563,71 +435,6 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Mobile sticky action bar -->
|
||||
<div id="mobile-action-bar" class="mobile-action-bar hidden">
|
||||
<div class="mobile-action-chip" id="mobile-active-color-chip" title="Active color"></div>
|
||||
<div class="mobile-action-row">
|
||||
<button type="button" class="mobile-action-btn" id="mobile-act-undo" aria-label="Undo">
|
||||
<i class="fa-solid fa-rotate-left" aria-hidden="true"></i>
|
||||
<span>Undo</span>
|
||||
</button>
|
||||
<button type="button" class="mobile-action-btn" id="mobile-act-redo" aria-label="Redo">
|
||||
<i class="fa-solid fa-rotate-right" aria-hidden="true"></i>
|
||||
<span>Redo</span>
|
||||
</button>
|
||||
<button type="button" class="mobile-action-btn" id="mobile-act-eyedrop" aria-label="Eyedropper">
|
||||
<i class="fa-solid fa-eye-dropper" aria-hidden="true"></i>
|
||||
<span>Pick</span>
|
||||
</button>
|
||||
<button type="button" class="mobile-action-btn" id="mobile-act-erase" aria-label="Toggle Erase">
|
||||
<i class="fa-solid fa-eraser" aria-hidden="true"></i>
|
||||
<span>Erase</span>
|
||||
</button>
|
||||
<button type="button" class="mobile-action-btn danger" id="mobile-act-clear" aria-label="Clear canvas">
|
||||
<i class="fa-solid fa-trash" aria-hidden="true"></i>
|
||||
<span>Clear</span>
|
||||
</button>
|
||||
<button type="button" class="mobile-action-btn" id="mobile-act-export" aria-label="Export PNG">
|
||||
<i class="fa-solid fa-download" aria-hidden="true"></i>
|
||||
<span>Export</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Color picker modal -->
|
||||
<div id="color-picker-modal" class="color-modal hidden" role="dialog" aria-modal="true" aria-labelledby="color-picker-title">
|
||||
<div class="color-modal-backdrop"></div>
|
||||
<div class="color-modal-card">
|
||||
<div class="color-modal-header">
|
||||
<div>
|
||||
<div id="color-picker-title" class="color-modal-title">Choose a color</div>
|
||||
<div id="color-picker-subtitle" class="color-modal-subtitle"></div>
|
||||
</div>
|
||||
<button type="button" id="color-picker-close" class="color-modal-close" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div id="color-picker-grid" class="color-modal-grid"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export modal -->
|
||||
<div id="export-modal" class="color-modal hidden" role="dialog" aria-modal="true" aria-labelledby="export-modal-title">
|
||||
<div class="color-modal-backdrop"></div>
|
||||
<div class="color-modal-card">
|
||||
<div class="color-modal-header">
|
||||
<div>
|
||||
<div id="export-modal-title" class="color-modal-title">Export design</div>
|
||||
<div class="color-modal-subtitle">Choose a format to download</div>
|
||||
</div>
|
||||
<button type="button" id="export-modal-close" class="color-modal-close" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<button type="button" class="btn-blue flex-1" data-export-choice="png">Export PNG</button>
|
||||
<button type="button" class="btn-dark flex-1" data-export-choice="svg">Export SVG</button>
|
||||
</div>
|
||||
<p class="hint mt-2 text-xs text-slate-500">SVG keeps vector shapes where possible (textures/images stay raster). PNG renders a high-res snapshot.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="message-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-gray-900 bg-opacity-50">
|
||||
<div class="bg-white p-6 rounded-lg shadow-lg max-w-sm text-center">
|
||||
<p id="modal-text" class="text-gray-800 text-lg"></p>
|
||||
@ -637,11 +444,8 @@
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/lz-string@1.5.0/libs/lz-string.min.js" defer></script>
|
||||
|
||||
<!-- Palette must load before shared.js; it is already included in the <head>. -->
|
||||
<script src="shared.js" defer></script>
|
||||
<script src="script.js" defer></script>
|
||||
<script src="organic.js" defer></script>
|
||||
<script src="wall.js" defer></script>
|
||||
|
||||
<script src="classic.js" defer></script>
|
||||
|
||||
</body>
|
||||
|
||||
2146
organic.js
2146
organic.js
File diff suppressed because it is too large
Load Diff
211
shared.js
211
shared.js
@ -1,211 +0,0 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
// Shared helpers & palette flattening
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const PX_PER_INCH = 4;
|
||||
const SIZE_PRESETS = [24, 18, 11, 9, 5];
|
||||
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 clamp = (v, min, max) => Math.max(min, Math.min(max, v));
|
||||
const clamp01 = v => clamp(v, 0, 1);
|
||||
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) {
|
||||
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 = 0.20;
|
||||
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 };
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
const IMG_CACHE = new Map();
|
||||
function getImage(path, onLoad) {
|
||||
if (!path) return null;
|
||||
let img = IMG_CACHE.get(path);
|
||||
if (!img) {
|
||||
img = new Image();
|
||||
// Avoid CORS issues on file:// by only setting crossOrigin for http/https
|
||||
const href = (() => { try { return new URL(path, window.location.href); } catch { return null; } })();
|
||||
const isFile = href?.protocol === 'file:' || window.location.protocol === 'file:';
|
||||
if (!isFile) img.crossOrigin = 'anonymous';
|
||||
img.decoding = 'async';
|
||||
img.loading = 'eager';
|
||||
img.src = path;
|
||||
if (onLoad) img.onload = onLoad;
|
||||
IMG_CACHE.set(path, img);
|
||||
}
|
||||
return img;
|
||||
}
|
||||
|
||||
const DATA_URL_CACHE = new Map();
|
||||
const XLINK_NS = 'http://www.w3.org/1999/xlink';
|
||||
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;
|
||||
// On file:// origins, drawing may be blocked; return null to fall back to original href.
|
||||
if (window.location.protocol === 'file:') 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 abs = (() => { try { return new URL(src, window.location.href).href; } catch { return src; } })();
|
||||
const urlObj = (() => { try { return new URL(abs); } catch { return null; } })();
|
||||
const isFile = urlObj?.protocol === 'file:';
|
||||
|
||||
const cachedImg = IMG_CACHE.get(src);
|
||||
const cachedUrl = imageToDataUrl(cachedImg);
|
||||
if (cachedUrl) { DATA_URL_CACHE.set(src, cachedUrl); return cachedUrl; }
|
||||
|
||||
let dataUrl = null;
|
||||
try {
|
||||
if (isFile) {
|
||||
// On file:// we cannot safely read pixels; return the original path.
|
||||
dataUrl = src;
|
||||
} else {
|
||||
const resp = await fetch(abs);
|
||||
if (!resp.ok) throw new Error(`Status ${resp.status}`);
|
||||
dataUrl = await blobToDataUrl(await resp.blob());
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isFile) console.warn('[Export] Fetch failed for', abs, err);
|
||||
dataUrl = await new Promise(resolve => {
|
||||
const img = new Image();
|
||||
if (!isFile) 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;
|
||||
}
|
||||
|
||||
function download(href, suggestedFilename) {
|
||||
const a = document.createElement('a');
|
||||
a.href = href;
|
||||
a.download = suggestedFilename || 'download';
|
||||
a.rel = 'noopener';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
|
||||
window.shared = {
|
||||
PX_PER_INCH,
|
||||
SIZE_PRESETS,
|
||||
TEXTURE_ZOOM_DEFAULT,
|
||||
TEXTURE_FOCUS_DEFAULT,
|
||||
SWATCH_TEXTURE_ZOOM,
|
||||
PNG_EXPORT_SCALE,
|
||||
clamp,
|
||||
clamp01,
|
||||
normalizeHex,
|
||||
hexToRgb,
|
||||
shineStyle,
|
||||
luminance,
|
||||
FLAT_COLORS,
|
||||
NAME_BY_HEX,
|
||||
HEX_TO_FIRST_IDX,
|
||||
allowedSet,
|
||||
getImage,
|
||||
DATA_URL_CACHE,
|
||||
XLINK_NS,
|
||||
blobToDataUrl,
|
||||
imageToDataUrl,
|
||||
imageUrlToDataUrl,
|
||||
download,
|
||||
};
|
||||
});
|
||||
})();
|
||||
282
style.css
282
style.css
@ -1,33 +1,5 @@
|
||||
/* Minimal extras (Tailwind handles most styling) */
|
||||
body { color: #1f2937; }
|
||||
body[data-active-tab="#tab-classic"] #clear-canvas-btn-top,
|
||||
body[data-active-tab="#tab-wall"] #clear-canvas-btn-top {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
#app-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 35;
|
||||
background: rgba(255,255,255,0.92);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
padding: .75rem 0.5rem;
|
||||
border-radius: 1.25rem;
|
||||
box-shadow: 0 8px 24px rgba(15,23,42,0.08);
|
||||
}
|
||||
|
||||
#balloon-canvas { touch-action: none; }
|
||||
|
||||
@ -40,31 +12,6 @@ body[data-active-tab="#tab-wall"] #clear-canvas-btn-top {
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn-dark,
|
||||
.btn-blue,
|
||||
.btn-green,
|
||||
.btn-yellow,
|
||||
.btn-danger,
|
||||
.btn-indigo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: .35rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
border: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.btn-dark:focus-visible,
|
||||
.btn-blue:focus-visible,
|
||||
.btn-green:focus-visible,
|
||||
.btn-yellow:focus-visible,
|
||||
.btn-danger:focus-visible,
|
||||
.btn-indigo:focus-visible,
|
||||
.tool-btn:focus-visible {
|
||||
outline: 2px solid #6366f1;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.tool-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -120,14 +67,12 @@ body[data-active-tab="#tab-wall"] #clear-canvas-btn-top {
|
||||
flex-direction: column;
|
||||
gap: .5rem;
|
||||
padding: .5rem;
|
||||
background: rgba(255,255,255,0.82);
|
||||
border: 1px solid rgba(226,232,240,0.9);
|
||||
border-radius: .9rem;
|
||||
background: rgba(255,255,255,0.6); /* More transparent */
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: .75rem;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.05);
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
@ -164,8 +109,6 @@ body[data-active-tab="#tab-wall"] #clear-canvas-btn-top {
|
||||
|
||||
.swatch-row { display:flex; flex-wrap:wrap; gap:.5rem; }
|
||||
.family-title { font-weight:700; color:#334155; margin-top:.25rem; font-size:.9rem; letter-spacing: -0.01em; }
|
||||
#wall-display { min-height: 60vh; }
|
||||
#wall-display svg { width: 100%; height: 100%; display: block; }
|
||||
|
||||
.badge {
|
||||
position:absolute;
|
||||
@ -244,34 +187,6 @@ body[data-active-tab="#tab-wall"] #clear-canvas-btn-top {
|
||||
}
|
||||
.slot-swatch.active::after { display: none; }
|
||||
|
||||
.replace-row {
|
||||
padding: 0.35rem;
|
||||
background: rgba(248,250,252,0.9);
|
||||
border: 1px solid rgba(226,232,240,0.9);
|
||||
border-radius: 12px;
|
||||
}
|
||||
.replace-chip {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
border: 2px solid rgba(51,65,85,0.18);
|
||||
box-shadow: 0 3px 8px rgba(0,0,0,0.06);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wall-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.wall-toolbar .tool-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.topper-type-group {
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
@ -357,13 +272,13 @@ body[data-active-tab="#tab-wall"] #clear-canvas-btn-top {
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.panel-card {
|
||||
background: rgba(255,255,255,0.82);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
border: 1px solid rgba(226,232,240,0.9);
|
||||
background: rgba(255,255,255,0.7);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255,255,255,0.6);
|
||||
border-radius: 1rem;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 12px 30px rgba(15,23,42,0.06);
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.03);
|
||||
}
|
||||
.control-stack {
|
||||
display: flex;
|
||||
@ -423,188 +338,16 @@ body[data-active-tab="#tab-wall"] #clear-canvas-btn-top {
|
||||
@media (max-width: 1023px) {
|
||||
body { padding-bottom: 0; overflow: auto; }
|
||||
html, body { height: auto; overflow: auto; }
|
||||
#current-color-chip-global { display: none; }
|
||||
#clear-canvas-btn-top { display: none !important; }
|
||||
/* Add breathing room under canvases so sheets/tabbar don’t cover content */
|
||||
#classic-display,
|
||||
#wall-display,
|
||||
#balloon-canvas {
|
||||
margin-bottom: 5rem;
|
||||
}
|
||||
|
||||
/* Stack switching: show only the active mobile tab stack across panels */
|
||||
.control-sheet .control-stack { display: none; }
|
||||
body[data-mobile-tab="controls"] #controls-panel [data-mobile-tab="controls"],
|
||||
body[data-mobile-tab="colors"] #controls-panel [data-mobile-tab="colors"],
|
||||
body[data-mobile-tab="save"] #controls-panel [data-mobile-tab="save"],
|
||||
body[data-mobile-tab="colors"] #controls-panel [data-mobile-tab="colors"],
|
||||
body[data-mobile-tab="save"] #controls-panel [data-mobile-tab="save"],
|
||||
body[data-mobile-tab="controls"] #classic-controls-panel [data-mobile-tab="controls"],
|
||||
body[data-mobile-tab="colors"] #classic-controls-panel [data-mobile-tab="colors"],
|
||||
body[data-mobile-tab="save"] #classic-controls-panel [data-mobile-tab="save"],
|
||||
body[data-mobile-tab="controls"] #wall-controls-panel [data-mobile-tab="controls"],
|
||||
body[data-mobile-tab="colors"] #wall-controls-panel [data-mobile-tab="colors"],
|
||||
body[data-mobile-tab="save"] #wall-controls-panel [data-mobile-tab="save"] {
|
||||
body[data-mobile-tab="colors"] #classic-controls-panel [data-mobile-tab="colors"],
|
||||
body[data-mobile-tab="save"] #classic-controls-panel [data-mobile-tab="save"] {
|
||||
display: block;
|
||||
}
|
||||
.control-sheet { bottom: 4.5rem; max-height: 55vh; }
|
||||
.control-sheet.minimized { transform: translateY(95%); }
|
||||
|
||||
/* Larger tap targets and spacing */
|
||||
.tool-btn,
|
||||
.btn-dark,
|
||||
.btn-blue,
|
||||
.btn-green,
|
||||
.btn-yellow,
|
||||
.btn-danger,
|
||||
.btn-indigo {
|
||||
min-height: 44px;
|
||||
padding: 0.75rem 0.85rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.swatch { width: 2.4rem; height: 2.4rem; }
|
||||
.swatch.tiny { width: 1.8rem; height: 1.8rem; }
|
||||
.select { min-height: 44px; }
|
||||
.panel-card { padding: 0.85rem; }
|
||||
}
|
||||
|
||||
.mobile-action-bar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 4.75rem;
|
||||
padding: 0.35rem 0.75rem 0.7rem;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.72) 0%, rgba(255,255,255,0.96) 100%);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
border-top: 1px solid rgba(226,232,240,0.9);
|
||||
box-shadow: 0 -10px 30px rgba(15,23,42,0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
z-index: 45;
|
||||
}
|
||||
|
||||
.color-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 60;
|
||||
}
|
||||
.color-modal.hidden { display: none; }
|
||||
.color-modal-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(15,23,42,0.35);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.color-modal-card {
|
||||
position: relative;
|
||||
width: min(640px, 92vw);
|
||||
max-height: 80vh;
|
||||
background: #fff;
|
||||
border-radius: 1.25rem;
|
||||
padding: 1.1rem 1.1rem 1.25rem;
|
||||
box-shadow: 0 24px 60px rgba(15,23,42,0.2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
z-index: 1;
|
||||
}
|
||||
.color-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.color-modal-title { font-size: 1.1rem; font-weight: 800; color: #0f172a; letter-spacing: -0.01em; }
|
||||
.color-modal-subtitle { font-size: 0.9rem; color: #475569; }
|
||||
.color-modal-close {
|
||||
background: #e2e8f0;
|
||||
border: none;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 12px;
|
||||
font-size: 1.4rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
.color-modal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
|
||||
gap: 0.75rem;
|
||||
overflow-y: auto;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
.color-option {
|
||||
border: 1px solid rgba(226,232,240,0.9);
|
||||
border-radius: 12px;
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
box-shadow: 0 4px 12px rgba(15,23,42,0.05);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
.color-option:hover { transform: translateY(-1px); }
|
||||
.color-option .swatch {
|
||||
width: 2.4rem;
|
||||
height: 2.4rem;
|
||||
border-width: 2px;
|
||||
}
|
||||
.color-option .label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
text-align: center;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.color-option .meta {
|
||||
font-size: 0.72rem;
|
||||
color: #475569;
|
||||
text-align: center;
|
||||
}
|
||||
.mobile-action-bar.hidden { display: none; }
|
||||
.mobile-action-chip {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 14px;
|
||||
border: 2px solid rgba(51,65,85,0.18);
|
||||
box-shadow: 0 4px 10px rgba(0,0,0,0.08);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-color: #fff;
|
||||
}
|
||||
.mobile-action-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 0.35rem;
|
||||
width: 100%;
|
||||
}
|
||||
.mobile-action-btn {
|
||||
background: rgba(255,255,255,0.92);
|
||||
border: 1px solid rgba(226,232,240,0.9);
|
||||
border-radius: 14px;
|
||||
padding: 0.55rem 0.35rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
box-shadow: 0 4px 14px rgba(15,23,42,0.08);
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
.mobile-action-btn i { font-size: 1rem; }
|
||||
.mobile-action-btn.danger { color: #dc2626; border-color: rgba(248,113,113,0.35); }
|
||||
.mobile-action-btn:active { transform: translateY(1px); }
|
||||
.mobile-action-btn.active {
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 2px rgba(37,99,235,0.18), 0 6px 16px rgba(37,99,235,0.2);
|
||||
}
|
||||
|
||||
.mobile-tabbar {
|
||||
@ -671,7 +414,6 @@ body[data-active-tab="#tab-wall"] #clear-canvas-btn-top {
|
||||
border: 1px solid rgba(255,255,255,0.4);
|
||||
}
|
||||
body { padding-bottom: 0; overflow: auto; }
|
||||
.mobile-action-bar { display: none !important; }
|
||||
}
|
||||
|
||||
/* Compact viewport fallback */
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user