From 70b2af53d13281eb90b681762aad8f5a4152856b Mon Sep 17 00:00:00 2001 From: chris Date: Mon, 1 Dec 2025 09:19:54 -0500 Subject: [PATCH] chore: snapshot v3 version --- classic.js | 91 ++++++++++- index.html | 87 +++++----- script.js | 469 +++++++++++++++++++++++++++++++++++++++-------------- style.css | 26 +++ 4 files changed, 505 insertions(+), 168 deletions(-) diff --git a/classic.js b/classic.js index f2a7a23..f31ad61 100644 --- a/classic.js +++ b/classic.js @@ -11,6 +11,60 @@ `; }; 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); } diff --git a/index.html b/index.html index 770f593..22ad0cd 100644 --- a/index.html +++ b/index.html @@ -33,10 +33,12 @@
Professional Design Tool
- +
+ +
@@ -75,32 +77,23 @@ - +
-
Used Colors
+
Project Palette
Built from the current design. Click a swatch to select that color. @@ -135,19 +129,25 @@
-
Allowed Colors
+
Color Library
-

Alt+Click a balloon on canvas to pick its color.

+

Alt+click on canvas to sample a balloon’s color.

+
+ Active Color +
+ +
+
Replace Color
- + - + @@ -177,7 +177,7 @@
-

SVG currently Classic only.

+

SVG export keeps vectors in Classic; Organic embeds textures.

@@ -268,6 +268,7 @@ Reverse spiral +

Use stacked for “same color per quad” layouts; reverse flips the spiral.

@@ -282,12 +283,14 @@
Pick a color for Slot #1 (from colors.js):
- +
-
Topper Color
-
- -

Select a color then click to apply.

+
@@ -298,9 +301,9 @@
-

SVG recommended for Classic.

+

SVG keeps the vector Classic layout; PNG is raster.

-

Classic JSON save/load not available yet.

+

Classic JSON save/load coming soon.

diff --git a/script.js b/script.js index 62981e3..f23a9ca 100644 --- a/script.js +++ b/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 += ``; } }); @@ -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 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(); diff --git a/style.css b/style.css index e57f464..fd50b26 100644 --- a/style.css +++ b/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;