merge: incorporate v3 version
This commit is contained in:
commit
0070506d92
91
classic.js
91
classic.js
@ -11,6 +11,60 @@
|
||||
</div>`;
|
||||
};
|
||||
const normHex = (h) => (String(h || '')).trim().toLowerCase();
|
||||
const clamp01 = (v) => Math.max(0, Math.min(1, v));
|
||||
function hexToRgb(hex) {
|
||||
const h = normHex(hex).replace('#', '');
|
||||
if (h.length === 3) {
|
||||
return {
|
||||
r: parseInt(h[0] + h[0], 16) || 0,
|
||||
g: parseInt(h[1] + h[1], 16) || 0,
|
||||
b: parseInt(h[2] + h[2], 16) || 0
|
||||
};
|
||||
}
|
||||
if (h.length === 6) {
|
||||
return {
|
||||
r: parseInt(h.slice(0,2), 16) || 0,
|
||||
g: parseInt(h.slice(2,4), 16) || 0,
|
||||
b: parseInt(h.slice(4,6), 16) || 0
|
||||
};
|
||||
}
|
||||
return { r: 0, g: 0, b: 0 };
|
||||
}
|
||||
function luminance(hex) {
|
||||
const { r, g, b } = hexToRgb(hex);
|
||||
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 classicShineStyle(colorInfo) {
|
||||
const hex = normHex(colorInfo?.hex || colorInfo?.colour || '');
|
||||
if (hex.startsWith('#')) {
|
||||
const lum = luminance(hex);
|
||||
if (lum > 0.7) {
|
||||
const t = clamp01((lum - 0.7) / 0.3);
|
||||
const fillAlpha = 0.22 + (0.10 - 0.22) * t;
|
||||
return {
|
||||
fill: `rgba(0,0,0,${fillAlpha})`,
|
||||
opacity: 1,
|
||||
stroke: null
|
||||
};
|
||||
}
|
||||
}
|
||||
return { fill: '#ffffff', opacity: 0.45, stroke: null };
|
||||
}
|
||||
function textStyleForColor(colorInfo) {
|
||||
if (!colorInfo) return { color: '#0f172a', shadow: 'none' };
|
||||
if (colorInfo.image) return { color: '#f8fafc', shadow: '0 1px 3px rgba(0,0,0,0.55)' };
|
||||
const hex = normHex(colorInfo.hex);
|
||||
if (hex.startsWith('#')) {
|
||||
const lum = luminance(hex);
|
||||
if (lum < 0.5) return { color: '#f8fafc', shadow: '0 1px 3px rgba(0,0,0,0.6)' };
|
||||
return { color: '#0f172a', shadow: '0 1px 2px rgba(255,255,255,0.7)' };
|
||||
}
|
||||
return { color: '#0f172a', shadow: 'none' };
|
||||
}
|
||||
|
||||
// -------- persistent color selection (now supports image textures) ----------
|
||||
const PALETTE_KEY = 'classic:colors:v2';
|
||||
@ -140,7 +194,7 @@
|
||||
const balloonSize = (cell)=> (cell.shape.size ?? 1);
|
||||
const cellScale = (cell)=> balloonSize(cell) * pxUnit;
|
||||
|
||||
function cellView(cell, id, explicitFill, model){
|
||||
function cellView(cell, id, explicitFill, model, colorInfo){
|
||||
const shape = cell.shape;
|
||||
const scale = cellScale(cell);
|
||||
const transform = [(shape.base.transform||''), `scale(${scale})`].join(' ');
|
||||
@ -163,9 +217,13 @@
|
||||
const kids = [shapeEl];
|
||||
const applyShine = model.shineEnabled && (!cell.isTopper || (cell.isTopper && model.topperType === 'round'));
|
||||
if (applyShine) {
|
||||
kids.push(svg('ellipse', {
|
||||
const shine = classicShineStyle(colorInfo);
|
||||
const shineAttrs = {
|
||||
class: 'shine', cx: -0.15, cy: -0.15, rx: 0.22, ry: 0.13,
|
||||
fill: '#ffffff', opacity: 0.45, transform: 'rotate(-25)', 'pointer-events': 'none'
|
||||
fill: shine.fill, opacity: shine.opacity, transform: 'rotate(-25)', 'pointer-events': 'none'
|
||||
};
|
||||
kids.push(svg('ellipse', {
|
||||
...shineAttrs
|
||||
}));
|
||||
}
|
||||
return svg('g', { id, transform }, kids);
|
||||
@ -216,7 +274,7 @@ function distinctPaletteSlots(palette) {
|
||||
];
|
||||
|
||||
for (let cell of cells) {
|
||||
let c, fill;
|
||||
let c, fill, colorInfo;
|
||||
if (cell.isTopper) {
|
||||
const topRowYIndex = 0, topClusterY = pattern.gridY(topRowYIndex, 0) * pxUnit;
|
||||
const regularBalloonRadius = (pattern.balloonShapes['front'] || pattern.balloonShapes['penta'] || pattern.balloonShapes['middle']).size * pxUnit * 0.5;
|
||||
@ -225,6 +283,7 @@ function distinctPaletteSlots(palette) {
|
||||
const topperY = highestPoint - topperRadius - (pxUnit * 0.5) + topperOffsetY_Px;
|
||||
c = { x: topperOffsetX_Px, y: topperY };
|
||||
fill = model.topperColor.image ? `url(#classic-pattern-topper)` : model.topperColor.hex;
|
||||
colorInfo = model.topperColor;
|
||||
} else {
|
||||
c = gridPos(cell.x, cell.y, cell.shape.zIndex, cell.inflate, pattern, model);
|
||||
|
||||
@ -270,14 +329,14 @@ function distinctPaletteSlots(palette) {
|
||||
|
||||
const colorCode = rowColorPatterns[rowIndex][cell.balloonIndexInCluster];
|
||||
cell.colorCode = colorCode;
|
||||
const colorInfo = model.palette[colorCode];
|
||||
colorInfo = model.palette[colorCode];
|
||||
fill = colorInfo ? (colorInfo.image ? `url(#classic-pattern-slot-${colorCode})` : colorInfo.colour) : 'transparent';
|
||||
}
|
||||
|
||||
const scale = cellScale(cell), shapeRadius = cell.shape.base.radius || 0.5, size = shapeRadius * scale;
|
||||
bbox.add(c.x - size, c.y - size);
|
||||
bbox.add(c.x + size, c.y + size);
|
||||
const v = cellView(cell, `balloon_${cell.x}_${cell.y}`, fill, model);
|
||||
const v = cellView(cell, `balloon_${cell.x}_${cell.y}`, fill, model, colorInfo);
|
||||
v.attrs.transform = `translate(${c.x},${c.y}) ${v.attrs.transform || ''}`;
|
||||
const zi = cell.isTopper ? 100 + 2 : (100 + (cell.shape.zIndex || 0));
|
||||
(layers[zi] ||= []).push(v);
|
||||
@ -464,6 +523,7 @@ function distinctPaletteSlots(palette) {
|
||||
|
||||
function initClassicColorPicker(onColorChange) {
|
||||
const slotsContainer = document.getElementById('classic-slots'), topperSwatch = document.getElementById('classic-topper-color-swatch'), swatchGrid = document.getElementById('classic-swatch-grid'), activeLabel = document.getElementById('classic-active-label'), randomizeBtn = document.getElementById('classic-randomize-colors'), addSlotBtn = document.getElementById('classic-add-slot');
|
||||
const topperBlock = document.getElementById('classic-topper-color-block');
|
||||
if (!slotsContainer || !topperSwatch || !swatchGrid || !activeLabel) return;
|
||||
topperSwatch.classList.add('tab-btn');
|
||||
let classicColors = getClassicColors(), activeTarget = '1', slotCount = getStoredSlotCount();
|
||||
@ -504,6 +564,7 @@ function distinctPaletteSlots(palette) {
|
||||
enforceSlotVisibility();
|
||||
const buttons = Array.from(slotsContainer.querySelectorAll('.slot-btn'));
|
||||
[...buttons, topperSwatch].forEach(el => { const id = el.dataset.slot || 'T'; el.classList.toggle('tab-active', activeTarget === id); el.classList.toggle('tab-idle', activeTarget !== id); });
|
||||
buttons.forEach(el => el.classList.toggle('slot-active', activeTarget === el.dataset.slot));
|
||||
|
||||
buttons.forEach((slot, i) => {
|
||||
const color = classicColors[i];
|
||||
@ -512,6 +573,9 @@ function distinctPaletteSlots(palette) {
|
||||
slot.style.backgroundColor = color.hex;
|
||||
slot.style.backgroundSize = '200%';
|
||||
slot.style.backgroundPosition = 'center';
|
||||
const txt = textStyleForColor(color);
|
||||
slot.style.color = txt.color;
|
||||
slot.style.textShadow = txt.shadow;
|
||||
});
|
||||
|
||||
const topperColor = getTopperColor();
|
||||
@ -519,6 +583,13 @@ function distinctPaletteSlots(palette) {
|
||||
topperSwatch.style.backgroundColor = topperColor.hex;
|
||||
topperSwatch.style.backgroundSize = '200%';
|
||||
topperSwatch.style.backgroundPosition = 'center';
|
||||
const topperTxt = textStyleForColor(topperColor);
|
||||
topperSwatch.style.color = topperTxt.color;
|
||||
topperSwatch.style.textShadow = topperTxt.shadow;
|
||||
const patName = (document.getElementById('classic-pattern')?.value || '').toLowerCase();
|
||||
const topperEnabled = document.getElementById('classic-topper-enabled')?.checked;
|
||||
const showTopperColor = patName.includes('column') && (patName.includes('4') || patName.includes('5')) && topperEnabled;
|
||||
if (topperBlock) topperBlock.classList.toggle('hidden', !showTopperColor);
|
||||
|
||||
const patSelect = document.getElementById('classic-pattern');
|
||||
const isStacked = (patSelect?.value || '').toLowerCase().includes('stacked');
|
||||
@ -540,6 +611,8 @@ function distinctPaletteSlots(palette) {
|
||||
(group.colors || []).forEach(colorItem => {
|
||||
const sw = document.createElement('button'); sw.type = 'button'; sw.className = 'swatch'; sw.title = colorItem.name;
|
||||
sw.setAttribute('aria-label', colorItem.name);
|
||||
sw.dataset.hex = normHex(colorItem.hex);
|
||||
if (colorItem.image) sw.dataset.image = colorItem.image;
|
||||
|
||||
sw.style.backgroundImage = colorItem.image ? `url("${colorItem.image}")` : 'none';
|
||||
sw.style.backgroundColor = colorItem.hex;
|
||||
@ -588,6 +661,7 @@ function distinctPaletteSlots(palette) {
|
||||
if (window.updateExportButtonVisibility) window.updateExportButtonVisibility();
|
||||
});
|
||||
updateUI();
|
||||
return updateUI;
|
||||
}
|
||||
|
||||
function initClassic() {
|
||||
@ -612,6 +686,7 @@ function distinctPaletteSlots(palette) {
|
||||
};
|
||||
if (!display) return fail('#classic-display not found');
|
||||
const GC = GridCalculator(), ctrl = GC.controller(display);
|
||||
let refreshClassicPaletteUi = null;
|
||||
|
||||
const getTopperType = () => topperTypeButtons.find(btn => btn.getAttribute('aria-pressed') === 'true')?.dataset.type || 'round';
|
||||
const setTopperType = (type) => {
|
||||
@ -693,6 +768,7 @@ function distinctPaletteSlots(palette) {
|
||||
}
|
||||
window.__updateFloatingNudge?.();
|
||||
if(clusterHint) clusterHint.textContent = `≈ ${Math.round((parseFloat(lengthInp.value) || 0) * 2)} clusters (rule: 2 clusters/ft)`;
|
||||
refreshClassicPaletteUi?.();
|
||||
ctrl.selectPattern(patternName);
|
||||
}
|
||||
|
||||
@ -738,10 +814,11 @@ function distinctPaletteSlots(palette) {
|
||||
.forEach(el => { if (!el) return; const eventType = (el.type === 'range' || el.type === 'number') ? 'input' : 'change'; el.addEventListener(eventType, () => { if (el === topperSizeInp || el === topperEnabledCb) lastPresetKey = 'custom'; updateClassicDesign(); }); });
|
||||
topperEnabledCb?.addEventListener('change', updateClassicDesign);
|
||||
shineEnabledCb?.addEventListener('change', (e) => { const on = !!e.target.checked; GC.setShineEnabled(on); updateClassicDesign(); window.syncAppShine?.(on); });
|
||||
initClassicColorPicker(updateClassicDesign);
|
||||
refreshClassicPaletteUi = initClassicColorPicker(updateClassicDesign);
|
||||
try { const saved = localStorage.getItem('app:shineEnabled:v1'); if (saved !== null && shineEnabledCb) shineEnabledCb.checked = JSON.parse(saved); } catch {}
|
||||
setLengthForPattern();
|
||||
updateClassicDesign();
|
||||
refreshClassicPaletteUi?.();
|
||||
if (window.updateExportButtonVisibility) window.updateExportButtonVisibility();
|
||||
log('Classic ready');
|
||||
} catch (e) { fail(e.message || e); }
|
||||
|
||||
87
index.html
87
index.html
@ -33,10 +33,12 @@
|
||||
<div class="text-xs text-indigo-500 font-bold uppercase tracking-wider">Professional Design Tool</div>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</nav>
|
||||
<div class="flex items-center gap-4">
|
||||
<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>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section id="tab-organic" class="flex flex-col lg:flex-row gap-4 lg:h-[calc(100vh-10rem)]">
|
||||
@ -75,32 +77,23 @@
|
||||
<span class="hidden sm:inline">Picker</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="eraser-controls" class="hidden flex flex-col gap-2">
|
||||
<label class="text-sm font-medium text-gray-700">Eraser Size: <span id="eraser-size-label">30</span>px</label>
|
||||
<input type="range" id="eraser-size" min="10" max="120" value="30" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer">
|
||||
<p class="hint">Click-drag to erase. Preview circle shows the area.</p>
|
||||
</div>
|
||||
<div id="eraser-controls" class="hidden flex flex-col gap-2">
|
||||
<label class="text-sm font-medium text-gray-700">Eraser Size: <span id="eraser-size-label">30</span>px</label>
|
||||
<input type="range" id="eraser-size" min="10" max="120" value="30" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer">
|
||||
<p class="hint">Hover to preview. Click-drag to erase.</p>
|
||||
</div>
|
||||
<div id="select-controls" class="hidden flex flex-col gap-2">
|
||||
<div class="flex gap-2">
|
||||
<button id="delete-selected" class="btn-danger" disabled>Delete</button>
|
||||
<button id="duplicate-selected" class="btn-dark" disabled>Duplicate</button>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<div class="flex items-center gap-2 text-xs text-gray-600 mb-1">
|
||||
<span class="font-semibold">Move</span>
|
||||
<span class="hint">↑↓←→</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 gap-1 max-w-xs">
|
||||
<button type="button" class="btn-dark nudge-selected text-sm py-2" data-dx="0" data-dy="-5" aria-label="Move Selection Up">↑</button>
|
||||
<button type="button" class="btn-dark nudge-selected text-sm py-2" data-dx="5" data-dy="0" aria-label="Move Selection Right">→</button>
|
||||
<button type="button" class="btn-dark nudge-selected text-sm py-2" data-dx="0" data-dy="5" aria-label="Move Selection Down">↓</button>
|
||||
<button type="button" class="btn-dark nudge-selected text-sm py-2" data-dx="-5" data-dy="0" aria-label="Move Selection Left">←</button>
|
||||
</div>
|
||||
<p class="hint">Drag balloons to reposition. Use keyboard arrows for fine nudges.</p>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center gap-2 text-xs text-gray-600">
|
||||
<span class="font-semibold">Resize</span>
|
||||
<input type="range" id="selected-size" min="5" max="200" value="40" class="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer" disabled>
|
||||
<span id="selected-size-label" class="text-xs w-10 text-right">0</span>
|
||||
<input type="range" id="selected-size" min="5" max="32" step="0.5" value="11" class="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer" disabled>
|
||||
<span id="selected-size-label" class="text-xs w-12 text-right">0\"</span>
|
||||
</div>
|
||||
<div class="mt-2 grid grid-cols-2 gap-2">
|
||||
<button type="button" class="btn-dark text-sm py-2" id="bring-forward" disabled>Bring Forward</button>
|
||||
@ -115,18 +108,19 @@
|
||||
</div>
|
||||
|
||||
<div class="panel-heading mt-4">Size & Shine</div>
|
||||
<div class="panel-card">
|
||||
<div id="size-preset-group" class="grid grid-cols-5 gap-2 mb-2"></div>
|
||||
<p class="hint mb-3">Global scale lives in <code>PX_PER_INCH</code> (see <code>script.js</code>).</p>
|
||||
<label class="text-sm inline-flex items-center gap-2 font-medium">
|
||||
<input id="toggle-shine-checkbox" type="checkbox" class="align-middle" checked>
|
||||
Enable Shine
|
||||
</label>
|
||||
<div class="panel-card">
|
||||
<div id="size-preset-group" class="grid grid-cols-5 gap-2 mb-2"></div>
|
||||
<p class="hint mb-3">Size presets adjust the diameter for new balloons.</p>
|
||||
<label class="text-sm inline-flex items-center gap-2 font-medium">
|
||||
<input id="toggle-shine-checkbox" type="checkbox" class="align-middle" checked>
|
||||
Enable Shine
|
||||
</label>
|
||||
<button type="button" id="fit-view-btn" class="btn-dark text-sm mt-3 w-full">Fit to Design</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-stack" data-mobile-tab="colors">
|
||||
<div class="panel-heading">Used Colors</div>
|
||||
<div class="panel-heading">Project Palette</div>
|
||||
<div class="panel-card">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm text-gray-600">Built from the current design. Click a swatch to select that color.</span>
|
||||
@ -135,19 +129,25 @@
|
||||
<div id="used-palette" class="palette-box min-h-[3rem]"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel-heading mt-4">Allowed Colors</div>
|
||||
<div class="panel-heading mt-4">Color Library</div>
|
||||
<div class="panel-card">
|
||||
<p class="hint mb-2">Alt+Click a balloon on canvas to pick its color.</p>
|
||||
<p class="hint mb-2">Alt+click on canvas to sample a balloon’s color.</p>
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<span class="text-sm font-medium text-gray-700">Active Color</span>
|
||||
<div id="current-color-chip" class="current-color-chip">
|
||||
<span id="current-color-label" class="text-xs font-semibold text-slate-700"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="color-palette" class="palette-box"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel-heading mt-4">Replace Color</div>
|
||||
<div class="panel-card">
|
||||
<div class="grid grid-cols-1 gap-2">
|
||||
<label class="text-sm font-medium">From (used):</label>
|
||||
<label class="text-sm font-medium">From (in design):</label>
|
||||
<select id="replace-from" class="select"></select>
|
||||
|
||||
<label class="text-sm font-medium">To (allowed):</label>
|
||||
<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>
|
||||
@ -177,7 +177,7 @@
|
||||
<div class="flex flex-wrap gap-3 mt-2">
|
||||
<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">SVG currently Classic only.</p>
|
||||
<p class="hint w-full">SVG export keeps vectors in Classic; Organic embeds textures.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -268,6 +268,7 @@
|
||||
<input id="classic-reverse" type="checkbox" class="align-middle">
|
||||
Reverse spiral
|
||||
</label>
|
||||
<p class="hint">Use stacked for “same color per quad” layouts; reverse flips the spiral.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -282,12 +283,14 @@
|
||||
<div class="text-sm text-gray-600 mb-1">Pick a color for <span id="classic-active-label" class="font-bold">Slot #1</span> (from colors.js):</div>
|
||||
<div id="classic-swatch-grid" class="palette-box min-h-[3rem]"></div>
|
||||
<div class="flex flex-wrap gap-2 mt-3">
|
||||
<button id="classic-randomize-colors" class="btn-dark">Randomize 5</button>
|
||||
<button id="classic-randomize-colors" class="btn-dark">Randomize</button>
|
||||
</div>
|
||||
<div class="panel-heading mt-3">Topper Color</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button id="classic-topper-color-swatch" class="slot-swatch" title="Click to change topper color">T</button>
|
||||
<p class="hint">Select a color then click to apply.</p>
|
||||
<div id="classic-topper-color-block" class="mt-3 hidden">
|
||||
<div class="panel-heading">Topper Color</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button id="classic-topper-color-swatch" class="slot-swatch" title="Click to change topper color">T</button>
|
||||
<p class="hint">Select a color then click to apply.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -298,9 +301,9 @@
|
||||
<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">SVG recommended for Classic.</p>
|
||||
<p class="hint w-full">SVG keeps the vector Classic layout; PNG is raster.</p>
|
||||
</div>
|
||||
<p class="hint text-red-500">Classic JSON save/load not available yet.</p>
|
||||
<p class="hint text-red-500">Classic JSON save/load coming soon.</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
469
script.js
469
script.js
@ -11,14 +11,15 @@
|
||||
const SIZE_PRESETS = [24, 18, 11, 9, 5];
|
||||
|
||||
// ====== Shine ellipse tuning ======
|
||||
const SHINE_OFFSET = 0.30, SHINE_RX = 0.40, SHINE_RY = 0.24, SHINE_ROT = -25, SHINE_ALPHA = 0.7; // ROT is now in degrees
|
||||
const SHINE_OFFSET = 0.30, SHINE_RX = 0.40, SHINE_RY = 0.24, SHINE_ROT = -25, SHINE_ALPHA = 0.45; // ROT is now in degrees
|
||||
let view = { s: 1, tx: 0, ty: 0 };
|
||||
const FIT_PADDING_PX = 15;
|
||||
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 clamp = (v, min, max) => Math.max(min, Math.min(max, v));
|
||||
const clamp01 = v => clamp(v, 0, 1);
|
||||
@ -79,6 +80,8 @@
|
||||
const toolDrawBtn = document.getElementById('tool-draw');
|
||||
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');
|
||||
@ -93,6 +96,7 @@
|
||||
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 sizePresetGroup = document.getElementById('size-preset-group');
|
||||
const toggleShineBtn = null;
|
||||
@ -151,12 +155,30 @@
|
||||
let eraserRadius = parseInt(eraserSizeInput?.value || '40', 10);
|
||||
let mouseInside = false;
|
||||
let mousePos = { x: 0, y: 0 };
|
||||
let selectedBalloonId = null;
|
||||
let selectedIds = new Set();
|
||||
let usedSortDesc = true;
|
||||
|
||||
// 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
|
||||
@ -172,35 +194,38 @@
|
||||
historyStack.shift();
|
||||
historyPointer--;
|
||||
}
|
||||
updateHistoryUi();
|
||||
}
|
||||
|
||||
function undo() {
|
||||
if (historyPointer > 0) {
|
||||
historyPointer--;
|
||||
balloons = JSON.parse(JSON.stringify(historyStack[historyPointer]));
|
||||
selectedBalloonId = null; // clear selection on undo to avoid issues
|
||||
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]));
|
||||
selectedBalloonId = null;
|
||||
selectedIds.clear();
|
||||
updateSelectButtons();
|
||||
draw();
|
||||
renderUsedPalette();
|
||||
persist();
|
||||
}
|
||||
updateHistoryUi();
|
||||
}
|
||||
|
||||
// Bind Undo/Redo Buttons
|
||||
document.getElementById('tool-undo')?.addEventListener('click', undo);
|
||||
document.getElementById('tool-redo')?.addEventListener('click', redo);
|
||||
toolUndoBtn?.addEventListener('click', undo);
|
||||
toolRedoBtn?.addEventListener('click', redo);
|
||||
|
||||
// Eyedropper Tool
|
||||
const toolEyedropperBtn = document.getElementById('tool-eyedropper');
|
||||
@ -240,7 +265,21 @@
|
||||
});
|
||||
return 0.2126*norm[0] + 0.7152*norm[1] + 0.0722*norm[2];
|
||||
}
|
||||
function shineStyle(colorHex) {
|
||||
const lum = luminance(colorHex);
|
||||
if (lum > 0.7) {
|
||||
const t = clamp01((lum - 0.7) / 0.3);
|
||||
const fillAlpha = 0.22 + (0.10 - 0.22) * t;
|
||||
return { fill: `rgba(0,0,0,${fillAlpha})`, stroke: null };
|
||||
}
|
||||
return { fill: `rgba(255,255,255,${SHINE_ALPHA})`, stroke: null };
|
||||
}
|
||||
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++) {
|
||||
@ -301,19 +340,45 @@
|
||||
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 = !!selectedBalloonId;
|
||||
const has = selectedIds.size > 0;
|
||||
if (deleteSelectedBtn) deleteSelectedBtn.disabled = !has;
|
||||
if (duplicateSelectedBtn) duplicateSelectedBtn.disabled = !has;
|
||||
if (selectedSizeInput) selectedSizeInput.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 (has && selectedSizeInput && selectedSizeLabel) {
|
||||
const b = balloons.find(bb => bb.id === selectedBalloonId);
|
||||
if (b) {
|
||||
selectedSizeInput.value = Math.round(b.radius);
|
||||
selectedSizeLabel.textContent = `${Math.round(b.radius)}`;
|
||||
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"';
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -323,6 +388,25 @@
|
||||
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();
|
||||
@ -338,81 +422,120 @@
|
||||
|
||||
if (mode === 'erase') {
|
||||
pointerDown = true;
|
||||
pushHistory(); // Save state before erasing
|
||||
eraseAt(mousePos.x, mousePos.y);
|
||||
erasingActive = true;
|
||||
eraseChanged = eraseAt(mousePos.x, mousePos.y);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'select') {
|
||||
pointerDown = true;
|
||||
const clickedIdx = findBalloonIndexAt(mousePos.x, mousePos.y);
|
||||
|
||||
if (clickedIdx !== -1) {
|
||||
// We clicked on a balloon
|
||||
const b = balloons[clickedIdx];
|
||||
if (selectedBalloonId !== b.id) {
|
||||
selectedBalloonId = b.id;
|
||||
updateSelectButtons();
|
||||
draw();
|
||||
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);
|
||||
}
|
||||
// Start Dragging
|
||||
updateSelectButtons();
|
||||
draw();
|
||||
isDragging = true;
|
||||
pointerDown = true;
|
||||
dragStartPos = { ...mousePos };
|
||||
initialBalloonPos = { x: b.x, y: b.y };
|
||||
pushHistory(); // Save state before move
|
||||
dragOffsets = selectionBalloons().map(bb => ({ id: bb.id, dx: bb.x - mousePos.x, dy: bb.y - mousePos.y }));
|
||||
dragMoved = false;
|
||||
} else {
|
||||
// Clicked empty space -> deselect
|
||||
if (selectedBalloonId) {
|
||||
selectedBalloonId = null;
|
||||
updateSelectButtons();
|
||||
draw();
|
||||
}
|
||||
// Perhaps handle panning here later?
|
||||
if (!e.shiftKey) selectedIds.clear();
|
||||
updateSelectButtons();
|
||||
marqueeActive = true;
|
||||
marqueeStart = { ...mousePos };
|
||||
marqueeEnd = { ...mousePos };
|
||||
requestDraw();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// draw mode: add
|
||||
pushHistory(); // Save state before 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 && selectedBalloonId) {
|
||||
if (isDragging && selectedIds.size) {
|
||||
const dx = mousePos.x - dragStartPos.x;
|
||||
const dy = mousePos.y - dragStartPos.y;
|
||||
const b = balloons.find(bb => bb.id === selectedBalloonId);
|
||||
if (b) {
|
||||
b.x = initialBalloonPos.x + dx;
|
||||
b.y = initialBalloonPos.y + dy;
|
||||
draw();
|
||||
}
|
||||
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 {
|
||||
// Hover cursor
|
||||
const hoverIdx = findBalloonIndexAt(mousePos.x, mousePos.y);
|
||||
canvas.style.cursor = (hoverIdx !== -1) ? 'move' : 'default';
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === 'erase') {
|
||||
if (pointerDown) eraseAt(mousePos.x, mousePos.y);
|
||||
else draw();
|
||||
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 === '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;
|
||||
if (mode === 'erase') draw();
|
||||
marqueeActive = false;
|
||||
if (mode === 'erase') requestDraw();
|
||||
}, { passive: true });
|
||||
|
||||
// ====== Canvas & Drawing ======
|
||||
@ -477,8 +600,7 @@
|
||||
}
|
||||
|
||||
if (isShineEnabled) {
|
||||
const isBright = luminance(b.color) > 0.75;
|
||||
const shineFill = isBright ? 'rgba(0,0,0,0.55)' : `rgba(255,255,255,${SHINE_ALPHA})`;
|
||||
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;
|
||||
@ -497,8 +619,8 @@
|
||||
ctx.arc(0, 0, ry, 0, Math.PI * 2);
|
||||
}
|
||||
ctx.fillStyle = shineFill;
|
||||
if (isBright) {
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.45)';
|
||||
if (shineStroke) {
|
||||
ctx.strokeStyle = shineStroke;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
}
|
||||
@ -507,23 +629,38 @@
|
||||
}
|
||||
});
|
||||
|
||||
// selection ring
|
||||
if (selectedBalloonId) {
|
||||
const b = balloons.find(bb => bb.id === selectedBalloonId);
|
||||
if (b) {
|
||||
ctx.save();
|
||||
// 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);
|
||||
// White halo
|
||||
ctx.lineWidth = 4 / view.s;
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
|
||||
ctx.stroke();
|
||||
// Blue ring
|
||||
ctx.lineWidth = 2 / view.s;
|
||||
ctx.strokeStyle = '#3b82f6';
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
});
|
||||
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
|
||||
@ -587,10 +724,12 @@
|
||||
usedSortDesc = s.usedSortDesc;
|
||||
if (sortUsedToggle) sortUsedToggle.textContent = usedSortDesc ? 'Sort: Most → Least' : 'Sort: Least → Most';
|
||||
}
|
||||
updateCurrentColorChip();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
loadAppState();
|
||||
resetHistory(); // establish initial history state for undo/redo controls
|
||||
|
||||
// ====== UI Rendering (Palettes) ======
|
||||
function renderAllowedPalette() {
|
||||
@ -628,6 +767,7 @@
|
||||
sw.addEventListener('click', () => {
|
||||
selectedColorIdx = idx ?? 0;
|
||||
renderAllowedPalette();
|
||||
updateCurrentColorChip();
|
||||
persist();
|
||||
});
|
||||
row.appendChild(sw);
|
||||
@ -652,6 +792,28 @@
|
||||
return arr;
|
||||
}
|
||||
|
||||
function updateCurrentColorChip() {
|
||||
const meta = FLAT_COLORS[selectedColorIdx] || FLAT_COLORS[0];
|
||||
const updateChip = (chipId, labelId) => {
|
||||
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 = meta.name || meta.hex || 'Current';
|
||||
};
|
||||
updateChip('current-color-chip', 'current-color-label');
|
||||
}
|
||||
|
||||
function renderUsedPalette() {
|
||||
if (!usedPaletteBox) return;
|
||||
usedPaletteBox.innerHTML = '';
|
||||
@ -719,7 +881,8 @@
|
||||
id: crypto.randomUUID()
|
||||
});
|
||||
ensureVisibleAfterAdd(balloons[balloons.length - 1]);
|
||||
refreshAll();
|
||||
refreshAll({ autoFit: true });
|
||||
pushHistory();
|
||||
}
|
||||
|
||||
function findBalloonIndexAt(x, y) {
|
||||
@ -732,83 +895,107 @@
|
||||
|
||||
function selectAt(x, y) {
|
||||
const i = findBalloonIndexAt(x, y);
|
||||
selectedBalloonId = (i !== -1) ? balloons[i].id : null;
|
||||
selectedIds.clear();
|
||||
if (i !== -1) selectedIds.add(balloons[i].id);
|
||||
updateSelectButtons();
|
||||
draw();
|
||||
}
|
||||
|
||||
function moveSelected(dx, dy) {
|
||||
if (!selectedBalloonId) return;
|
||||
const b = balloons.find(bb => bb.id === selectedBalloonId);
|
||||
if (!b) return;
|
||||
b.x += dx;
|
||||
b.y += dy;
|
||||
const sel = selectionBalloons();
|
||||
if (!sel.length) return;
|
||||
sel.forEach(b => { b.x += dx; b.y += dy; });
|
||||
refreshAll();
|
||||
pushHistory();
|
||||
}
|
||||
|
||||
function resizeSelected(newRadius) {
|
||||
if (!selectedBalloonId) return;
|
||||
const b = balloons.find(bb => bb.id === selectedBalloonId);
|
||||
if (!b) return;
|
||||
b.radius = clamp(newRadius, 5, 200);
|
||||
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() {
|
||||
if (!selectedBalloonId) return;
|
||||
const idx = balloons.findIndex(bb => bb.id === selectedBalloonId);
|
||||
if (idx === -1 || idx === balloons.length - 1) return;
|
||||
const [b] = balloons.splice(idx, 1);
|
||||
balloons.push(b);
|
||||
refreshAll();
|
||||
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() {
|
||||
if (!selectedBalloonId) return;
|
||||
const idx = balloons.findIndex(bb => bb.id === selectedBalloonId);
|
||||
if (idx <= 0) return;
|
||||
const [b] = balloons.splice(idx, 1);
|
||||
balloons.unshift(b);
|
||||
refreshAll();
|
||||
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() {
|
||||
if (!selectedBalloonId) return;
|
||||
const b = balloons.find(bb => bb.id === selectedBalloonId);
|
||||
const meta = FLAT_COLORS[selectedColorIdx] || FLAT_COLORS[0];
|
||||
if (!b || !meta) return;
|
||||
b.color = meta.hex;
|
||||
b.image = meta.image || null;
|
||||
b.colorIdx = meta._idx;
|
||||
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 (!selectedBalloonId) return;
|
||||
balloons = balloons.filter(b => b.id !== selectedBalloonId);
|
||||
selectedBalloonId = null;
|
||||
if (!selectedIds.size) return;
|
||||
balloons = balloons.filter(b => !selectedIds.has(b.id));
|
||||
selectedIds.clear();
|
||||
updateSelectButtons();
|
||||
refreshAll();
|
||||
refreshAll({ autoFit: true });
|
||||
pushHistory();
|
||||
}
|
||||
|
||||
function duplicateSelected() {
|
||||
if (!selectedBalloonId) return;
|
||||
const b = balloons.find(bb => bb.id === selectedBalloonId);
|
||||
if (!b) return;
|
||||
const copy = { ...b, x: b.x + 10, y: b.y + 10, id: crypto.randomUUID() };
|
||||
balloons.push(copy);
|
||||
selectedBalloonId = copy.id;
|
||||
refreshAll();
|
||||
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);
|
||||
if (selectedBalloonId && !balloons.find(b => b.id === selectedBalloonId)) {
|
||||
selectedBalloonId = null;
|
||||
updateSelectButtons();
|
||||
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();
|
||||
}
|
||||
refreshAll();
|
||||
if (removed && !erasingActive) requestDraw();
|
||||
return removed;
|
||||
}
|
||||
|
||||
function pickColorAt(x, y) {
|
||||
@ -817,6 +1004,7 @@
|
||||
selectedColorIdx = HEX_TO_FIRST_IDX.get(normalizeHex(balloons[i].color)) ?? 0;
|
||||
renderAllowedPalette();
|
||||
renderUsedPalette();
|
||||
updateCurrentColorChip();
|
||||
}
|
||||
}
|
||||
|
||||
@ -873,9 +1061,10 @@
|
||||
};
|
||||
})
|
||||
: [];
|
||||
selectedBalloonId = null;
|
||||
selectedIds.clear();
|
||||
updateSelectButtons();
|
||||
refreshAll({ refit: true });
|
||||
resetHistory();
|
||||
persist();
|
||||
} catch {
|
||||
showModal('Error parsing JSON file.');
|
||||
@ -1019,9 +1208,8 @@
|
||||
const sy = b.y - b.radius * SHINE_OFFSET;
|
||||
const rx = b.radius * SHINE_RX;
|
||||
const ry = b.radius * SHINE_RY;
|
||||
const isBright = luminance(b.color) > 0.75;
|
||||
const shineFill = isBright ? 'rgba(0,0,0,0.55)' : `rgba(255,255,255,${SHINE_ALPHA})`;
|
||||
const stroke = isBright ? ' stroke="rgba(0,0,0,0.45)" stroke-width="1.5"' : '';
|
||||
const { fill: shineFill, stroke: shineStroke } = shineStyle(b.color);
|
||||
const stroke = shineStroke ? ` stroke="${shineStroke}" stroke-width="1.5"` : '';
|
||||
elements += `<ellipse cx="${sx}" cy="${sy}" rx="${rx}" ry="${ry}" fill="${shineFill}"${stroke} transform="rotate(${SHINE_ROT} ${sx} ${sy})" />`;
|
||||
}
|
||||
});
|
||||
@ -1038,6 +1226,19 @@
|
||||
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'));
|
||||
@ -1050,10 +1251,17 @@
|
||||
|
||||
// Ensure required namespaces are present
|
||||
const viewBox = (clonedSvg.getAttribute('viewBox') || '0 0 1000 1000').split(/\s+/).map(Number);
|
||||
const vbX = isFinite(viewBox[0]) ? viewBox[0] : 0;
|
||||
const vbY = isFinite(viewBox[1]) ? viewBox[1] : 0;
|
||||
const vbW = isFinite(viewBox[2]) ? viewBox[2] : (svgElement.clientWidth || 1000);
|
||||
const vbH = isFinite(viewBox[3]) ? viewBox[3] : (svgElement.clientHeight || 1000);
|
||||
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');
|
||||
@ -1073,11 +1281,15 @@
|
||||
|
||||
async function svgStringToPng(svgString, width, height) {
|
||||
const img = new Image();
|
||||
const scale = 2;
|
||||
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;
|
||||
@ -1204,7 +1416,9 @@
|
||||
})
|
||||
: compactToDesign(data);
|
||||
refreshAll({ refit: true });
|
||||
resetHistory();
|
||||
persist();
|
||||
updateCurrentColorChip();
|
||||
showModal('Design loaded from link!');
|
||||
} catch {
|
||||
showModal('Could not load design from URL.');
|
||||
@ -1274,11 +1488,13 @@
|
||||
|
||||
view.tx += dx;
|
||||
view.ty += dy;
|
||||
return (dx !== 0 || dy !== 0);
|
||||
}
|
||||
|
||||
// ====== Refresh & Events ======
|
||||
function refreshAll({ refit = false } = {}) {
|
||||
function refreshAll({ refit = false, autoFit = false } = {}) {
|
||||
if (refit) fitView();
|
||||
else if (autoFit) fitView();
|
||||
draw();
|
||||
renderUsedPalette();
|
||||
persist();
|
||||
@ -1310,10 +1526,22 @@
|
||||
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;
|
||||
@ -1321,29 +1549,31 @@
|
||||
else if (e.key === 'v' || e.key === 'V') setMode('draw');
|
||||
else if (e.key === 's' || e.key === 'S') setMode('select');
|
||||
else if (e.key === 'Escape') {
|
||||
if (selectedBalloonId) {
|
||||
selectedBalloonId = null;
|
||||
updateSelectButtons();
|
||||
draw();
|
||||
if (selectedIds.size) {
|
||||
clearSelection();
|
||||
} else if (mode !== 'draw') {
|
||||
setMode('draw');
|
||||
}
|
||||
} else if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
if (selectedBalloonId) { e.preventDefault(); deleteSelected(); }
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
clearCanvasBtn?.addEventListener('click', () => {
|
||||
balloons = [];
|
||||
selectedBalloonId = null;
|
||||
selectedIds.clear();
|
||||
updateSelectButtons();
|
||||
refreshAll({ refit: true });
|
||||
pushHistory();
|
||||
});
|
||||
|
||||
saveJsonBtn?.addEventListener('click', saveJson);
|
||||
@ -1394,6 +1624,7 @@
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
26
style.css
26
style.css
@ -8,6 +8,7 @@ body { color: #1f2937; }
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 4px 6px -1px rgba(0,0,0,.1), 0 2px 4px -1px rgba(0,0,0,.06);
|
||||
border: 1px black solid;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
@ -27,6 +28,7 @@ body { color: #1f2937; }
|
||||
.tool-btn svg { width: 1.1em; height: 1.1em; fill: currentColor; }
|
||||
.tool-btn:hover { transform: translateY(-1px); box-shadow: 0 2px 5px rgba(0,0,0,0.05); }
|
||||
.tool-btn[aria-pressed="true"] { background:#3b82f6; color:#fff; border-color:#3b82f6; box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); }
|
||||
.tool-btn:disabled { opacity: 0.45; cursor: not-allowed; transform: none; box-shadow: none; }
|
||||
|
||||
/* Base button style - Slate Gradient */
|
||||
.btn-dark { background: linear-gradient(135deg, #334155, #0f172a); color:#fff; padding:.6rem .8rem; border-radius:.75rem; transition: all 0.2s; box-shadow: 0 2px 8px rgba(15, 23, 42, 0.15); }
|
||||
@ -89,6 +91,21 @@ body { color: #1f2937; }
|
||||
.swatch:focus-visible { outline: 2px solid #6366f1; outline-offset: 2px; }
|
||||
.swatch.active { outline: 2px solid #6366f1; outline-offset: 2px; }
|
||||
|
||||
.current-color-chip {
|
||||
min-width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 9999px;
|
||||
border: 2px solid rgba(51,65,85,0.15);
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.08);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 .6rem;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.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; }
|
||||
|
||||
@ -120,7 +137,14 @@ body { color: #1f2937; }
|
||||
|
||||
#classic-swatch-grid .sw { width: 24px; height: 24px; border-radius: 6px; border: 1px solid rgba(0,0,0,.1); cursor: pointer; }
|
||||
#classic-swatch-grid .sw:focus { outline: 2px solid #2563eb; outline-offset: 2px; }
|
||||
.slot-btn { position: relative; overflow: hidden; }
|
||||
.slot-btn[aria-pressed="true"] { background:#3b82f6; color:#fff; }
|
||||
.slot-btn.slot-active {
|
||||
box-shadow: 0 0 0 3px rgba(255,255,255,0.95);
|
||||
outline: 3px solid #f97316;
|
||||
outline-offset: 3px;
|
||||
z-index: 1;
|
||||
}
|
||||
.slot-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -149,6 +173,7 @@ body { color: #1f2937; }
|
||||
color: rgba(0,0,0,0.4);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.slot-swatch:hover {
|
||||
@ -159,6 +184,7 @@ body { color: #1f2937; }
|
||||
border-color: #2563eb;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
.slot-swatch.active::after { display: none; }
|
||||
|
||||
.topper-type-group {
|
||||
display: flex;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user