chore: snapshot v3 version
This commit is contained in:
parent
2ca3487009
commit
70b2af53d1
91
classic.js
91
classic.js
@ -11,6 +11,60 @@
|
|||||||
</div>`;
|
</div>`;
|
||||||
};
|
};
|
||||||
const normHex = (h) => (String(h || '')).trim().toLowerCase();
|
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) ----------
|
// -------- persistent color selection (now supports image textures) ----------
|
||||||
const PALETTE_KEY = 'classic:colors:v2';
|
const PALETTE_KEY = 'classic:colors:v2';
|
||||||
@ -140,7 +194,7 @@
|
|||||||
const balloonSize = (cell)=> (cell.shape.size ?? 1);
|
const balloonSize = (cell)=> (cell.shape.size ?? 1);
|
||||||
const cellScale = (cell)=> balloonSize(cell) * pxUnit;
|
const cellScale = (cell)=> balloonSize(cell) * pxUnit;
|
||||||
|
|
||||||
function cellView(cell, id, explicitFill, model){
|
function cellView(cell, id, explicitFill, model, colorInfo){
|
||||||
const shape = cell.shape;
|
const shape = cell.shape;
|
||||||
const scale = cellScale(cell);
|
const scale = cellScale(cell);
|
||||||
const transform = [(shape.base.transform||''), `scale(${scale})`].join(' ');
|
const transform = [(shape.base.transform||''), `scale(${scale})`].join(' ');
|
||||||
@ -163,9 +217,13 @@
|
|||||||
const kids = [shapeEl];
|
const kids = [shapeEl];
|
||||||
const applyShine = model.shineEnabled && (!cell.isTopper || (cell.isTopper && model.topperType === 'round'));
|
const applyShine = model.shineEnabled && (!cell.isTopper || (cell.isTopper && model.topperType === 'round'));
|
||||||
if (applyShine) {
|
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,
|
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);
|
return svg('g', { id, transform }, kids);
|
||||||
@ -216,7 +274,7 @@ function distinctPaletteSlots(palette) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
for (let cell of cells) {
|
for (let cell of cells) {
|
||||||
let c, fill;
|
let c, fill, colorInfo;
|
||||||
if (cell.isTopper) {
|
if (cell.isTopper) {
|
||||||
const topRowYIndex = 0, topClusterY = pattern.gridY(topRowYIndex, 0) * pxUnit;
|
const topRowYIndex = 0, topClusterY = pattern.gridY(topRowYIndex, 0) * pxUnit;
|
||||||
const regularBalloonRadius = (pattern.balloonShapes['front'] || pattern.balloonShapes['penta'] || pattern.balloonShapes['middle']).size * pxUnit * 0.5;
|
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;
|
const topperY = highestPoint - topperRadius - (pxUnit * 0.5) + topperOffsetY_Px;
|
||||||
c = { x: topperOffsetX_Px, y: topperY };
|
c = { x: topperOffsetX_Px, y: topperY };
|
||||||
fill = model.topperColor.image ? `url(#classic-pattern-topper)` : model.topperColor.hex;
|
fill = model.topperColor.image ? `url(#classic-pattern-topper)` : model.topperColor.hex;
|
||||||
|
colorInfo = model.topperColor;
|
||||||
} else {
|
} else {
|
||||||
c = gridPos(cell.x, cell.y, cell.shape.zIndex, cell.inflate, pattern, model);
|
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];
|
const colorCode = rowColorPatterns[rowIndex][cell.balloonIndexInCluster];
|
||||||
cell.colorCode = colorCode;
|
cell.colorCode = colorCode;
|
||||||
const colorInfo = model.palette[colorCode];
|
colorInfo = model.palette[colorCode];
|
||||||
fill = colorInfo ? (colorInfo.image ? `url(#classic-pattern-slot-${colorCode})` : colorInfo.colour) : 'transparent';
|
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;
|
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);
|
||||||
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 || ''}`;
|
v.attrs.transform = `translate(${c.x},${c.y}) ${v.attrs.transform || ''}`;
|
||||||
const zi = cell.isTopper ? 100 + 2 : (100 + (cell.shape.zIndex || 0));
|
const zi = cell.isTopper ? 100 + 2 : (100 + (cell.shape.zIndex || 0));
|
||||||
(layers[zi] ||= []).push(v);
|
(layers[zi] ||= []).push(v);
|
||||||
@ -464,6 +523,7 @@ function distinctPaletteSlots(palette) {
|
|||||||
|
|
||||||
function initClassicColorPicker(onColorChange) {
|
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 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;
|
if (!slotsContainer || !topperSwatch || !swatchGrid || !activeLabel) return;
|
||||||
topperSwatch.classList.add('tab-btn');
|
topperSwatch.classList.add('tab-btn');
|
||||||
let classicColors = getClassicColors(), activeTarget = '1', slotCount = getStoredSlotCount();
|
let classicColors = getClassicColors(), activeTarget = '1', slotCount = getStoredSlotCount();
|
||||||
@ -504,6 +564,7 @@ function distinctPaletteSlots(palette) {
|
|||||||
enforceSlotVisibility();
|
enforceSlotVisibility();
|
||||||
const buttons = Array.from(slotsContainer.querySelectorAll('.slot-btn'));
|
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, 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) => {
|
buttons.forEach((slot, i) => {
|
||||||
const color = classicColors[i];
|
const color = classicColors[i];
|
||||||
@ -512,6 +573,9 @@ function distinctPaletteSlots(palette) {
|
|||||||
slot.style.backgroundColor = color.hex;
|
slot.style.backgroundColor = color.hex;
|
||||||
slot.style.backgroundSize = '200%';
|
slot.style.backgroundSize = '200%';
|
||||||
slot.style.backgroundPosition = 'center';
|
slot.style.backgroundPosition = 'center';
|
||||||
|
const txt = textStyleForColor(color);
|
||||||
|
slot.style.color = txt.color;
|
||||||
|
slot.style.textShadow = txt.shadow;
|
||||||
});
|
});
|
||||||
|
|
||||||
const topperColor = getTopperColor();
|
const topperColor = getTopperColor();
|
||||||
@ -519,6 +583,13 @@ function distinctPaletteSlots(palette) {
|
|||||||
topperSwatch.style.backgroundColor = topperColor.hex;
|
topperSwatch.style.backgroundColor = topperColor.hex;
|
||||||
topperSwatch.style.backgroundSize = '200%';
|
topperSwatch.style.backgroundSize = '200%';
|
||||||
topperSwatch.style.backgroundPosition = 'center';
|
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 patSelect = document.getElementById('classic-pattern');
|
||||||
const isStacked = (patSelect?.value || '').toLowerCase().includes('stacked');
|
const isStacked = (patSelect?.value || '').toLowerCase().includes('stacked');
|
||||||
@ -540,6 +611,8 @@ function distinctPaletteSlots(palette) {
|
|||||||
(group.colors || []).forEach(colorItem => {
|
(group.colors || []).forEach(colorItem => {
|
||||||
const sw = document.createElement('button'); sw.type = 'button'; sw.className = 'swatch'; sw.title = colorItem.name;
|
const sw = document.createElement('button'); sw.type = 'button'; sw.className = 'swatch'; sw.title = colorItem.name;
|
||||||
sw.setAttribute('aria-label', 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.backgroundImage = colorItem.image ? `url("${colorItem.image}")` : 'none';
|
||||||
sw.style.backgroundColor = colorItem.hex;
|
sw.style.backgroundColor = colorItem.hex;
|
||||||
@ -588,6 +661,7 @@ function distinctPaletteSlots(palette) {
|
|||||||
if (window.updateExportButtonVisibility) window.updateExportButtonVisibility();
|
if (window.updateExportButtonVisibility) window.updateExportButtonVisibility();
|
||||||
});
|
});
|
||||||
updateUI();
|
updateUI();
|
||||||
|
return updateUI;
|
||||||
}
|
}
|
||||||
|
|
||||||
function initClassic() {
|
function initClassic() {
|
||||||
@ -612,6 +686,7 @@ function distinctPaletteSlots(palette) {
|
|||||||
};
|
};
|
||||||
if (!display) return fail('#classic-display not found');
|
if (!display) return fail('#classic-display not found');
|
||||||
const GC = GridCalculator(), ctrl = GC.controller(display);
|
const GC = GridCalculator(), ctrl = GC.controller(display);
|
||||||
|
let refreshClassicPaletteUi = null;
|
||||||
|
|
||||||
const getTopperType = () => topperTypeButtons.find(btn => btn.getAttribute('aria-pressed') === 'true')?.dataset.type || 'round';
|
const getTopperType = () => topperTypeButtons.find(btn => btn.getAttribute('aria-pressed') === 'true')?.dataset.type || 'round';
|
||||||
const setTopperType = (type) => {
|
const setTopperType = (type) => {
|
||||||
@ -693,6 +768,7 @@ function distinctPaletteSlots(palette) {
|
|||||||
}
|
}
|
||||||
window.__updateFloatingNudge?.();
|
window.__updateFloatingNudge?.();
|
||||||
if(clusterHint) clusterHint.textContent = `≈ ${Math.round((parseFloat(lengthInp.value) || 0) * 2)} clusters (rule: 2 clusters/ft)`;
|
if(clusterHint) clusterHint.textContent = `≈ ${Math.round((parseFloat(lengthInp.value) || 0) * 2)} clusters (rule: 2 clusters/ft)`;
|
||||||
|
refreshClassicPaletteUi?.();
|
||||||
ctrl.selectPattern(patternName);
|
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(); }); });
|
.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);
|
topperEnabledCb?.addEventListener('change', updateClassicDesign);
|
||||||
shineEnabledCb?.addEventListener('change', (e) => { const on = !!e.target.checked; GC.setShineEnabled(on); updateClassicDesign(); window.syncAppShine?.(on); });
|
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 {}
|
try { const saved = localStorage.getItem('app:shineEnabled:v1'); if (saved !== null && shineEnabledCb) shineEnabledCb.checked = JSON.parse(saved); } catch {}
|
||||||
setLengthForPattern();
|
setLengthForPattern();
|
||||||
updateClassicDesign();
|
updateClassicDesign();
|
||||||
|
refreshClassicPaletteUi?.();
|
||||||
if (window.updateExportButtonVisibility) window.updateExportButtonVisibility();
|
if (window.updateExportButtonVisibility) window.updateExportButtonVisibility();
|
||||||
log('Classic ready');
|
log('Classic ready');
|
||||||
} catch (e) { fail(e.message || e); }
|
} 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 class="text-xs text-indigo-500 font-bold uppercase tracking-wider">Professional Design Tool</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<nav id="mode-tabs" class="flex gap-2">
|
<div class="flex items-center gap-4">
|
||||||
<button type="button" class="tab-btn tab-active" data-target="#tab-organic" aria-pressed="true">Organic</button>
|
<nav id="mode-tabs" class="flex gap-2">
|
||||||
<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-active" data-target="#tab-organic" aria-pressed="true">Organic</button>
|
||||||
</nav>
|
<button type="button" class="tab-btn tab-idle" data-target="#tab-classic" aria-pressed="false">Classic (Arch/Column)</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section id="tab-organic" class="flex flex-col lg:flex-row gap-4 lg:h-[calc(100vh-10rem)]">
|
<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>
|
<span class="hidden sm:inline">Picker</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="eraser-controls" class="hidden flex flex-col gap-2">
|
<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>
|
<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">
|
<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>
|
<p class="hint">Hover to preview. Click-drag to erase.</p>
|
||||||
</div>
|
</div>
|
||||||
<div id="select-controls" class="hidden flex flex-col gap-2">
|
<div id="select-controls" class="hidden flex flex-col gap-2">
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button id="delete-selected" class="btn-danger" disabled>Delete</button>
|
<button id="delete-selected" class="btn-danger" disabled>Delete</button>
|
||||||
<button id="duplicate-selected" class="btn-dark" disabled>Duplicate</button>
|
<button id="duplicate-selected" class="btn-dark" disabled>Duplicate</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<div class="flex items-center gap-2 text-xs text-gray-600 mb-1">
|
<p class="hint">Drag balloons to reposition. Use keyboard arrows for fine nudges.</p>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 flex items-center gap-2 text-xs text-gray-600">
|
<div class="mt-2 flex items-center gap-2 text-xs text-gray-600">
|
||||||
<span class="font-semibold">Resize</span>
|
<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>
|
<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-10 text-right">0</span>
|
<span id="selected-size-label" class="text-xs w-12 text-right">0\"</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 grid grid-cols-2 gap-2">
|
<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>
|
<button type="button" class="btn-dark text-sm py-2" id="bring-forward" disabled>Bring Forward</button>
|
||||||
@ -115,18 +108,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-heading mt-4">Size & Shine</div>
|
<div class="panel-heading mt-4">Size & Shine</div>
|
||||||
<div class="panel-card">
|
<div class="panel-card">
|
||||||
<div id="size-preset-group" class="grid grid-cols-5 gap-2 mb-2"></div>
|
<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>
|
<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">
|
<label class="text-sm inline-flex items-center gap-2 font-medium">
|
||||||
<input id="toggle-shine-checkbox" type="checkbox" class="align-middle" checked>
|
<input id="toggle-shine-checkbox" type="checkbox" class="align-middle" checked>
|
||||||
Enable Shine
|
Enable Shine
|
||||||
</label>
|
</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>
|
|
||||||
|
|
||||||
<div class="control-stack" data-mobile-tab="colors">
|
<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="panel-card">
|
||||||
<div class="flex items-center justify-between mb-2">
|
<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>
|
<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 id="used-palette" class="palette-box min-h-[3rem]"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-heading mt-4">Allowed Colors</div>
|
<div class="panel-heading mt-4">Color Library</div>
|
||||||
<div class="panel-card">
|
<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 id="color-palette" class="palette-box"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-heading mt-4">Replace Color</div>
|
<div class="panel-heading mt-4">Replace Color</div>
|
||||||
<div class="panel-card">
|
<div class="panel-card">
|
||||||
<div class="grid grid-cols-1 gap-2">
|
<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>
|
<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>
|
<select id="replace-to" class="select"></select>
|
||||||
|
|
||||||
<button id="replace-btn" class="btn-blue">Replace</button>
|
<button id="replace-btn" class="btn-blue">Replace</button>
|
||||||
@ -177,7 +177,7 @@
|
|||||||
<div class="flex flex-wrap gap-3 mt-2">
|
<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-600" data-export="png">Export PNG</button>
|
||||||
<button class="btn-dark bg-blue-700" data-export="svg">Export SVG</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -268,6 +268,7 @@
|
|||||||
<input id="classic-reverse" type="checkbox" class="align-middle">
|
<input id="classic-reverse" type="checkbox" class="align-middle">
|
||||||
Reverse spiral
|
Reverse spiral
|
||||||
</label>
|
</label>
|
||||||
|
<p class="hint">Use stacked for “same color per quad” layouts; reverse flips the spiral.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 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 id="classic-swatch-grid" class="palette-box min-h-[3rem]"></div>
|
||||||
<div class="flex flex-wrap gap-2 mt-3">
|
<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>
|
||||||
<div class="panel-heading mt-3">Topper Color</div>
|
<div id="classic-topper-color-block" class="mt-3 hidden">
|
||||||
<div class="flex items-center gap-3">
|
<div class="panel-heading">Topper Color</div>
|
||||||
<button id="classic-topper-color-swatch" class="slot-swatch" title="Click to change topper color">T</button>
|
<div class="flex items-center gap-3">
|
||||||
<p class="hint">Select a color then click to apply.</p>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -298,9 +301,9 @@
|
|||||||
<div class="flex flex-wrap gap-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-600" data-export="png">Export PNG</button>
|
||||||
<button class="btn-dark bg-blue-700" data-export="svg">Export SVG</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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
469
script.js
469
script.js
@ -11,14 +11,15 @@
|
|||||||
const SIZE_PRESETS = [24, 18, 11, 9, 5];
|
const SIZE_PRESETS = [24, 18, 11, 9, 5];
|
||||||
|
|
||||||
// ====== Shine ellipse tuning ======
|
// ====== 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 };
|
let view = { s: 1, tx: 0, ty: 0 };
|
||||||
const FIT_PADDING_PX = 15;
|
const FIT_PADDING_PX = 30;
|
||||||
|
|
||||||
// ====== Texture defaults ======
|
// ====== Texture defaults ======
|
||||||
const TEXTURE_ZOOM_DEFAULT = 1.8;
|
const TEXTURE_ZOOM_DEFAULT = 1.8;
|
||||||
const TEXTURE_FOCUS_DEFAULT = { x: 0.5, y: 0.5 };
|
const TEXTURE_FOCUS_DEFAULT = { x: 0.5, y: 0.5 };
|
||||||
const SWATCH_TEXTURE_ZOOM = 2.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 clamp = (v, min, max) => Math.max(min, Math.min(max, v));
|
||||||
const clamp01 = v => clamp(v, 0, 1);
|
const clamp01 = v => clamp(v, 0, 1);
|
||||||
@ -79,6 +80,8 @@
|
|||||||
const toolDrawBtn = document.getElementById('tool-draw');
|
const toolDrawBtn = document.getElementById('tool-draw');
|
||||||
const toolEraseBtn = document.getElementById('tool-erase');
|
const toolEraseBtn = document.getElementById('tool-erase');
|
||||||
const toolSelectBtn = document.getElementById('tool-select');
|
const toolSelectBtn = document.getElementById('tool-select');
|
||||||
|
const toolUndoBtn = document.getElementById('tool-undo');
|
||||||
|
const toolRedoBtn = document.getElementById('tool-redo');
|
||||||
|
|
||||||
// panels/controls
|
// panels/controls
|
||||||
const eraserControls = document.getElementById('eraser-controls');
|
const eraserControls = document.getElementById('eraser-controls');
|
||||||
@ -93,6 +96,7 @@
|
|||||||
const bringForwardBtn = document.getElementById('bring-forward');
|
const bringForwardBtn = document.getElementById('bring-forward');
|
||||||
const sendBackwardBtn = document.getElementById('send-backward');
|
const sendBackwardBtn = document.getElementById('send-backward');
|
||||||
const applyColorBtn = document.getElementById('apply-selected-color');
|
const applyColorBtn = document.getElementById('apply-selected-color');
|
||||||
|
const fitViewBtn = document.getElementById('fit-view-btn');
|
||||||
|
|
||||||
const sizePresetGroup = document.getElementById('size-preset-group');
|
const sizePresetGroup = document.getElementById('size-preset-group');
|
||||||
const toggleShineBtn = null;
|
const toggleShineBtn = null;
|
||||||
@ -151,12 +155,30 @@
|
|||||||
let eraserRadius = parseInt(eraserSizeInput?.value || '40', 10);
|
let eraserRadius = parseInt(eraserSizeInput?.value || '40', 10);
|
||||||
let mouseInside = false;
|
let mouseInside = false;
|
||||||
let mousePos = { x: 0, y: 0 };
|
let mousePos = { x: 0, y: 0 };
|
||||||
let selectedBalloonId = null;
|
let selectedIds = new Set();
|
||||||
let usedSortDesc = true;
|
let usedSortDesc = true;
|
||||||
|
|
||||||
// History for Undo/Redo
|
// History for Undo/Redo
|
||||||
const historyStack = [];
|
const historyStack = [];
|
||||||
let historyPointer = -1;
|
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() {
|
function pushHistory() {
|
||||||
// Remove any future history if we are in the middle of the stack
|
// Remove any future history if we are in the middle of the stack
|
||||||
@ -172,35 +194,38 @@
|
|||||||
historyStack.shift();
|
historyStack.shift();
|
||||||
historyPointer--;
|
historyPointer--;
|
||||||
}
|
}
|
||||||
|
updateHistoryUi();
|
||||||
}
|
}
|
||||||
|
|
||||||
function undo() {
|
function undo() {
|
||||||
if (historyPointer > 0) {
|
if (historyPointer > 0) {
|
||||||
historyPointer--;
|
historyPointer--;
|
||||||
balloons = JSON.parse(JSON.stringify(historyStack[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();
|
updateSelectButtons();
|
||||||
draw();
|
draw();
|
||||||
renderUsedPalette();
|
renderUsedPalette();
|
||||||
persist();
|
persist();
|
||||||
}
|
}
|
||||||
|
updateHistoryUi();
|
||||||
}
|
}
|
||||||
|
|
||||||
function redo() {
|
function redo() {
|
||||||
if (historyPointer < historyStack.length - 1) {
|
if (historyPointer < historyStack.length - 1) {
|
||||||
historyPointer++;
|
historyPointer++;
|
||||||
balloons = JSON.parse(JSON.stringify(historyStack[historyPointer]));
|
balloons = JSON.parse(JSON.stringify(historyStack[historyPointer]));
|
||||||
selectedBalloonId = null;
|
selectedIds.clear();
|
||||||
updateSelectButtons();
|
updateSelectButtons();
|
||||||
draw();
|
draw();
|
||||||
renderUsedPalette();
|
renderUsedPalette();
|
||||||
persist();
|
persist();
|
||||||
}
|
}
|
||||||
|
updateHistoryUi();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bind Undo/Redo Buttons
|
// Bind Undo/Redo Buttons
|
||||||
document.getElementById('tool-undo')?.addEventListener('click', undo);
|
toolUndoBtn?.addEventListener('click', undo);
|
||||||
document.getElementById('tool-redo')?.addEventListener('click', redo);
|
toolRedoBtn?.addEventListener('click', redo);
|
||||||
|
|
||||||
// Eyedropper Tool
|
// Eyedropper Tool
|
||||||
const toolEyedropperBtn = document.getElementById('tool-eyedropper');
|
const toolEyedropperBtn = document.getElementById('tool-eyedropper');
|
||||||
@ -240,7 +265,21 @@
|
|||||||
});
|
});
|
||||||
return 0.2126*norm[0] + 0.7152*norm[1] + 0.0722*norm[2];
|
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 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) {
|
function radiusToSizeIndex(r) {
|
||||||
let best = 0, bestDiff = Infinity;
|
let best = 0, bestDiff = Infinity;
|
||||||
for (let i = 0; i < SIZE_PRESETS.length; i++) {
|
for (let i = 0; i < SIZE_PRESETS.length; i++) {
|
||||||
@ -301,19 +340,45 @@
|
|||||||
persist();
|
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() {
|
function updateSelectButtons() {
|
||||||
const has = !!selectedBalloonId;
|
const has = selectedIds.size > 0;
|
||||||
if (deleteSelectedBtn) deleteSelectedBtn.disabled = !has;
|
if (deleteSelectedBtn) deleteSelectedBtn.disabled = !has;
|
||||||
if (duplicateSelectedBtn) duplicateSelectedBtn.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 (bringForwardBtn) bringForwardBtn.disabled = !has;
|
||||||
if (sendBackwardBtn) sendBackwardBtn.disabled = !has;
|
if (sendBackwardBtn) sendBackwardBtn.disabled = !has;
|
||||||
if (applyColorBtn) applyColorBtn.disabled = !has;
|
if (applyColorBtn) applyColorBtn.disabled = !has;
|
||||||
if (has && selectedSizeInput && selectedSizeLabel) {
|
if (selectedSizeInput && selectedSizeLabel) {
|
||||||
const b = balloons.find(bb => bb.id === selectedBalloonId);
|
if (has) {
|
||||||
if (b) {
|
const first = balloons.find(bb => selectedIds.has(bb.id));
|
||||||
selectedSizeInput.value = Math.round(b.radius);
|
if (first) {
|
||||||
selectedSizeLabel.textContent = `${Math.round(b.radius)}`;
|
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 isDragging = false;
|
||||||
let dragStartPos = { x: 0, y: 0 };
|
let dragStartPos = { x: 0, y: 0 };
|
||||||
let initialBalloonPos = { 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 => {
|
canvas.addEventListener('pointerdown', e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -338,81 +422,120 @@
|
|||||||
|
|
||||||
if (mode === 'erase') {
|
if (mode === 'erase') {
|
||||||
pointerDown = true;
|
pointerDown = true;
|
||||||
pushHistory(); // Save state before erasing
|
erasingActive = true;
|
||||||
eraseAt(mousePos.x, mousePos.y);
|
eraseChanged = eraseAt(mousePos.x, mousePos.y);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode === 'select') {
|
if (mode === 'select') {
|
||||||
|
pointerDown = true;
|
||||||
const clickedIdx = findBalloonIndexAt(mousePos.x, mousePos.y);
|
const clickedIdx = findBalloonIndexAt(mousePos.x, mousePos.y);
|
||||||
|
|
||||||
if (clickedIdx !== -1) {
|
if (clickedIdx !== -1) {
|
||||||
// We clicked on a balloon
|
|
||||||
const b = balloons[clickedIdx];
|
const b = balloons[clickedIdx];
|
||||||
if (selectedBalloonId !== b.id) {
|
if (e.shiftKey) {
|
||||||
selectedBalloonId = b.id;
|
if (selectedIds.has(b.id)) selectedIds.delete(b.id);
|
||||||
updateSelectButtons();
|
else selectedIds.add(b.id);
|
||||||
draw();
|
} else if (!selectedIds.has(b.id)) {
|
||||||
|
selectedIds.clear();
|
||||||
|
selectedIds.add(b.id);
|
||||||
}
|
}
|
||||||
// Start Dragging
|
updateSelectButtons();
|
||||||
|
draw();
|
||||||
isDragging = true;
|
isDragging = true;
|
||||||
pointerDown = true;
|
|
||||||
dragStartPos = { ...mousePos };
|
dragStartPos = { ...mousePos };
|
||||||
initialBalloonPos = { x: b.x, y: b.y };
|
dragOffsets = selectionBalloons().map(bb => ({ id: bb.id, dx: bb.x - mousePos.x, dy: bb.y - mousePos.y }));
|
||||||
pushHistory(); // Save state before move
|
dragMoved = false;
|
||||||
} else {
|
} else {
|
||||||
// Clicked empty space -> deselect
|
if (!e.shiftKey) selectedIds.clear();
|
||||||
if (selectedBalloonId) {
|
updateSelectButtons();
|
||||||
selectedBalloonId = null;
|
marqueeActive = true;
|
||||||
updateSelectButtons();
|
marqueeStart = { ...mousePos };
|
||||||
draw();
|
marqueeEnd = { ...mousePos };
|
||||||
}
|
requestDraw();
|
||||||
// Perhaps handle panning here later?
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// draw mode: add
|
// draw mode: add
|
||||||
pushHistory(); // Save state before add
|
|
||||||
addBalloon(mousePos.x, mousePos.y);
|
addBalloon(mousePos.x, mousePos.y);
|
||||||
pointerDown = true; // track for potential continuous drawing or other gestures?
|
pointerDown = true; // track for potential continuous drawing or other gestures?
|
||||||
}, { passive: false });
|
}, { passive: false });
|
||||||
|
|
||||||
canvas.addEventListener('pointermove', e => {
|
canvas.addEventListener('pointermove', e => {
|
||||||
|
mouseInside = true;
|
||||||
mousePos = getMousePos(e);
|
mousePos = getMousePos(e);
|
||||||
|
|
||||||
if (mode === 'select') {
|
if (mode === 'select') {
|
||||||
if (isDragging && selectedBalloonId) {
|
if (isDragging && selectedIds.size) {
|
||||||
const dx = mousePos.x - dragStartPos.x;
|
const dx = mousePos.x - dragStartPos.x;
|
||||||
const dy = mousePos.y - dragStartPos.y;
|
const dy = mousePos.y - dragStartPos.y;
|
||||||
const b = balloons.find(bb => bb.id === selectedBalloonId);
|
dragOffsets.forEach(off => {
|
||||||
if (b) {
|
const b = balloons.find(bb => bb.id === off.id);
|
||||||
b.x = initialBalloonPos.x + dx;
|
if (b) {
|
||||||
b.y = initialBalloonPos.y + dy;
|
b.x = mousePos.x + off.dx;
|
||||||
draw();
|
b.y = mousePos.y + off.dy;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
requestDraw();
|
||||||
|
dragMoved = true;
|
||||||
|
} else if (marqueeActive) {
|
||||||
|
marqueeEnd = { ...mousePos };
|
||||||
|
requestDraw();
|
||||||
} else {
|
} else {
|
||||||
// Hover cursor
|
|
||||||
const hoverIdx = findBalloonIndexAt(mousePos.x, mousePos.y);
|
const hoverIdx = findBalloonIndexAt(mousePos.x, mousePos.y);
|
||||||
canvas.style.cursor = (hoverIdx !== -1) ? 'move' : 'default';
|
canvas.style.cursor = (hoverIdx !== -1) ? 'move' : 'default';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode === 'erase') {
|
if (mode === 'erase') {
|
||||||
if (pointerDown) eraseAt(mousePos.x, mousePos.y);
|
if (pointerDown) {
|
||||||
else draw();
|
eraseChanged = eraseAt(mousePos.x, mousePos.y) || eraseChanged;
|
||||||
|
if (eraseChanged) requestDraw();
|
||||||
|
} else {
|
||||||
|
requestDraw();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, { passive: true });
|
}, { passive: true });
|
||||||
|
|
||||||
|
canvas.addEventListener('pointerenter', () => {
|
||||||
|
mouseInside = true;
|
||||||
|
if (mode === 'erase') requestDraw();
|
||||||
|
});
|
||||||
|
|
||||||
canvas.addEventListener('pointerup', e => {
|
canvas.addEventListener('pointerup', e => {
|
||||||
pointerDown = false;
|
pointerDown = false;
|
||||||
isDragging = 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);
|
canvas.releasePointerCapture?.(e.pointerId);
|
||||||
}, { passive: true });
|
}, { passive: true });
|
||||||
|
|
||||||
canvas.addEventListener('pointerleave', () => {
|
canvas.addEventListener('pointerleave', () => {
|
||||||
mouseInside = false;
|
mouseInside = false;
|
||||||
if (mode === 'erase') draw();
|
marqueeActive = false;
|
||||||
|
if (mode === 'erase') requestDraw();
|
||||||
}, { passive: true });
|
}, { passive: true });
|
||||||
|
|
||||||
// ====== Canvas & Drawing ======
|
// ====== Canvas & Drawing ======
|
||||||
@ -477,8 +600,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isShineEnabled) {
|
if (isShineEnabled) {
|
||||||
const isBright = luminance(b.color) > 0.75;
|
const { fill: shineFill, stroke: shineStroke } = shineStyle(b.color);
|
||||||
const shineFill = isBright ? 'rgba(0,0,0,0.55)' : `rgba(255,255,255,${SHINE_ALPHA})`;
|
|
||||||
const sx = b.x - b.radius * SHINE_OFFSET;
|
const sx = b.x - b.radius * SHINE_OFFSET;
|
||||||
const sy = b.y - b.radius * SHINE_OFFSET;
|
const sy = b.y - b.radius * SHINE_OFFSET;
|
||||||
const rx = b.radius * SHINE_RX;
|
const rx = b.radius * SHINE_RX;
|
||||||
@ -497,8 +619,8 @@
|
|||||||
ctx.arc(0, 0, ry, 0, Math.PI * 2);
|
ctx.arc(0, 0, ry, 0, Math.PI * 2);
|
||||||
}
|
}
|
||||||
ctx.fillStyle = shineFill;
|
ctx.fillStyle = shineFill;
|
||||||
if (isBright) {
|
if (shineStroke) {
|
||||||
ctx.strokeStyle = 'rgba(0,0,0,0.45)';
|
ctx.strokeStyle = shineStroke;
|
||||||
ctx.lineWidth = 1.5;
|
ctx.lineWidth = 1.5;
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
@ -507,23 +629,38 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// selection ring
|
// selection ring(s)
|
||||||
if (selectedBalloonId) {
|
if (selectedIds.size) {
|
||||||
const b = balloons.find(bb => bb.id === selectedBalloonId);
|
ctx.save();
|
||||||
if (b) {
|
selectedIds.forEach(id => {
|
||||||
ctx.save();
|
const b = balloons.find(bb => bb.id === id);
|
||||||
|
if (!b) return;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(b.x, b.y, b.radius + 3, 0, Math.PI * 2);
|
ctx.arc(b.x, b.y, b.radius + 3, 0, Math.PI * 2);
|
||||||
// White halo
|
|
||||||
ctx.lineWidth = 4 / view.s;
|
ctx.lineWidth = 4 / view.s;
|
||||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
// Blue ring
|
|
||||||
ctx.lineWidth = 2 / view.s;
|
ctx.lineWidth = 2 / view.s;
|
||||||
ctx.strokeStyle = '#3b82f6';
|
ctx.strokeStyle = '#3b82f6';
|
||||||
ctx.stroke();
|
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
|
// eraser preview
|
||||||
@ -587,10 +724,12 @@
|
|||||||
usedSortDesc = s.usedSortDesc;
|
usedSortDesc = s.usedSortDesc;
|
||||||
if (sortUsedToggle) sortUsedToggle.textContent = usedSortDesc ? 'Sort: Most → Least' : 'Sort: Least → Most';
|
if (sortUsedToggle) sortUsedToggle.textContent = usedSortDesc ? 'Sort: Most → Least' : 'Sort: Least → Most';
|
||||||
}
|
}
|
||||||
|
updateCurrentColorChip();
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadAppState();
|
loadAppState();
|
||||||
|
resetHistory(); // establish initial history state for undo/redo controls
|
||||||
|
|
||||||
// ====== UI Rendering (Palettes) ======
|
// ====== UI Rendering (Palettes) ======
|
||||||
function renderAllowedPalette() {
|
function renderAllowedPalette() {
|
||||||
@ -628,6 +767,7 @@
|
|||||||
sw.addEventListener('click', () => {
|
sw.addEventListener('click', () => {
|
||||||
selectedColorIdx = idx ?? 0;
|
selectedColorIdx = idx ?? 0;
|
||||||
renderAllowedPalette();
|
renderAllowedPalette();
|
||||||
|
updateCurrentColorChip();
|
||||||
persist();
|
persist();
|
||||||
});
|
});
|
||||||
row.appendChild(sw);
|
row.appendChild(sw);
|
||||||
@ -652,6 +792,28 @@
|
|||||||
return arr;
|
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() {
|
function renderUsedPalette() {
|
||||||
if (!usedPaletteBox) return;
|
if (!usedPaletteBox) return;
|
||||||
usedPaletteBox.innerHTML = '';
|
usedPaletteBox.innerHTML = '';
|
||||||
@ -719,7 +881,8 @@
|
|||||||
id: crypto.randomUUID()
|
id: crypto.randomUUID()
|
||||||
});
|
});
|
||||||
ensureVisibleAfterAdd(balloons[balloons.length - 1]);
|
ensureVisibleAfterAdd(balloons[balloons.length - 1]);
|
||||||
refreshAll();
|
refreshAll({ autoFit: true });
|
||||||
|
pushHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
function findBalloonIndexAt(x, y) {
|
function findBalloonIndexAt(x, y) {
|
||||||
@ -732,83 +895,107 @@
|
|||||||
|
|
||||||
function selectAt(x, y) {
|
function selectAt(x, y) {
|
||||||
const i = findBalloonIndexAt(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();
|
updateSelectButtons();
|
||||||
draw();
|
draw();
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveSelected(dx, dy) {
|
function moveSelected(dx, dy) {
|
||||||
if (!selectedBalloonId) return;
|
const sel = selectionBalloons();
|
||||||
const b = balloons.find(bb => bb.id === selectedBalloonId);
|
if (!sel.length) return;
|
||||||
if (!b) return;
|
sel.forEach(b => { b.x += dx; b.y += dy; });
|
||||||
b.x += dx;
|
|
||||||
b.y += dy;
|
|
||||||
refreshAll();
|
refreshAll();
|
||||||
|
pushHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
function resizeSelected(newRadius) {
|
function resizeSelected(newDiamInches) {
|
||||||
if (!selectedBalloonId) return;
|
const sel = selectionBalloons();
|
||||||
const b = balloons.find(bb => bb.id === selectedBalloonId);
|
if (!sel.length) return;
|
||||||
if (!b) return;
|
const diam = clamp(newDiamInches, 5, 32);
|
||||||
b.radius = clamp(newRadius, 5, 200);
|
const newRadius = inchesToRadiusPx(diam);
|
||||||
|
sel.forEach(b => { b.radius = newRadius; });
|
||||||
refreshAll();
|
refreshAll();
|
||||||
updateSelectButtons();
|
updateSelectButtons();
|
||||||
|
resizeChanged = true;
|
||||||
|
clearTimeout(resizeSaveTimer);
|
||||||
|
resizeSaveTimer = setTimeout(() => {
|
||||||
|
if (resizeChanged) {
|
||||||
|
pushHistory();
|
||||||
|
resizeChanged = false;
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
function bringSelectedForward() {
|
function bringSelectedForward() {
|
||||||
if (!selectedBalloonId) return;
|
const sel = selectionArray();
|
||||||
const idx = balloons.findIndex(bb => bb.id === selectedBalloonId);
|
if (!sel.length) return;
|
||||||
if (idx === -1 || idx === balloons.length - 1) return;
|
const set = new Set(sel);
|
||||||
const [b] = balloons.splice(idx, 1);
|
const kept = balloons.filter(b => !set.has(b.id));
|
||||||
balloons.push(b);
|
const moving = balloons.filter(b => set.has(b.id));
|
||||||
refreshAll();
|
balloons = kept.concat(moving);
|
||||||
|
refreshAll({ autoFit: true });
|
||||||
|
pushHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendSelectedBackward() {
|
function sendSelectedBackward() {
|
||||||
if (!selectedBalloonId) return;
|
const sel = selectionArray();
|
||||||
const idx = balloons.findIndex(bb => bb.id === selectedBalloonId);
|
if (!sel.length) return;
|
||||||
if (idx <= 0) return;
|
const set = new Set(sel);
|
||||||
const [b] = balloons.splice(idx, 1);
|
const moving = balloons.filter(b => set.has(b.id));
|
||||||
balloons.unshift(b);
|
const kept = balloons.filter(b => !set.has(b.id));
|
||||||
refreshAll();
|
balloons = moving.concat(kept);
|
||||||
|
refreshAll({ autoFit: true });
|
||||||
|
pushHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyColorToSelected() {
|
function applyColorToSelected() {
|
||||||
if (!selectedBalloonId) return;
|
|
||||||
const b = balloons.find(bb => bb.id === selectedBalloonId);
|
|
||||||
const meta = FLAT_COLORS[selectedColorIdx] || FLAT_COLORS[0];
|
const meta = FLAT_COLORS[selectedColorIdx] || FLAT_COLORS[0];
|
||||||
if (!b || !meta) return;
|
if (!meta) return;
|
||||||
b.color = meta.hex;
|
let changed = false;
|
||||||
b.image = meta.image || null;
|
selectionBalloons().forEach(b => {
|
||||||
b.colorIdx = meta._idx;
|
b.color = meta.hex;
|
||||||
|
b.image = meta.image || null;
|
||||||
|
b.colorIdx = meta._idx;
|
||||||
|
changed = true;
|
||||||
|
});
|
||||||
|
if (!changed) return;
|
||||||
refreshAll();
|
refreshAll();
|
||||||
|
pushHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteSelected() {
|
function deleteSelected() {
|
||||||
if (!selectedBalloonId) return;
|
if (!selectedIds.size) return;
|
||||||
balloons = balloons.filter(b => b.id !== selectedBalloonId);
|
balloons = balloons.filter(b => !selectedIds.has(b.id));
|
||||||
selectedBalloonId = null;
|
selectedIds.clear();
|
||||||
updateSelectButtons();
|
updateSelectButtons();
|
||||||
refreshAll();
|
refreshAll({ autoFit: true });
|
||||||
|
pushHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
function duplicateSelected() {
|
function duplicateSelected() {
|
||||||
if (!selectedBalloonId) return;
|
const sel = selectionBalloons();
|
||||||
const b = balloons.find(bb => bb.id === selectedBalloonId);
|
if (!sel.length) return;
|
||||||
if (!b) return;
|
const copies = sel.map(b => ({ ...b, x: b.x + 10, y: b.y + 10, id: crypto.randomUUID() }));
|
||||||
const copy = { ...b, x: b.x + 10, y: b.y + 10, id: crypto.randomUUID() };
|
copies.forEach(c => balloons.push(c));
|
||||||
balloons.push(copy);
|
selectedIds = new Set(copies.map(c => c.id));
|
||||||
selectedBalloonId = copy.id;
|
refreshAll({ autoFit: true });
|
||||||
refreshAll();
|
updateSelectButtons();
|
||||||
|
pushHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
function eraseAt(x, y) {
|
function eraseAt(x, y) {
|
||||||
|
const before = balloons.length;
|
||||||
balloons = balloons.filter(b => Math.hypot(x - b.x, y - b.y) > eraserRadius);
|
balloons = balloons.filter(b => Math.hypot(x - b.x, y - b.y) > eraserRadius);
|
||||||
if (selectedBalloonId && !balloons.find(b => b.id === selectedBalloonId)) {
|
const removed = balloons.length !== before;
|
||||||
selectedBalloonId = null;
|
if (selectedIds.size) {
|
||||||
updateSelectButtons();
|
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) {
|
function pickColorAt(x, y) {
|
||||||
@ -817,6 +1004,7 @@
|
|||||||
selectedColorIdx = HEX_TO_FIRST_IDX.get(normalizeHex(balloons[i].color)) ?? 0;
|
selectedColorIdx = HEX_TO_FIRST_IDX.get(normalizeHex(balloons[i].color)) ?? 0;
|
||||||
renderAllowedPalette();
|
renderAllowedPalette();
|
||||||
renderUsedPalette();
|
renderUsedPalette();
|
||||||
|
updateCurrentColorChip();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -873,9 +1061,10 @@
|
|||||||
};
|
};
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
selectedBalloonId = null;
|
selectedIds.clear();
|
||||||
updateSelectButtons();
|
updateSelectButtons();
|
||||||
refreshAll({ refit: true });
|
refreshAll({ refit: true });
|
||||||
|
resetHistory();
|
||||||
persist();
|
persist();
|
||||||
} catch {
|
} catch {
|
||||||
showModal('Error parsing JSON file.');
|
showModal('Error parsing JSON file.');
|
||||||
@ -1019,9 +1208,8 @@
|
|||||||
const sy = b.y - b.radius * SHINE_OFFSET;
|
const sy = b.y - b.radius * SHINE_OFFSET;
|
||||||
const rx = b.radius * SHINE_RX;
|
const rx = b.radius * SHINE_RX;
|
||||||
const ry = b.radius * SHINE_RY;
|
const ry = b.radius * SHINE_RY;
|
||||||
const isBright = luminance(b.color) > 0.75;
|
const { fill: shineFill, stroke: shineStroke } = shineStyle(b.color);
|
||||||
const shineFill = isBright ? 'rgba(0,0,0,0.55)' : `rgba(255,255,255,${SHINE_ALPHA})`;
|
const stroke = shineStroke ? ` stroke="${shineStroke}" stroke-width="1.5"` : '';
|
||||||
const stroke = isBright ? ' stroke="rgba(0,0,0,0.45)" stroke-width="1.5"' : '';
|
|
||||||
elements += `<ellipse cx="${sx}" cy="${sy}" rx="${rx}" ry="${ry}" fill="${shineFill}"${stroke} transform="rotate(${SHINE_ROT} ${sx} ${sy})" />`;
|
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');
|
const svgElement = document.querySelector('#classic-display svg');
|
||||||
if (!svgElement) throw new Error('Classic design not found. Please create a design first.');
|
if (!svgElement) throw new Error('Classic design not found. Please create a design first.');
|
||||||
const clonedSvg = svgElement.cloneNode(true);
|
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
|
// Inline pattern images and any other <image> nodes
|
||||||
const allImages = Array.from(clonedSvg.querySelectorAll('image'));
|
const allImages = Array.from(clonedSvg.querySelectorAll('image'));
|
||||||
@ -1050,10 +1251,17 @@
|
|||||||
|
|
||||||
// Ensure required namespaces are present
|
// Ensure required namespaces are present
|
||||||
const viewBox = (clonedSvg.getAttribute('viewBox') || '0 0 1000 1000').split(/\s+/).map(Number);
|
const viewBox = (clonedSvg.getAttribute('viewBox') || '0 0 1000 1000').split(/\s+/).map(Number);
|
||||||
const vbX = isFinite(viewBox[0]) ? viewBox[0] : 0;
|
let vbX = isFinite(viewBox[0]) ? viewBox[0] : 0;
|
||||||
const vbY = isFinite(viewBox[1]) ? viewBox[1] : 0;
|
let vbY = isFinite(viewBox[1]) ? viewBox[1] : 0;
|
||||||
const vbW = isFinite(viewBox[2]) ? viewBox[2] : (svgElement.clientWidth || 1000);
|
let vbW = isFinite(viewBox[2]) ? viewBox[2] : (svgElement.clientWidth || 1000);
|
||||||
const vbH = isFinite(viewBox[3]) ? viewBox[3] : (svgElement.clientHeight || 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('width', vbW);
|
||||||
clonedSvg.setAttribute('height', vbH);
|
clonedSvg.setAttribute('height', vbH);
|
||||||
if (!clonedSvg.getAttribute('xmlns')) clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
if (!clonedSvg.getAttribute('xmlns')) clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||||
@ -1073,11 +1281,15 @@
|
|||||||
|
|
||||||
async function svgStringToPng(svgString, width, height) {
|
async function svgStringToPng(svgString, width, height) {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
const scale = 2;
|
const scale = PNG_EXPORT_SCALE;
|
||||||
const canvasEl = document.createElement('canvas');
|
const canvasEl = document.createElement('canvas');
|
||||||
canvasEl.width = Math.max(1, Math.round(width * scale));
|
canvasEl.width = Math.max(1, Math.round(width * scale));
|
||||||
canvasEl.height = Math.max(1, Math.round(height * scale));
|
canvasEl.height = Math.max(1, Math.round(height * scale));
|
||||||
const ctx2 = canvasEl.getContext('2d');
|
const ctx2 = canvasEl.getContext('2d');
|
||||||
|
if (ctx2) {
|
||||||
|
ctx2.imageSmoothingEnabled = true;
|
||||||
|
ctx2.imageSmoothingQuality = 'high';
|
||||||
|
}
|
||||||
const dataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgString)}`;
|
const dataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgString)}`;
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
img.onload = resolve;
|
img.onload = resolve;
|
||||||
@ -1204,7 +1416,9 @@
|
|||||||
})
|
})
|
||||||
: compactToDesign(data);
|
: compactToDesign(data);
|
||||||
refreshAll({ refit: true });
|
refreshAll({ refit: true });
|
||||||
|
resetHistory();
|
||||||
persist();
|
persist();
|
||||||
|
updateCurrentColorChip();
|
||||||
showModal('Design loaded from link!');
|
showModal('Design loaded from link!');
|
||||||
} catch {
|
} catch {
|
||||||
showModal('Could not load design from URL.');
|
showModal('Could not load design from URL.');
|
||||||
@ -1274,11 +1488,13 @@
|
|||||||
|
|
||||||
view.tx += dx;
|
view.tx += dx;
|
||||||
view.ty += dy;
|
view.ty += dy;
|
||||||
|
return (dx !== 0 || dy !== 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ====== Refresh & Events ======
|
// ====== Refresh & Events ======
|
||||||
function refreshAll({ refit = false } = {}) {
|
function refreshAll({ refit = false, autoFit = false } = {}) {
|
||||||
if (refit) fitView();
|
if (refit) fitView();
|
||||||
|
else if (autoFit) fitView();
|
||||||
draw();
|
draw();
|
||||||
renderUsedPalette();
|
renderUsedPalette();
|
||||||
persist();
|
persist();
|
||||||
@ -1310,10 +1526,22 @@
|
|||||||
selectedSizeInput?.addEventListener('input', e => {
|
selectedSizeInput?.addEventListener('input', e => {
|
||||||
resizeSelected(parseFloat(e.target.value) || 0);
|
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);
|
bringForwardBtn?.addEventListener('click', bringSelectedForward);
|
||||||
sendBackwardBtn?.addEventListener('click', sendSelectedBackward);
|
sendBackwardBtn?.addEventListener('click', sendSelectedBackward);
|
||||||
applyColorBtn?.addEventListener('click', applyColorToSelected);
|
applyColorBtn?.addEventListener('click', applyColorToSelected);
|
||||||
|
fitViewBtn?.addEventListener('click', () => refreshAll({ refit: true }));
|
||||||
|
|
||||||
document.addEventListener('keydown', e => {
|
document.addEventListener('keydown', e => {
|
||||||
if (document.activeElement && document.activeElement.tagName === 'INPUT') return;
|
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 === 'v' || e.key === 'V') setMode('draw');
|
||||||
else if (e.key === 's' || e.key === 'S') setMode('select');
|
else if (e.key === 's' || e.key === 'S') setMode('select');
|
||||||
else if (e.key === 'Escape') {
|
else if (e.key === 'Escape') {
|
||||||
if (selectedBalloonId) {
|
if (selectedIds.size) {
|
||||||
selectedBalloonId = null;
|
clearSelection();
|
||||||
updateSelectButtons();
|
|
||||||
draw();
|
|
||||||
} else if (mode !== 'draw') {
|
} else if (mode !== 'draw') {
|
||||||
setMode('draw');
|
setMode('draw');
|
||||||
}
|
}
|
||||||
} else if (e.key === 'Delete' || e.key === 'Backspace') {
|
} 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')) {
|
} else if ((e.ctrlKey || e.metaKey) && (e.key === 'z' || e.key === 'Z')) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
undo();
|
undo();
|
||||||
} else if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || e.key === 'Y')) {
|
} else if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || e.key === 'Y')) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
redo();
|
redo();
|
||||||
|
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'd') {
|
||||||
|
e.preventDefault();
|
||||||
|
duplicateSelected();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
clearCanvasBtn?.addEventListener('click', () => {
|
clearCanvasBtn?.addEventListener('click', () => {
|
||||||
balloons = [];
|
balloons = [];
|
||||||
selectedBalloonId = null;
|
selectedIds.clear();
|
||||||
updateSelectButtons();
|
updateSelectButtons();
|
||||||
refreshAll({ refit: true });
|
refreshAll({ refit: true });
|
||||||
|
pushHistory();
|
||||||
});
|
});
|
||||||
|
|
||||||
saveJsonBtn?.addEventListener('click', saveJson);
|
saveJsonBtn?.addEventListener('click', saveJson);
|
||||||
@ -1394,6 +1624,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
|
pushHistory();
|
||||||
if (replaceMsg) replaceMsg.textContent = `Replaced ${count} balloon${count === 1 ? '' : 's'}.`;
|
if (replaceMsg) replaceMsg.textContent = `Replaced ${count} balloon${count === 1 ? '' : 's'}.`;
|
||||||
if (normalizeHex(FLAT_COLORS[selectedColorIdx]?.hex) === normalizeHex(fromHex)) selectedColorIdx = toIdx;
|
if (normalizeHex(FLAT_COLORS[selectedColorIdx]?.hex) === normalizeHex(fromHex)) selectedColorIdx = toIdx;
|
||||||
refreshAll();
|
refreshAll();
|
||||||
|
|||||||
26
style.css
26
style.css
@ -8,6 +8,7 @@ body { color: #1f2937; }
|
|||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
box-shadow: 0 4px 6px -1px rgba(0,0,0,.1), 0 2px 4px -1px rgba(0,0,0,.06);
|
box-shadow: 0 4px 6px -1px rgba(0,0,0,.1), 0 2px 4px -1px rgba(0,0,0,.06);
|
||||||
border: 1px black solid;
|
border: 1px black solid;
|
||||||
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Buttons */
|
/* Buttons */
|
||||||
@ -27,6 +28,7 @@ body { color: #1f2937; }
|
|||||||
.tool-btn svg { width: 1.1em; height: 1.1em; fill: currentColor; }
|
.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: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[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 */
|
/* 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); }
|
.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:focus-visible { outline: 2px solid #6366f1; outline-offset: 2px; }
|
||||||
.swatch.active { 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; }
|
.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; }
|
.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 { 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; }
|
#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[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 {
|
.slot-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -149,6 +173,7 @@ body { color: #1f2937; }
|
|||||||
color: rgba(0,0,0,0.4);
|
color: rgba(0,0,0,0.4);
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
background-position: center;
|
background-position: center;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slot-swatch:hover {
|
.slot-swatch:hover {
|
||||||
@ -159,6 +184,7 @@ body { color: #1f2937; }
|
|||||||
border-color: #2563eb;
|
border-color: #2563eb;
|
||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
|
.slot-swatch.active::after { display: none; }
|
||||||
|
|
||||||
.topper-type-group {
|
.topper-type-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user