diff --git a/classic.js b/classic.js index e6b81a2..2d2bb30 100644 --- a/classic.js +++ b/classic.js @@ -3,6 +3,7 @@ // -------- helpers ---------- const log = (...a) => console.log('[Classic]', ...a); + const debug = (...a) => console.debug('[Classic/manual]', ...a); const fail = (msg) => { console.error('[Classic ERROR]', msg); const d = document.getElementById('classic-display'); @@ -38,18 +39,25 @@ }); return 0.2126 * norm[0] + 0.7152 * norm[1] + 0.0722 * norm[2]; } + let manualModeState = false; + let classicZoom = 1; + const clampZoom = (z) => Math.min(2.2, Math.max(0.5, z)); + let currentPatternName = ''; + let currentRowCount = 0; + let manualUndoStack = []; + let manualRedoStack = []; function classicShineStyle(colorInfo) { const hex = normHex(colorInfo?.hex || colorInfo?.colour || ''); if (hex.startsWith('#')) { const lum = luminance(hex); + // For bright hues (yellows, pastels) avoid darkening which can skew green; use a light, soft highlight instead. 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 - }; + // Slightly stronger highlight for bright hues while staying neutral + return { fill: 'rgba(255,255,255,0.4)', opacity: 1, stroke: null }; + } + // Deep shades keep a stronger white highlight. + if (lum < 0.2) { + return { fill: 'rgba(255,255,255,0.55)', opacity: 1, stroke: null }; } } return { fill: '#ffffff', opacity: 0.45, stroke: null }; @@ -65,13 +73,19 @@ } return { color: '#0f172a', shadow: 'none' }; } + function outlineColorFor(colorInfo) { + // Mirrors the topper chip text color choice for consistent contrast. + const chipStyle = textStyleForColor(colorInfo || { hex: '#ffffff' }); + return chipStyle.color || '#0f172a'; + } // -------- persistent color selection (now supports image textures) ---------- const PALETTE_KEY = 'classic:colors:v2'; const TOPPER_COLOR_KEY = 'classic:topperColor:v2'; const CLASSIC_STATE_KEY = 'classic:state:v1'; - const NUMBER_TINT_COLOR_KEY = 'classic:numberTintColor:v1'; - const NUMBER_TINT_OPACITY_KEY = 'classic:numberTintOpacity:v1'; + const MANUAL_MODE_KEY = 'classic:manualMode:v1'; + const MANUAL_OVERRIDES_KEY = 'classic:manualOverrides:v1'; + const MANUAL_EXPANDED_KEY = 'classic:manualExpanded:v1'; const NUMBER_IMAGE_MAP = { '0': 'output_webp/0.webp', '1': 'output_webp/1.webp', @@ -92,7 +106,15 @@ { hex: '#0055a4', image: null }, { hex: '#40e0d0', image: null }, { hex: '#fcd34d', image: null } ]; - const defaultTopper = () => ({ hex: '#a18b67', image: 'images/chrome-gold.webp' }); + const defaultTopper = () => ({ hex: '#E32636', image: 'images/chrome-gold.webp' }); // Classic gold + const numberSpriteSet = new Set(Object.values(NUMBER_IMAGE_MAP)); + const sanitizeTopperColor = (colorObj = {}) => { + const base = defaultTopper(); + const hex = normHex(colorObj.hex || base.hex); + const img = colorObj.image || null; + const image = (img && !numberSpriteSet.has(img)) ? img : null; + return { hex, image }; + }; function getClassicColors() { let arr = defaultColors(); @@ -125,38 +147,102 @@ function getTopperColor() { try { const saved = JSON.parse(localStorage.getItem(TOPPER_COLOR_KEY)); - return (saved && saved.hex) ? saved : defaultTopper(); - } catch { return defaultTopper(); } + if (saved && saved.hex) return sanitizeTopperColor(saved); + } catch {} + return sanitizeTopperColor(defaultTopper()); } function setTopperColor(colorObj) { - const clean = { hex: normHex(colorObj.hex), image: colorObj.image || null }; + const clean = sanitizeTopperColor(colorObj); try { localStorage.setItem(TOPPER_COLOR_KEY, JSON.stringify(clean)); } catch {} } - function getNumberTintColor() { + function loadManualMode() { try { - const saved = JSON.parse(localStorage.getItem(NUMBER_TINT_COLOR_KEY)); - if (saved && saved.hex) return normHex(saved.hex); + const saved = JSON.parse(localStorage.getItem(MANUAL_MODE_KEY)); + if (typeof saved === 'boolean') return saved; } catch {} - return '#ffffff'; + return false; // default to pattern-driven mode; manual is opt-in } - function setNumberTintColor(hex) { - const clean = normHex(hex || '#ffffff'); - try { localStorage.setItem(NUMBER_TINT_COLOR_KEY, JSON.stringify({ hex: clean })); } catch {} - return clean; + function saveManualMode(on) { + try { localStorage.setItem(MANUAL_MODE_KEY, JSON.stringify(!!on)); } catch {} } - function getNumberTintOpacity() { + function loadManualExpanded() { try { - const saved = parseFloat(localStorage.getItem(NUMBER_TINT_OPACITY_KEY)); - if (!isNaN(saved)) return clamp01(saved); + const saved = JSON.parse(localStorage.getItem(MANUAL_EXPANDED_KEY)); + if (typeof saved === 'boolean') return saved; } catch {} - return 0.5; + return true; } - function setNumberTintOpacity(v) { - const clamped = clamp01(parseFloat(v)); - try { localStorage.setItem(NUMBER_TINT_OPACITY_KEY, String(clamped)); } catch {} - return clamped; + function saveManualExpanded(on) { + try { localStorage.setItem(MANUAL_EXPANDED_KEY, JSON.stringify(!!on)); } catch {} } + function loadManualOverrides() { + try { + const saved = JSON.parse(localStorage.getItem(MANUAL_OVERRIDES_KEY)); + if (saved && typeof saved === 'object') return saved; + } catch {} + return {}; + } + function saveManualOverrides(map) { + try { localStorage.setItem(MANUAL_OVERRIDES_KEY, JSON.stringify(map || {})); } catch {} + } + function manualKey(patternName, rowCount) { + return `${patternName || ''}::${rowCount || 0}`; + } + const manualOverrides = loadManualOverrides(); + function manualOverrideCount(patternName, rowCount) { + const key = manualKey(patternName, rowCount); + const entry = manualOverrides[key]; + return entry ? Object.keys(entry).length : 0; + } + function getManualOverride(patternName, rowCount, x, y) { + const key = manualKey(patternName, rowCount); + const entry = manualOverrides[key]; + if (!entry) return undefined; + return entry[`${x},${y}`]; + } + function setManualOverride(patternName, rowCount, x, y, value) { + const key = manualKey(patternName, rowCount); + if (!manualOverrides[key]) manualOverrides[key] = {}; + manualOverrides[key][`${x},${y}`] = value; + saveManualOverrides(manualOverrides); + } + function clearManualOverride(patternName, rowCount, x, y) { + const key = manualKey(patternName, rowCount); + const entry = manualOverrides[key]; + if (entry) { + delete entry[`${x},${y}`]; + saveManualOverrides(manualOverrides); + } + } + function manualUsedColorsFor(patternName, rowCount) { + const key = manualKey(patternName, rowCount); + const overrides = manualOverrides[key] || {}; + const palette = buildClassicPalette(); + const out = []; + const seen = new Set(); + Object.values(overrides).forEach(val => { + let hex = null, image = null; + if (val && typeof val === 'object') { + hex = normHex(val.hex || val.colour || ''); + image = val.image || null; + } else if (typeof val === 'number') { + const info = palette[val] || null; + hex = normHex(info?.colour || info?.hex || ''); + image = info?.image || null; + } + if (!hex && !image) return; + const keyStr = `${image || ''}|${hex || ''}`; + if (seen.has(keyStr)) return; + seen.add(keyStr); + out.push({ hex, image, label: hex || (image ? 'Texture' : 'Color') }); + }); + return out; + } + // Manual palette (used in Manual mode project palette) + let projectPaletteBox = null; + let renderProjectPalette = () => {}; + let manualActiveColorGlobal = (window.shared?.getActiveColor?.()) || { hex: '#ffffff', image: null }; function getTopperTypeSafe() { try { return (window.ClassicDesigner?.lastTopperType) || null; } catch { return null; } } @@ -209,10 +295,16 @@ let topperOffsetX_Px = 0; let topperOffsetY_Px = 0; let topperSizeMultiplier = 1; - let numberTintHex = getNumberTintColor(); - let numberTintOpacity = getNumberTintOpacity(); let shineEnabled = true; let borderEnabled = false; + let manualMode = loadManualMode(); + let explodedScale = 1.18; + let explodedGapPx = pxUnit * 1.6; + let explodedStaggerPx = pxUnit * 0.6; + let manualFocusStart = 0; + let manualFocusSize = 8; + let manualFocusEnabled = false; + let manualFloatingQuad = null; const patterns = {}; const api = { @@ -226,10 +318,20 @@ setTopperOffsetX(val) { topperOffsetX_Px = (Number(val) || 0) * 5; }, setTopperOffsetY(val) { topperOffsetY_Px = (Number(val) || 0) * -5; }, setTopperSize(multiplier) { topperSizeMultiplier = Number(multiplier) || 1; }, - setNumberTintHex(hex) { numberTintHex = setNumberTintColor(hex); }, - setNumberTintOpacity(val) { numberTintOpacity = setNumberTintOpacity(val); }, setShineEnabled(on) { shineEnabled = !!on; }, - setBorderEnabled(on) { borderEnabled = !!on; } + setBorderEnabled(on) { borderEnabled = !!on; }, + setManualMode(on) { manualMode = !!on; saveManualMode(manualMode); }, + setExplodedSettings({ scale, gapPx, staggerPx } = {}) { + if (Number.isFinite(scale)) explodedScale = scale; + if (Number.isFinite(gapPx)) explodedGapPx = gapPx; + if (Number.isFinite(staggerPx)) explodedStaggerPx = staggerPx; + }, + setManualFocus({ start, size, enabled }) { + if (Number.isFinite(start)) manualFocusStart = Math.max(0, start|0); + if (Number.isFinite(size)) manualFocusSize = Math.max(1, size|0); + if (typeof enabled === 'boolean') manualFocusEnabled = enabled; + }, + setManualFloatingQuad(val) { manualFloatingQuad = (val === null ? null : (val|0)); } }; const svg = (tag, attrs, children) => m(tag, attrs, children); @@ -252,17 +354,25 @@ const balloonSize = (cell)=> (cell.shape.size ?? 1); const cellScale = (cell)=> balloonSize(cell) * pxUnit; - function cellView(cell, id, explicitFill, model, colorInfo){ + function cellView(cell, id, explicitFill, model, colorInfo, opts = {}){ const shape = cell.shape; const base = shape.base || {}; const scale = cellScale(cell); - const transform = [(base.transform||''), `scale(${scale})`].join(' '); + const expandedOn = model.manualMode && (model.explodedGapPx || 0) > 0; + // Keep arch geometry consistent when expanded; only scale the alpha (wireframe) ring slightly to improve hit targets. + const manualScale = expandedOn && model.patternName?.toLowerCase().includes('arch') ? 1 : (expandedOn ? 1.15 : 1); + const transform = [(base.transform||''), `scale(${scale * manualScale})`].join(' '); + const isUnpainted = !colorInfo || explicitFill === 'none'; + const wireframe = !!opts.wireframe || (model.manualMode && isUnpainted); + const wantsOutlineOnly = model.manualMode && isUnpainted; + const wireStroke = '#94a3b8'; const commonAttrs = { 'vector-effect': 'non-scaling-stroke', - stroke: borderEnabled ? '#111827' : 'none', - 'stroke-width': borderEnabled ? 0.6 : 0, + stroke: wireframe ? wireStroke : (borderEnabled ? '#111827' : (wantsOutlineOnly ? wireStroke : 'none')), + 'stroke-width': wireframe ? 1.1 : (borderEnabled ? 0.8 : (wantsOutlineOnly ? 1.1 : 0)), 'paint-order': 'stroke fill', class: 'balloon', - fill: explicitFill || '#cccccc' + fill: isUnpainted ? 'none' : (explicitFill || '#cccccc'), + 'pointer-events': 'all' }; if (cell.isTopper) { commonAttrs['data-is-topper'] = true; @@ -273,22 +383,38 @@ const kids = []; const fillRule = base.fillRule || base['fill-rule'] || null; + const isNumTopper = cell.isTopper && (model.topperType || '').startsWith('num-'); if (base.image) { const w = base.width || 1, h = base.height || 1; - kids.push(svg('image', { href: base.image, x: -w/2, y: -h/2, width: w, height: h, preserveAspectRatio: base.preserveAspectRatio || 'xMidYMid meet', style: 'pointer-events:none' })); - const tintColor = model.numberTintHex || '#ffffff'; - const tintOpacity = model.numberTintOpacity || 0; - if (tintOpacity > 0 && cell.isTopper && (model.topperType || '').startsWith('num-')) { - const maskId = `mask-${id}`; - kids.push(svg('mask', { id: maskId, maskUnits: 'userSpaceOnUse' }, [ - svg('image', { href: base.image, x: -w/2, y: -h/2, width: w, height: h, preserveAspectRatio: base.preserveAspectRatio || 'xMidYMid meet', style: 'pointer-events:none' }) - ])); - kids.push(svg('rect', { - x: -w/2, y: -h/2, width: w, height: h, - fill: tintColor, opacity: tintOpacity, - mask: `url(#${maskId})`, - style: 'mix-blend-mode:multiply; pointer-events:none' + if (isNumTopper) { + const maskId = base.maskId || 'classic-num-mask'; + const fillVal = (colorInfo && colorInfo.image) + ? 'url(#classic-pattern-topper)' + : (colorInfo?.hex || '#ffffff'); + const lum = luminance(colorInfo?.hex || colorInfo?.colour || '#ffffff'); + const outlineFilterId = lum >= 0.55 ? 'classic-num-outline-dark' : 'classic-num-outline-light'; + kids.push(svg('image', { + href: base.image, + x: -w/2, + y: -h/2, + width: w, + height: h, + preserveAspectRatio: base.preserveAspectRatio || 'xMidYMid meet', + style: 'pointer-events:none', + filter: `url(#${outlineFilterId})`, + mask: `url(#${maskId})` })); + kids.push(svg('rect', { + x: -w/2, + y: -h/2, + width: w, + height: h, + fill: fillVal, + mask: `url(#${maskId})`, + style: 'pointer-events:none' + })); + } else { + kids.push(svg('image', { href: base.image, x: -w/2, y: -h/2, width: w, height: h, preserveAspectRatio: base.preserveAspectRatio || 'xMidYMid meet', style: 'pointer-events:none' })); } } else if (Array.isArray(base.paths)) { base.paths.forEach(p => { @@ -301,7 +427,7 @@ } const allowShine = base.allowShine !== false; - const applyShine = model.shineEnabled && (!cell.isTopper || allowShine); + const applyShine = !wireframe && model.shineEnabled && (!cell.isTopper || (allowShine && !isNumTopper)); if (applyShine) { const shine = classicShineStyle(colorInfo); const shineAttrs = { @@ -320,7 +446,30 @@ const rel = (pattern.baseBalloonSize && base.baseBalloonSize) ? pattern.baseBalloonSize/base.baseBalloonSize : 1; let p = { x: pattern.gridX(model.pattern.cellsPerRow > 1 ? y : x, x), y: pattern.gridY(y,x) }; if (pattern.transform) p = pattern.transform(p,x,y,model); - return { x: p.x * rel * pxUnit, y: p.y * rel * pxUnit }; + let xPx = p.x * rel * pxUnit; + let yPx = p.y * rel * pxUnit; + if (model.manualMode && (model.explodedGapPx || 0) > 0) { + const rowIndex = y; + const gap = model.explodedGapPx || 0; + const isArch = (model.patternName || '').toLowerCase().includes('arch'); + if (isArch) { + // Move outward along the radial vector and add a tangential nudge for even spread; push ends a bit more. + const dist = Math.hypot(xPx, yPx) || 1; + const maxRow = Math.max(1, (pattern.cellsPerRow * model.rowCount) - 1); + const t = Math.max(0, Math.min(1, y / maxRow)); // 0 first row, 1 last row + const radialPush = gap * (1.6 + Math.abs(t - 0.5) * 1.6); // ends > crown + const tangentialPush = (t - 0.5) * (gap * 0.8); // small along-arc spread + const nx = xPx / dist; + const ny = yPx / dist; + const tx = -ny; + const ty = nx; + xPx += nx * radialPush + tx * tangentialPush; + yPx += ny * radialPush + ty * tangentialPush; + } else { + yPx += rowIndex * gap; // columns: separate along the vertical path + } + } + return { x: xPx, y: yPx }; } // === Spiral coloring helpers (shared by 4- and 5-balloon clusters) === @@ -339,17 +488,25 @@ function distinctPaletteSlots(palette) { function newGrid(pattern, cells, container, model){ - const kids = [], layers = [], bbox = new BBox(); + const kids = [], layers = [], bbox = new BBox(), focusBox = new BBox(), resetDots = new Map(); + let floatingAnchor = null; + let overrideCount = manualOverrideCount(model.patternName, model.rowCount); const balloonsPerCluster = pattern.balloonsPerCluster || 4; const reversed = !!(pattern._reverse || (pattern.parent && pattern.parent._reverse)); const rowColorPatterns = {}; + const wireframeMode = false; // per-balloon wireframe handled in cellView for unpainted balloons const stackedSlots = (() => { const slots = distinctPaletteSlots(model.palette); const limit = Math.max(1, Math.min(slots.length, balloonsPerCluster)); return slots.slice(0, limit); })(); - const colorBlock4 = [[1, 2, 3, 4], [3, 1, 4, 2], [4, 3, 2, 1], [2, 4, 1, 3]]; + const colorBlock4 = [ + [1, 2, 4, 3], + [4, 1, 3, 2], + [3, 4, 2, 1], + [2, 3, 1, 4], + ]; const colorBlock5 = [ [5, 2, 3, 4, 1], @@ -359,6 +516,18 @@ function distinctPaletteSlots(palette) { [4, 1, 2, 3, 5], ]; + const isManual = !!model.manualMode; + const manualImagePatterns = new Map(); + const getManualPatternId = (img) => { + if (!img) return null; + if (manualImagePatterns.has(img)) return manualImagePatterns.get(img); + const id = `classic-manual-${manualImagePatterns.size + 1}`; + manualImagePatterns.set(img, id); + return id; + }; + + const expandedOn = model.manualMode && (model.explodedGapPx || 0) > 0; + for (let cell of cells) { let c, fill, colorInfo; if (cell.isTopper) { @@ -372,7 +541,12 @@ function distinctPaletteSlots(palette) { colorInfo = model.topperColor; } else { c = gridPos(cell.x, cell.y, cell.shape.zIndex, cell.inflate, pattern, model); - + if (expandedOn) { + const lift = (cell.shape.zIndex || 0) * 6.2; + c.y -= lift; + } + const manualOverride = isManual ? getManualOverride(model.patternName, model.rowCount, cell.x, cell.y) : undefined; + const manualSlot = (typeof manualOverride === 'number') ? manualOverride : undefined; const rowIndex = cell.y; if (!rowColorPatterns[rowIndex]) { const totalRows = model.rowCount * (pattern.cellsPerRow || 1); @@ -413,28 +587,180 @@ function distinctPaletteSlots(palette) { rowColorPatterns[rowIndex] = pat; } - const colorCode = rowColorPatterns[rowIndex][cell.balloonIndexInCluster]; + const patternSlot = rowColorPatterns[rowIndex][cell.balloonIndexInCluster]; + const manualColorInfo = (manualOverride && typeof manualOverride === 'object') ? manualOverride : null; + const colorCode = (manualSlot !== undefined) ? manualSlot : patternSlot; cell.colorCode = colorCode; - colorInfo = model.palette[colorCode]; - fill = colorInfo ? (colorInfo.image ? `url(#classic-pattern-slot-${colorCode})` : colorInfo.colour) : 'transparent'; + colorInfo = manualColorInfo || model.palette[colorCode]; + if (manualColorInfo) { + const pid = manualColorInfo.image ? getManualPatternId(manualColorInfo.image) : null; + fill = pid ? `url(#${pid})` : (manualColorInfo.hex || manualColorInfo.colour || 'transparent'); + if (manualColorInfo.hex && !manualColorInfo.colour) { + colorInfo = { ...manualColorInfo, colour: manualColorInfo.hex }; + } + } else if (isManual) { + // In manual mode, leave unpainted balloons transparent with an outline only. + fill = 'none'; + colorInfo = null; + } else { + fill = colorInfo ? (colorInfo.image ? `url(#classic-pattern-slot-${colorCode})` : colorInfo.colour) : 'transparent'; + } + } + if (wireframeMode) { + fill = 'none'; + colorInfo = colorInfo || {}; } 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, colorInfo); - v.attrs.transform = `translate(${c.x},${c.y}) ${v.attrs.transform || ''}`; - const zi = cell.isTopper ? 100 + 2 : (100 + (cell.shape.zIndex || 0)); + const inFocus = model.manualFocusSize && model.manualFocusStart >= 0 + ? (cell.y >= model.manualFocusStart && cell.y < model.manualFocusStart + model.manualFocusSize) + : false; + const v = cellView(cell, `balloon_${cell.x}_${cell.y}`, fill, model, colorInfo, { wireframe: wireframeMode }); + if (!cell.isTopper) { + v.attrs = v.attrs || {}; + v.attrs['data-quad-number'] = cell.y + 1; + } + const depthLift = expandedOn ? ((cell.shape.zIndex || 0) * 1.8) : 0; + const floatingOut = model.manualMode && model.manualFloatingQuad === cell.y; + if (floatingOut) { + if (!resetDots.has(cell.y)) resetDots.set(cell.y, { x: c.x, y: c.y }); + const isArch = (model.patternName || '').toLowerCase().includes('arch'); + let slideX = 80; + let slideY = 0; + const idx = typeof cell.balloonIndexInCluster === 'number' ? cell.balloonIndexInCluster : 0; + const spread = idx - 1.5; + if (isArch) { + // Radial slide outward; preserve layout. + const dist = Math.hypot(c.x, c.y) || 1; + const offset = (model.manualMode && (model.explodedGapPx || 0) > 0) ? 120 : 80; + const nx = c.x / dist, ny = c.y / dist; + slideX = nx * offset; + slideY = ny * offset; + // Slight tangent spread (~5px) to separate balloons without reshaping the quad. + const txDirX = -ny; + const txDirY = nx; + const fan = spread * ((model.manualMode && (model.explodedGapPx || 0) > 0) ? 16 : 10); + slideX += txDirX * fan; + slideY += txDirY * fan; + } + let tx = c.x + slideX; + let ty = c.y + slideY; + // Keep shape intact; only fan columns slightly. + if (isArch) { + // no fan/scale for arches; preserve layout + } else { + tx += spread * 4; + ty += spread * 4; + } + const fanScale = 1; + // Nudge the top pair down slightly in columns so they remain easily clickable. + if (!isArch && typeof cell.balloonIndexInCluster === 'number' && cell.balloonIndexInCluster <= 1) { + ty += 6; + } + + // CRITICAL: Re-add NaN checks to prevent coordinates from breaking SVG + if (!Number.isFinite(tx) || !Number.isFinite(ty)) { + console.error('[Classic] Floating Quad: Non-finite coordinates detected. Falling back to original position.', {row: cell.y, cx: c.x, cy: c.y, slideX, slideY, tx, ty}); + tx = c.x; + ty = c.y; + } + + // Correctly construct transform: Translate first, then Base Transform, then Scale to pixel size. + // We use cellScale(cell) directly (1.0x) to match the true wireframe size perfectly, + // removing any "pop" or expansion that caused the size mismatch overlay effect. + // Reuse the original balloon transform so the floated cluster matches the stacked layout exactly. + // Only prepend the slide offset (plus any depth lift) to move it aside. + const baseTransform = v.attrs.transform || ''; + const yPos = ty + depthLift; + const scaleStr = fanScale !== 1 ? ` scale(${fanScale})` : ''; + const tiltDeg = isArch ? 0 : 0; // remove tilt for columns + const rotationStr = tiltDeg ? ` rotate(${tiltDeg})` : ''; + v.attrs.transform = `translate(${tx},${yPos})${rotationStr} ${baseTransform}${scaleStr}`; + + // Standard z-index for top visibility, removed drop-shadow; no animation to avoid layout flicker. + v.attrs.style = `${v.attrs.style || ''}; z-index: 1000;`; + + // Boost shine visibility on floating balloons to compensate for smaller scale + if (Array.isArray(v.children)) { + const shineNode = v.children.find(c => c.attrs && c.attrs.class === 'shine'); + if (shineNode && shineNode.attrs) { + shineNode.attrs.opacity = 0.65; + } + } + // Suppress outlines on the floated preview to avoid the dark halo. + v.attrs.stroke = 'none'; + v.attrs['stroke-width'] = 0; + + bbox.add(tx - size, yPos - size); + bbox.add(tx + size, yPos + size); + if (inFocus) { + focusBox.add(tx - size, yPos - size); + focusBox.add(tx + size, yPos + size); + } + } else { + const yPos = c.y + depthLift; + v.attrs.transform = `translate(${c.x},${yPos}) ${v.attrs.transform || ''}`; + v.attrs.style = `${v.attrs.style || ''};`; + bbox.add(c.x - size, yPos - size); + bbox.add(c.x + size, yPos + size); + if (inFocus) { + focusBox.add(c.x - size, yPos - size); + focusBox.add(c.x + size, yPos + size); + } + } + // Keep stacking order stable even when the quad is floated. + const baseZi = cell.isTopper ? 102 : (100 + (cell.shape.zIndex || 0)); + const zi = floatingOut ? (1000 + baseZi) : baseZi; (layers[zi] ||= []).push(v); }; layers.forEach(layer => layer && layer.forEach(v => kids.push(v))); - const margin = 20; - const vb = [ bbox.min.x - margin, bbox.min.y - margin, Math.max(1,bbox.w()) + margin*2, Math.max(1,bbox.h()) + margin*2 ].join(' '); + // Add reset dots for floated quads (one per floating row) at their original position. + if (resetDots.size) { + resetDots.forEach(({ x, y }) => { + kids.push(svg('g', { transform: `translate(${x},${y})`, style: 'cursor:pointer' , onclick: 'window.ClassicDesigner?.resetFloatingQuad?.()' }, [ + svg('circle', { cx: 0, cy: 0, r: 10, fill: 'rgba(37,99,235,0.12)', stroke: '#2563eb', 'stroke-width': 2 }), + svg('circle', { cx: 0, cy: 0, r: 3.5, fill: '#2563eb' }) + ])); + }); + } + // Keep a modest margin when a quad is floated so the design doesn’t shrink too much. + const margin = (model.manualMode && model.manualFloatingQuad !== null) ? 40 : 20; + const focusValid = isFinite(focusBox.min.x) && isFinite(focusBox.min.y) && focusBox.w() > 0 && focusBox.h() > 0; + const focusOn = model.manualMode && model.manualFocusEnabled && model.manualFocusSize && focusValid; + // Keep full arch/column in view while still tracking focus extents for highlighting + const box = bbox; + const baseW = Math.max(1, box.w()) + margin * 2; + const baseH = Math.max(1, box.h()) + margin * 2; + let minX = box.min.x - margin; + let minY = box.min.y - margin; + let vbW = baseW; + let vbH = baseH; + const isColumnPattern = (model.patternName || '').toLowerCase().includes('column'); + const targetClusters = 14; // ≈7ft at 2 clusters/ft + // When not in manual, pad short columns to a consistent scale; in manual keep true size so expanded spacing stays in view. + if (!model.manualMode && isColumnPattern && model.rowCount < targetClusters) { + const scaleFactor = targetClusters / Math.max(1, model.rowCount); + vbW = baseW * scaleFactor; + vbH = baseH * scaleFactor; + const cx = (box.min.x + box.max.x) / 2; + const cy = (box.min.y + box.max.y) / 2; + minX = cx - vbW / 2; + minY = cy - vbH / 2; + } + if (model.manualMode) { + // Keep the full column centered in manual mode; avoid upward bias that was hiding the top. + const lift = 0; + minY -= lift; + } + const vb = [ minX, minY, vbW, vbH ].join(' '); const patternsDefs = []; + const maskDefs = []; const SVG_PATTERN_ZOOM = 2.5; const offset = (1 - SVG_PATTERN_ZOOM) / 2; + const OUTLINE_DARK = '#0f172a'; // matches chip text for light colors + const OUTLINE_LIGHT = '#f8fafc'; // matches chip text for dark colors Object.entries(model.palette).forEach(([slot, colorInfo]) => { if (colorInfo.image) { @@ -443,15 +769,85 @@ function distinctPaletteSlots(palette) { )); } }); + manualImagePatterns.forEach((id, href) => { + patternsDefs.push(svg('pattern', {id, patternContentUnits: 'objectBoundingBox', width: 1, height: 1}, + [ svg('image', { href, x: offset, y: offset, width: SVG_PATTERN_ZOOM, height: SVG_PATTERN_ZOOM, preserveAspectRatio: 'xMidYMid slice' }) ] + )); + }); if (model.topperColor.image) { patternsDefs.push(svg('pattern', {id: 'classic-pattern-topper', patternContentUnits: 'objectBoundingBox', width: 1, height: 1}, [ svg('image', { href: model.topperColor.image, x: offset, y: offset, width: SVG_PATTERN_ZOOM, height: SVG_PATTERN_ZOOM, preserveAspectRatio: 'xMidYMid slice' }) ] )); } - const svgDefs = svg('defs', {}, patternsDefs); + Object.entries(numberTopperShapes).forEach(([key, shape]) => { + const base = shape.base || {}; + if (!base.image) return; + const w = base.width || 1, h = base.height || 1; + const maskId = base.maskId || `classic-num-mask-${key.replace('topper-num-', '')}`; + maskDefs.push(svg('mask', { id: maskId, maskUnits: 'userSpaceOnUse', x: -w/2, y: -h/2, width: w, height: h }, [ + svg('image', { href: base.image, x: -w/2, y: -h/2, width: w, height: h, preserveAspectRatio: base.preserveAspectRatio || 'xMidYMid meet' }) + ])); + }); + const svgDefs = svg('defs', {}, [ + // Tint: use source alpha to clip topper color, then reapply ink. + svg('filter', { id: 'classic-num-tint', 'color-interpolation-filters': 'sRGB', x: '-10%', y: '-10%', width: '120%', height: '120%' }, [ + svg('feColorMatrix', { in: 'SourceGraphic', type: 'matrix', values: '0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0', result: 'alpha' }), + svg('feFlood', { 'flood-color': model.topperColor?.hex || '#ffffff', result: 'tint' }), + svg('feComposite', { in: 'tint', in2: 'alpha', operator: 'in', result: 'fill' }), + svg('feComposite', { in: 'SourceGraphic', in2: 'alpha', operator: 'in', result: 'ink' }), + svg('feMerge', {}, [ + svg('feMergeNode', { in: 'fill' }), + svg('feMergeNode', { in: 'ink' }) + ]) + ]), + svg('filter', { id: 'classic-num-outline-dark', 'color-interpolation-filters': 'sRGB', x: '-30%', y: '-30%', width: '160%', height: '160%' }, [ + // Heavy outer stroke + svg('feMorphology', { in: 'SourceAlpha', operator: 'dilate', radius: 9, result: 'spreadOuter' }), + svg('feComposite', { in: 'spreadOuter', in2: 'SourceAlpha', operator: 'out', result: 'strokeOuter' }), + svg('feFlood', { 'flood-color': OUTLINE_DARK, 'flood-opacity': 1, result: 'strokeOuterColor' }), + svg('feComposite', { in: 'strokeOuterColor', in2: 'strokeOuter', operator: 'in', result: 'coloredStrokeOuter' }), + // Inner reinforcement to avoid gaps + svg('feMorphology', { in: 'SourceAlpha', operator: 'dilate', radius: 4.8, result: 'spreadInner' }), + svg('feComposite', { in: 'spreadInner', in2: 'SourceAlpha', operator: 'out', result: 'strokeInner' }), + svg('feFlood', { 'flood-color': OUTLINE_DARK, 'flood-opacity': 1, result: 'strokeInnerColor' }), + svg('feComposite', { in: 'strokeInnerColor', in2: 'strokeInner', operator: 'in', result: 'coloredStrokeInner' }), + svg('feMerge', {}, [ + svg('feMergeNode', { in: 'coloredStrokeOuter' }), + svg('feMergeNode', { in: 'coloredStrokeInner' }), + svg('feMergeNode', { in: 'SourceGraphic' }) + ]) + ]), + svg('filter', { id: 'classic-num-outline-light', 'color-interpolation-filters': 'sRGB', x: '-30%', y: '-30%', width: '160%', height: '160%' }, [ + // Heavy outer stroke + svg('feMorphology', { in: 'SourceAlpha', operator: 'dilate', radius: 9, result: 'spreadOuter' }), + svg('feComposite', { in: 'spreadOuter', in2: 'SourceAlpha', operator: 'out', result: 'strokeOuter' }), + svg('feFlood', { 'flood-color': OUTLINE_LIGHT, 'flood-opacity': 1, result: 'strokeOuterColor' }), + svg('feComposite', { in: 'strokeOuterColor', in2: 'strokeOuter', operator: 'in', result: 'coloredStrokeOuter' }), + // Inner reinforcement to avoid gaps + svg('feMorphology', { in: 'SourceAlpha', operator: 'dilate', radius: 4.8, result: 'spreadInner' }), + svg('feComposite', { in: 'spreadInner', in2: 'SourceAlpha', operator: 'out', result: 'strokeInner' }), + svg('feFlood', { 'flood-color': OUTLINE_LIGHT, 'flood-opacity': 1, result: 'strokeInnerColor' }), + svg('feComposite', { in: 'strokeInnerColor', in2: 'strokeInner', operator: 'in', result: 'coloredStrokeInner' }), + svg('feMerge', {}, [ + svg('feMergeNode', { in: 'coloredStrokeOuter' }), + svg('feMergeNode', { in: 'coloredStrokeInner' }), + svg('feMergeNode', { in: 'SourceGraphic' }) + ]) + ]), + ...maskDefs, + ...patternsDefs + ]); const mainGroup = svg('g', null, kids); - m.render(container, svg('svg', { xmlns: 'http://www.w3.org/2000/svg', width:'100%', height:'100%', viewBox: vb, preserveAspectRatio:'xMidYMid meet', style: 'isolation:isolate' }, [svgDefs, mainGroup])); + const zoomPercent = classicZoom * 100; + m.render(container, svg('svg', { + xmlns: 'http://www.w3.org/2000/svg', + width:'100%', + height:'100%', + viewBox: vb, + preserveAspectRatio:'xMidYMid meet', + style: `isolation:isolate; width:${zoomPercent}%; height:${zoomPercent}%; min-width:${zoomPercent}%; min-height:${zoomPercent}%; transform-origin:center center;` + }, [svgDefs, mainGroup])); } function makeController(displayEl){ @@ -464,9 +860,22 @@ function distinctPaletteSlots(palette) { if (patterns['Arch 5']) patterns['Arch 5']._reverse = reverse; const model = { - patternName: name, pattern, cells: [], rowCount: clusters, palette: buildClassicPalette(), - topperColor: getTopperColor(), topperType, shineEnabled, - numberTintHex, numberTintOpacity + patternName: name, + pattern, + cells: [], + rowCount: clusters, + palette: buildClassicPalette(), + topperColor: getTopperColor(), + topperType, + shineEnabled, + manualMode, + manualFocusEnabled, + manualFloatingQuad, + explodedScale, + explodedGapPx, + explodedStaggerPx, + manualFocusStart, + manualFocusSize }; const rows = pattern.cellsPerRow * model.rowCount, cols = pattern.cellsPerColumn; for (let y=0; y { - const img = NUMBER_IMAGE_MAP[num]; - const hasImage = !!img; + const topperSize = 8.2; // closer to round topper scale + const baseHeight = 1.05; + + Object.entries(NUMBER_IMAGE_MAP).forEach(([num, href]) => { + const vb = viewBoxes[num] || { w: 40, h: 40 }; + const aspect = vb.w / Math.max(1, vb.h); + const height = baseHeight; + const width = height * aspect; + const radius = Math.max(width, height) / 2; + const maskId = `classic-num-mask-${num}`; + shapes[`topper-num-${num}`] = { - base: hasImage - ? { type: 'image', image: img, width: 1, height: 1, radius: 0.9, allowShine: false, transform: 'scale(0.9)' } - : { type: 'path', paths: [{ d: fallbackPaths[num].d, fillRule: fallbackPaths[num].fillRule || 'nonzero' }], radius: r, allowShine: true, transform: baseTransform }, + base: { + image: href, + width, + height, + preserveAspectRatio: 'xMidYMid meet', + maskId, + radius, + allowShine: false // keep number toppers matte; shine causes halo + }, size: topperSize }; }); + return shapes; } @@ -665,12 +1088,48 @@ 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 numberTintSlider = document.getElementById('classic-number-tint'); + 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'), activeChip = document.getElementById('classic-active-chip'), floatingChip = document.getElementById('classic-active-chip-floating'), activeDot = document.getElementById('classic-active-dot'), floatingDot = document.getElementById('classic-active-dot-floating'); + const projectBlock = document.getElementById('classic-project-block'); + const replaceBlock = document.getElementById('classic-replace-block'); + const replaceFromSel = document.getElementById('classic-replace-from'); + const replaceToSel = document.getElementById('classic-replace-to'); + const replaceBtn = document.getElementById('classic-replace-btn'); + const replaceMsg = document.getElementById('classic-replace-msg'); + const replaceFromChip = document.getElementById('classic-replace-from-chip'); + const replaceToChip = document.getElementById('classic-replace-to-chip'); + const replaceCountLabel = document.getElementById('classic-replace-count'); const topperBlock = document.getElementById('classic-topper-color-block'); - if (!slotsContainer || !topperSwatch || !swatchGrid || !activeLabel) return; + if (!slotsContainer || !topperSwatch || !swatchGrid) return; topperSwatch.classList.add('tab-btn'); let classicColors = getClassicColors(), activeTarget = '1', slotCount = getStoredSlotCount(); + const publishActiveTarget = () => { + window.ClassicDesigner = window.ClassicDesigner || {}; + window.ClassicDesigner.activeSlot = activeTarget; + }; + const isManual = () => !!(window.ClassicDesigner?.manualMode); + const manualColor = () => manualActiveColorGlobal || (manualActiveColorGlobal = (window.shared?.getActiveColor?.() || { hex: '#ffffff', image: null })); + const openManualPicker = () => { + if (!isManual()) return false; + if (typeof window.openColorPicker !== 'function') return false; + const all = flattenPalette().map(c => ({ + label: c.name || c.hex, + hex: c.hex, + meta: c, + metaText: c.family || '' + })); + window.openColorPicker({ + title: 'Pick a color', + subtitle: 'Apply to active paint', + items: all, + onSelect: (item) => { + const meta = item.meta || {}; + manualActiveColorGlobal = window.shared?.setActiveColor?.({ hex: meta.hex || item.hex, image: meta.image || null }); + updateUI(); + onColorChange(); + } + }); + return true; + }; function visibleSlotCount() { const patSelect = document.getElementById('classic-pattern'); @@ -693,7 +1152,7 @@ function distinctPaletteSlots(palette) { btn.className = 'slot-btn tab-btn'; btn.dataset.slot = String(i); btn.textContent = `#${i}`; - btn.addEventListener('click', () => { activeTarget = String(i); updateUI(); }); + btn.addEventListener('click', () => { activeTarget = String(i); updateUI(); openPalettePicker(); }); slotsContainer.appendChild(btn); } } @@ -703,12 +1162,201 @@ function distinctPaletteSlots(palette) { if (parseInt(activeTarget, 10) > count) activeTarget = '1'; renderSlots(); } + const allPaletteColors = flattenPalette(); + const labelForColor = (color) => { + const hex = normHex(color?.hex || color?.colour || ''); + const image = color?.image || ''; + const byImage = image ? allPaletteColors.find(c => c.image === image) : null; + const byHex = hex ? allPaletteColors.find(c => c.hex === hex) : null; + return color?.name || byImage?.name || byHex?.name || hex || (image ? 'Texture' : 'Current'); + }; + + const colorKeyFromVal = (val) => { + const palette = buildClassicPalette(); + let hex = null, image = null; + if (val && typeof val === 'object') { + hex = normHex(val.hex || val.colour || ''); + image = val.image || null; + } else if (typeof val === 'number') { + const info = palette[val] || null; + hex = normHex(info?.colour || info?.hex || ''); + image = info?.image || null; + } + const cleanedHex = (hex === 'transparent' || hex === 'none') ? '' : (hex || ''); + const key = (image || cleanedHex) ? `${image || ''}|${cleanedHex}` : ''; + return { hex: cleanedHex, image: image || null, key }; + }; + + const manualUsage = () => { + if (!manualModeState) return []; + const palette = buildClassicPalette(); + const map = new Map(); + const cells = Array.from(document.querySelectorAll('#classic-display g[id^="balloon_"]')); + cells.forEach(g => { + const match = g.id.match(/balloon_(\d+)_(\d+)/); + if (!match) return; + const x = parseInt(match[1], 10); + const y = parseInt(match[2], 10); + const override = getManualOverride(currentPatternName, currentRowCount, x, y); + const code = parseInt(g.getAttribute('data-color-code') || '0', 10); + const base = palette[code] || { hex: '#ffffff', image: null }; + const fill = override || base; + const { hex, image, key: k } = colorKeyFromVal(fill); + if (!k) return; + const existing = map.get(k) || { hex, image, count: 0 }; + existing.count += 1; + map.set(k, existing); + }); + return Array.from(map.values()); + }; + + const setReplaceChip = (chip, color) => { + if (!chip) return; + if (color?.image) { + chip.style.backgroundImage = `url("${color.image}")`; + chip.style.backgroundSize = 'cover'; + chip.style.backgroundColor = color.hex || '#fff'; + } else { + chip.style.backgroundImage = 'none'; + chip.style.backgroundColor = color?.hex || '#f1f5f9'; + } + }; + + const populateReplaceTo = () => { + if (!replaceToSel) return; + replaceToSel.innerHTML = ''; + allPaletteColors.forEach((c, idx) => { + const opt = document.createElement('option'); + opt.value = String(idx); + opt.textContent = c.name || c.hex || (c.image ? 'Texture' : 'Color'); + replaceToSel.appendChild(opt); + }); + }; + + const updateReplaceChips = () => { + if (!replaceFromSel || !replaceToSel) return 0; + if (!manualModeState) { + replaceFromSel.innerHTML = ''; + setReplaceChip(replaceFromChip, { hex: '#f8fafc' }); + setReplaceChip(replaceToChip, { hex: '#f8fafc' }); + if (replaceCountLabel) replaceCountLabel.textContent = ''; + if (replaceMsg) replaceMsg.textContent = 'Manual paint only.'; + return 0; + } + const usage = manualUsage(); + replaceFromSel.innerHTML = ''; + usage.forEach(u => { + const opt = document.createElement('option'); + opt.value = `${u.image || ''}|${u.hex || ''}`; + const labelHex = u.hex || (u.image ? 'Texture' : 'Color'); + opt.textContent = `${labelHex} (${u.count})`; + replaceFromSel.appendChild(opt); + }); + if (!replaceFromSel.value && usage.length) replaceFromSel.value = `${usage[0].image || ''}|${usage[0].hex || ''}`; + if (!replaceToSel.value && replaceToSel.options.length) replaceToSel.value = replaceToSel.options[0].value; + + const toIdx = parseInt(replaceToSel.value || '-1', 10); + const toMeta = Number.isInteger(toIdx) && toIdx >= 0 ? allPaletteColors[toIdx] : null; + const fromVal = replaceFromSel.value || ''; + const fromParts = fromVal.split('|'); + const fromColor = { image: fromParts[0] || null, hex: fromParts[1] || '' }; + setReplaceChip(replaceFromChip, fromColor); + setReplaceChip(replaceToChip, toMeta ? { hex: toMeta.hex || '#f1f5f9', image: toMeta.image || null } : { hex: '#f1f5f9' }); + + // count matches + let count = 0; + if (fromVal) { + const usage = manualUsage(); + usage.forEach(u => { if (`${u.image || ''}|${u.hex || ''}` === fromVal) count += u.count; }); + } + if (replaceCountLabel) replaceCountLabel.textContent = count ? `${count} match${count === 1 ? '' : 'es'}` : '0 matches'; + if (replaceMsg) replaceMsg.textContent = usage.length ? '' : 'Paint something first to replace.'; + return count; + }; + + const openReplacePicker = (mode = 'from') => { + if (!window.openColorPicker) return; + if (mode === 'from') { + const usage = manualUsage(); + const items = usage.map(u => ({ + label: u.hex || (u.image ? 'Texture' : 'Color'), + metaText: `${u.count} in design`, + value: `${u.image || ''}|${u.hex || ''}`, + hex: u.hex || '#ffffff', + meta: { image: u.image, hex: u.hex || '#ffffff' }, + image: u.image + })); + window.openColorPicker({ + title: 'Replace: From color', + subtitle: 'Pick a color already on canvas', + items, + onSelect: (item) => { + if (!replaceFromSel) return; + replaceFromSel.value = item.value; + updateReplaceChips(); + } + }); + } else { + const items = allPaletteColors.map((c, idx) => ({ + label: c.name || c.hex || (c.image ? 'Texture' : 'Color'), + metaText: c.family || '', + idx + })); + window.openColorPicker({ + title: 'Replace: To color', + subtitle: 'Choose a library color', + items, + onSelect: (item) => { + if (!replaceToSel) return; + replaceToSel.value = String(item.idx); + updateReplaceChips(); + } + }); + } + }; + + renderProjectPalette = function renderProjectPaletteFn() { + if (!projectPaletteBox) return; + projectPaletteBox.innerHTML = ''; + if (!manualModeState) { + projectPaletteBox.innerHTML = '
Enter Manual paint to see colors used.
'; + return; + } + const used = manualUsedColorsFor(currentPatternName, currentRowCount); + if (!used.length) { + projectPaletteBox.innerHTML = '
Paint to build a project palette.
'; + return; + } + const row = document.createElement('div'); + row.className = 'swatch-row'; + used.forEach(item => { + const sw = document.createElement('button'); + sw.type = 'button'; + sw.className = 'swatch'; + if (item.image) { + sw.style.backgroundImage = `url("${item.image}")`; + sw.style.backgroundSize = '500%'; + sw.style.backgroundPosition = 'center'; + sw.style.backgroundColor = item.hex || '#fff'; + } else { + sw.style.backgroundColor = item.hex || '#fff'; + } + sw.title = item.label || item.hex || 'Color'; + sw.addEventListener('click', () => { + manualActiveColorGlobal = window.shared?.setActiveColor?.({ hex: item.hex || '#ffffff', image: item.image || null }) || { hex: item.hex || '#ffffff', image: item.image || null }; + updateClassicDesign(); + }); + row.appendChild(sw); + }); + projectPaletteBox.appendChild(row); + } function updateUI() { 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)); + publishActiveTarget(); buttons.forEach((slot, i) => { const color = classicColors[i]; @@ -722,23 +1370,14 @@ function distinctPaletteSlots(palette) { slot.style.textShadow = txt.shadow; }); - const topperColor = getTopperColor(); - const currentType = document.querySelector('.topper-type-btn[aria-pressed="true"]')?.dataset.type || 'round'; - const tintColor = getNumberTintColor(); - if (currentType.startsWith('num-') && topperColor.image) { - topperSwatch.style.backgroundImage = `linear-gradient(${tintColor}99, ${tintColor}99), url("${topperColor.image}")`; - topperSwatch.style.backgroundBlendMode = 'multiply, normal'; - topperSwatch.style.backgroundSize = '220%'; - topperSwatch.style.backgroundPosition = 'center'; - topperSwatch.style.backgroundColor = tintColor; - } else { - topperSwatch.style.backgroundImage = topperColor.image ? `url("${topperColor.image}")` : 'none'; - topperSwatch.style.backgroundBlendMode = 'normal'; - topperSwatch.style.backgroundColor = topperColor.hex; - topperSwatch.style.backgroundSize = '200%'; - topperSwatch.style.backgroundPosition = 'center'; - } - const topperTxt = textStyleForColor({ hex: tintColor || topperColor.hex, image: topperColor.image }); + const topperColor = getTopperColor(); + const currentType = document.querySelector('.topper-type-btn[aria-pressed="true"]')?.dataset.type || 'round'; + topperSwatch.style.backgroundImage = topperColor.image ? `url("${topperColor.image}")` : 'none'; + topperSwatch.style.backgroundBlendMode = 'normal'; + topperSwatch.style.backgroundColor = topperColor.hex; + topperSwatch.style.backgroundSize = '200%'; + topperSwatch.style.backgroundPosition = 'center'; + const topperTxt = textStyleForColor({ hex: topperColor.hex, image: topperColor.image }); topperSwatch.style.color = topperTxt.color; topperSwatch.style.textShadow = topperTxt.shadow; const patName = (document.getElementById('classic-pattern')?.value || '').toLowerCase(); @@ -756,47 +1395,100 @@ function distinctPaletteSlots(palette) { addSlotBtn.disabled = !isStacked || slotCount >= maxSlots; } - activeLabel.textContent = activeTarget === 'T' ? 'Topper' : `Slot #${activeTarget}`; + const manualModeOn = isManual(); + const sharedActive = window.shared?.getActiveColor?.() || { hex: '#ffffff', image: null }; + if (activeChip) { + const idx = parseInt(activeTarget, 10) - 1; + const color = manualModeOn ? sharedActive : (classicColors[idx] || { hex: '#ffffff', image: null }); + activeChip.setAttribute('title', manualModeOn ? 'Active color' : `Slot #${activeTarget}`); + const bgImg = color.image ? `url(\"${color.image}\")` : 'none'; + const bgCol = color.hex || '#ffffff'; + activeChip.style.backgroundImage = bgImg; + activeChip.style.backgroundColor = bgCol; + activeChip.style.backgroundSize = color.image ? '200%' : 'cover'; + activeChip.style.backgroundPosition = 'center'; + const txt = textStyleForColor({ hex: color.hex || '#ffffff', image: color.image }); + activeChip.style.color = txt.color; + activeChip.style.textShadow = txt.shadow; + if (activeLabel) { + activeLabel.textContent = labelForColor(color); + activeLabel.style.color = txt.color; + activeLabel.style.textShadow = txt.shadow; + } + if (activeDot) { + activeDot.style.backgroundImage = bgImg; + activeDot.style.backgroundColor = bgCol; + } + if (floatingChip) { + floatingChip.style.backgroundImage = bgImg; + floatingChip.style.backgroundColor = bgCol; + if (floatingDot) { + floatingDot.style.backgroundImage = bgImg; + floatingDot.style.backgroundColor = bgCol; + } + floatingChip.style.display = manualModeOn ? '' : 'none'; + } + } + if (slotsContainer) { + const row = slotsContainer.parentElement; + if (row) row.style.display = manualModeOn ? 'none' : ''; + if (addSlotBtn) addSlotBtn.style.display = manualModeOn ? 'none' : ''; + } + if (activeChip) { + activeChip.style.display = manualModeOn ? '' : 'none'; + } + if (projectBlock) projectBlock.classList.toggle('hidden', !manualModeOn); + if (replaceBlock) replaceBlock.classList.toggle('hidden', !manualModeOn); } - const allPaletteColors = flattenPalette(); swatchGrid.innerHTML = ''; - (window.PALETTE || []).forEach(group => { - const title = document.createElement('div'); title.className = 'family-title'; title.textContent = group.family; swatchGrid.appendChild(title); - const row = document.createElement('div'); row.className = 'swatch-row'; - (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; - sw.style.backgroundSize = '500%'; - sw.style.backgroundPosition = 'center'; + swatchGrid.innerHTML = ''; + swatchGrid.style.display = 'none'; // hide inline list; use modal picker instead - sw.addEventListener('click', () => { - const selectedColor = { hex: colorItem.hex, image: colorItem.image }; - if (activeTarget === 'T') { - const currentType = document.querySelector('.topper-type-btn[aria-pressed="true"]')?.dataset.type || 'round'; - if (currentType.startsWith('num-')) { - setNumberTintColor(selectedColor.hex); - if (numberTintSlider) numberTintSlider.value = getNumberTintOpacity(); - } else { - setTopperColor(selectedColor); - } - } else { - const index = parseInt(activeTarget, 10) - 1; - if (index >= 0 && index < MAX_SLOTS) { classicColors[index] = selectedColor; setClassicColors(classicColors); } - } - updateUI(); onColorChange(); - if (window.updateExportButtonVisibility) window.updateExportButtonVisibility(); - }); - row.appendChild(sw); - }); - swatchGrid.appendChild(row); + const openPalettePicker = () => { + if (typeof window.openColorPicker !== 'function') return; + const items = allPaletteColors.map(c => ({ + label: c.name || c.hex, + hex: c.hex, + meta: c, + metaText: c.family || '' + })); + const currentType = document.querySelector('.topper-type-btn[aria-pressed="true"]')?.dataset.type || 'round'; + const subtitle = isManual() + ? 'Apply to active paint' + : (activeTarget === 'T' ? 'Set topper color' : `Set slot #${activeTarget}`); + window.openColorPicker({ + title: 'Choose a color', + subtitle, + items, + onSelect: (item) => { + const meta = item.meta || {}; + const selectedColor = { hex: meta.hex || item.hex, image: meta.image || null }; + if (activeTarget === 'T') { + setTopperColor(selectedColor); + } else if (isManual()) { + manualActiveColorGlobal = window.shared?.setActiveColor?.(selectedColor) || selectedColor; + } else { + const index = parseInt(activeTarget, 10) - 1; + if (index >= 0 && index < MAX_SLOTS) { classicColors[index] = selectedColor; setClassicColors(classicColors); } + } + updateUI(); onColorChange(); + if (window.updateExportButtonVisibility) window.updateExportButtonVisibility(); + } + }); + }; + + topperSwatch.addEventListener('click', () => { activeTarget = 'T'; updateUI(); openPalettePicker(); }); + activeChip?.addEventListener('click', () => { + openPalettePicker(); + }); + floatingChip?.addEventListener('click', () => { + openPalettePicker(); }); - topperSwatch.addEventListener('click', () => { activeTarget = 'T'; updateUI(); }); randomizeBtn?.addEventListener('click', () => { + if (isManual() && window.ClassicDesigner?.randomizeManualFromPalette) { + const applied = window.ClassicDesigner.randomizeManualFromPalette(); + if (applied) return; + } const pool = allPaletteColors.slice(); const picks = []; const colorCount = visibleSlotCount(); for (let i = 0; i < colorCount && pool.length; i++) { picks.push(pool.splice(Math.floor(Math.random() * pool.length), 1)[0]); } @@ -822,28 +1514,133 @@ function distinctPaletteSlots(palette) { updateUI(); onColorChange(); if (window.updateExportButtonVisibility) window.updateExportButtonVisibility(); }); + replaceFromChip?.addEventListener('click', () => openReplacePicker('from')); + replaceToChip?.addEventListener('click', () => openReplacePicker('to')); + replaceFromSel?.addEventListener('change', updateReplaceChips); + replaceToSel?.addEventListener('change', updateReplaceChips); + replaceBtn?.addEventListener('click', () => { + if (!manualModeState) { if (replaceMsg) replaceMsg.textContent = 'Manual paint only.'; return; } + const fromKey = replaceFromSel?.value || ''; + const toIdx = parseInt(replaceToSel?.value || '-1', 10); + if (!fromKey || Number.isNaN(toIdx) || toIdx < 0 || toIdx >= allPaletteColors.length) { if (replaceMsg) replaceMsg.textContent = 'Pick both colors.'; return; } + const toMeta = allPaletteColors[toIdx]; + const key = manualKey(currentPatternName, currentRowCount); + const prevSnapshot = manualOverrides[key] ? { ...manualOverrides[key] } : null; + if (!manualOverrides[key]) manualOverrides[key] = {}; + const cells = Array.from(document.querySelectorAll('#classic-display g[id^="balloon_"]')); + const palette = buildClassicPalette(); + let count = 0; + cells.forEach(g => { + const match = g.id.match(/balloon_(\d+)_(\d+)/); + if (!match) return; + const x = parseInt(match[1], 10); + const y = parseInt(match[2], 10); + const override = getManualOverride(currentPatternName, currentRowCount, x, y); + const code = parseInt(g.getAttribute('data-color-code') || '0', 10); + const base = palette[code] || { hex: '#ffffff', image: null }; + const fill = override || base; + if (colorKeyFromVal(fill).key === fromKey) { + manualOverrides[key][`${x},${y}`] = { + hex: normHex(toMeta.hex || toMeta.colour || '#ffffff'), + image: toMeta.image || null + }; + count++; + } + }); + if (!count) { if (replaceMsg) replaceMsg.textContent = 'Nothing to replace.'; return; } + saveManualOverrides(manualOverrides); + manualUndoStack.push({ clear: true, pattern: currentPatternName, rows: currentRowCount, snapshot: prevSnapshot }); + manualRedoStack.length = 0; + if (replaceMsg) replaceMsg.textContent = `Replaced ${count} balloon${count === 1 ? '' : 's'}.`; + onColorChange(); + updateReplaceChips(); + }); + + populateReplaceTo(); updateUI(); - return updateUI; + updateReplaceChips(); + return () => { updateUI(); updateReplaceChips(); }; } - function initClassic() { - try { - if (typeof window.m === 'undefined') return fail('Mithril not loaded'); - const display = document.getElementById('classic-display'), patSel = document.getElementById('classic-pattern'), lengthInp = document.getElementById('classic-length-ft'), clusterHint = document.getElementById('classic-cluster-hint'), reverseCb = document.getElementById('classic-reverse'), topperControls = document.getElementById('topper-controls'), topperToggleRow = document.getElementById('classic-topper-toggle-row'), topperEnabledCb = document.getElementById('classic-topper-enabled'), topperSizeInp = document.getElementById('classic-topper-size'), shineEnabledCb = document.getElementById('classic-shine-enabled'), borderEnabledCb = document.getElementById('classic-border-enabled'); - const numberTintRow = document.getElementById('classic-number-tint-row'), numberTintSlider = document.getElementById('classic-number-tint'); + function initClassic() { + try { + if (typeof window.m === 'undefined') return fail('Mithril not loaded'); + projectPaletteBox = null; + const display = document.getElementById('classic-display'), patSel = document.getElementById('classic-pattern'), lengthInp = document.getElementById('classic-length-ft'), clusterHint = document.getElementById('classic-cluster-hint'), reverseCb = document.getElementById('classic-reverse'), topperControls = document.getElementById('topper-controls'), topperToggleRow = document.getElementById('classic-topper-toggle-row'), topperEnabledCb = document.getElementById('classic-topper-enabled'), topperSizeInp = document.getElementById('classic-topper-size'), shineEnabledCb = document.getElementById('classic-shine-enabled'), borderEnabledCb = document.getElementById('classic-border-enabled'), manualModeBtn = document.getElementById('classic-manual-btn'), expandedToggleRow = document.getElementById('classic-expanded-row'), expandedToggle = document.getElementById('classic-expanded-toggle'), focusRow = document.getElementById('classic-focus-row'), focusPrev = document.getElementById('classic-focus-prev'), focusNext = document.getElementById('classic-focus-next'), focusLabel = document.getElementById('classic-focus-label'), floatingBar = document.getElementById('classic-mobile-bar'), floatingChip = document.getElementById('classic-active-chip-floating'), floatingUndo = document.getElementById('classic-undo-manual'), floatingRedo = document.getElementById('classic-redo-manual'), floatingPick = document.getElementById('classic-pick-manual'), floatingErase = document.getElementById('classic-erase-manual'), floatingClear = document.getElementById('classic-clear-manual'), floatingExport = document.getElementById('classic-export-manual'), quadReset = document.getElementById('classic-quad-reset'), focusZoomOut = document.getElementById('classic-focus-zoomout'), manualHub = document.getElementById('classic-manual-hub'), manualRange = document.getElementById('classic-manual-range'), manualRangeLabel = document.getElementById('classic-manual-range-label'), manualPrevBtn = document.getElementById('classic-manual-prev'), manualNextBtn = document.getElementById('classic-manual-next'), manualFullBtn = document.getElementById('classic-manual-full'), manualFocusBtn = document.getElementById('classic-manual-focus'), manualDetailDisplay = document.getElementById('classic-manual-detail-display'); const nudgeOpenBtn = document.getElementById('classic-nudge-open'); const fullscreenBtn = document.getElementById('app-fullscreen-toggle'); + const toolbar = document.getElementById('classic-canvas-toolbar'); + const toolbarPrev = document.getElementById('classic-toolbar-prev'); + const toolbarNext = document.getElementById('classic-toolbar-next'); + const toolbarZoomOut = document.getElementById('classic-toolbar-zoomout'); + const toolbarReset = document.getElementById('classic-toolbar-reset'); + const focusLabelCanvas = document.getElementById('classic-focus-label-canvas'); + const reverseLabel = reverseCb?.closest('label'); + const reverseHint = reverseLabel?.parentElement?.querySelector('.hint'); + const quadModal = document.getElementById('classic-quad-modal'); + const quadModalClose = document.getElementById('classic-quad-modal-close'); + const quadModalDisplay = document.getElementById('classic-quad-modal-display'); const patternShapeBtns = Array.from(document.querySelectorAll('[data-pattern-shape]')); const patternCountBtns = Array.from(document.querySelectorAll('[data-pattern-count]')); const patternLayoutBtns = Array.from(document.querySelectorAll('[data-pattern-layout]')); const topperNudgeBtns = Array.from(document.querySelectorAll('.nudge-topper')); const topperTypeButtons = Array.from(document.querySelectorAll('.topper-type-btn')); + const topperSizeInput = document.getElementById('classic-topper-size'); const slotsContainer = document.getElementById('classic-slots'); + projectPaletteBox = document.getElementById('classic-project-palette'); + const manualPaletteBtn = document.getElementById('classic-manual-palette'); let topperOffsetX = 0, topperOffsetY = 0; let lastPresetKey = null; // 'custom' means user-tweaked; otherwise `${pattern}:${type}` window.ClassicDesigner = window.ClassicDesigner || {}; window.ClassicDesigner.lastTopperType = window.ClassicDesigner.lastTopperType || 'round'; - if (numberTintSlider) numberTintSlider.value = getNumberTintOpacity(); + window.ClassicDesigner.resetFloatingQuad = () => { manualFloatingQuad = null; updateClassicDesign(); }; + let patternShape = 'arch', patternCount = 4, patternLayout = 'spiral', lastNonManualLayout = 'spiral'; + manualModeState = loadManualMode(); + let manualExpandedState = loadManualExpanded(); + let manualFocusEnabled = false; // start with full design visible; focus toggles when user targets a cluster + manualActiveColorGlobal = window.shared?.getActiveColor?.() || { hex: '#ffffff', image: null }; + currentPatternName = ''; + currentRowCount = Math.max(1, Math.round((parseFloat(lengthInp?.value) || 0) * 2)); + let manualFocusStart = 0; + const manualFocusSize = 8; + manualUndoStack = []; + manualRedoStack = []; + let manualTool = 'paint'; // paint | pick | erase + let manualFloatingQuad = null; + let quadModalRow = null; + let quadModalStartRect = null; + let manualDetailRow = 0; + let manualDetailFrame = null; + classicZoom = 1; + window.ClassicDesigner = window.ClassicDesigner || {}; + window.ClassicDesigner.randomizeManualFromPalette = () => { + if (!manualModeState) return false; + const used = manualUsedColorsFor(currentPatternName, currentRowCount); + const source = (used.length ? used : flattenPalette().map(c => ({ hex: c.hex, image: c.image || null }))).filter(Boolean); + if (!source.length) return false; + const cells = Array.from(document.querySelectorAll('#classic-display g[id^="balloon_"]')); + if (!cells.length) return false; + const key = manualKey(currentPatternName, currentRowCount); + const prevSnapshot = manualOverrides[key] ? { ...manualOverrides[key] } : null; + manualUndoStack.push({ clear: true, pattern: currentPatternName, rows: currentRowCount, snapshot: prevSnapshot }); + manualRedoStack.length = 0; + manualOverrides[key] = {}; + cells.forEach(g => { + const match = g.id.match(/balloon_(\d+)_(\d+)/); + if (!match) return; + const pick = source[Math.floor(Math.random() * source.length)] || { hex: '#ffffff', image: null }; + manualOverrides[key][`${parseInt(match[1], 10)},${parseInt(match[2], 10)}`] = { + hex: normHex(pick.hex || pick.colour || '#ffffff'), + image: pick.image || null + }; + }); + saveManualOverrides(manualOverrides); + updateClassicDesign(); + scheduleManualDetail(); + return true; + }; + // Force UI to reflect initial manual state + if (manualModeState) patternLayout = 'manual'; const topperPresets = { 'Column 4:heart': { enabled: true, offsetX: 3, offsetY: -10.5, size: 1.05 }, 'Column 4:star': { enabled: true, offsetX: 3, offsetY: -7.5, size: 1.65 }, @@ -856,6 +1653,16 @@ function distinctPaletteSlots(palette) { }; if (!display) return fail('#classic-display not found'); const GC = GridCalculator(), ctrl = GC.controller(display); + if (topperSizeInput) { + const val = Math.max(0.5, Math.min(2, parseFloat(topperSizeInput.value) || 1)); + GC.setTopperSize(val); + topperSizeInput.addEventListener('input', () => { + const next = Math.max(0.5, Math.min(2, parseFloat(topperSizeInput.value) || 1)); + GC.setTopperSize(next); + updateClassicDesign(); + if (window.updateExportButtonVisibility) window.updateExportButtonVisibility(); + }); + } let refreshClassicPaletteUi = null; const getTopperType = () => topperTypeButtons.find(btn => btn.getAttribute('aria-pressed') === 'true')?.dataset.type || 'round'; @@ -868,21 +1675,18 @@ function distinctPaletteSlots(palette) { }); window.ClassicDesigner.lastTopperType = type; }; - function applyNumberTopperTexture(type) { + function ensureNumberTopperImage(type) { if (!type || !type.startsWith('num-')) return; - const num = type.split('-')[1]; - if (!num) return; - const imgPath = NUMBER_IMAGE_MAP[num]; - if (imgPath) setTopperColor({ hex: '#ffffff', image: imgPath }); - else setTopperColor({ hex: '#d4d4d8', image: null }); // fallback silver fill if image missing - refreshClassicPaletteUi?.(); + const cur = getTopperColor(); + if (cur?.image && numberSpriteSet.has(cur.image)) { + setTopperColor({ hex: cur?.hex || '#ffffff', image: null }); + refreshClassicPaletteUi?.(); + } } - function resetNonNumberTopperColor(type) { if (type && type.startsWith('num-')) return; const fallback = getTopperColor(); - // If last topper type was a number, strip image to avoid leaking photo texture. - if (fallback?.image && Object.values(NUMBER_IMAGE_MAP).includes(fallback.image)) { + if (fallback?.image && numberSpriteSet.has(fallback.image)) { setTopperColor({ hex: fallback.hex || '#ffffff', image: null }); refreshClassicPaletteUi?.(); } @@ -896,15 +1700,14 @@ function distinctPaletteSlots(palette) { if (lastPresetKey === key || lastPresetKey === 'custom') return; topperOffsetX = preset.offsetX; topperOffsetY = preset.offsetY; - if (topperSizeInp) topperSizeInp.value = preset.size; + if (topperSizeInput) topperSizeInput.value = preset.size; if (topperEnabledCb) topperEnabledCb.checked = preset.enabled; setTopperType(type); - applyNumberTopperTexture(type); + ensureNumberTopperImage(type); resetNonNumberTopperColor(type); lastPresetKey = key; } - let patternShape = 'arch', patternCount = 4, patternLayout = 'spiral'; const computePatternName = () => { const base = patternShape === 'column' ? 'Column' : 'Arch'; const count = patternCount === 5 ? '5' : '4'; @@ -918,6 +1721,7 @@ function distinctPaletteSlots(palette) { patternLayout = val.includes('stacked') ? 'stacked' : 'spiral'; }; const applyPatternButtons = () => { + const displayLayout = manualModeState ? 'manual' : patternLayout; const setActive = (btns, attr, val) => btns.forEach(b => { const active = b.dataset[attr] === val; b.classList.toggle('tab-active', active); @@ -926,7 +1730,32 @@ function distinctPaletteSlots(palette) { }); setActive(patternShapeBtns, 'patternShape', patternShape); setActive(patternCountBtns, 'patternCount', String(patternCount)); - setActive(patternLayoutBtns, 'patternLayout', patternLayout); + setActive(patternLayoutBtns, 'patternLayout', displayLayout); + patternLayoutBtns.forEach(b => b.disabled = false); + if (manualModeBtn) { + const active = manualModeState; + manualModeBtn.disabled = false; + manualModeBtn.classList.toggle('tab-active', active); + manualModeBtn.classList.toggle('tab-idle', !active); + manualModeBtn.setAttribute('aria-pressed', String(active)); + } + if (toolbar) toolbar.classList.toggle('hidden', !manualModeState); + if (quadModal) quadModal.classList.toggle('hidden', !(manualModeState && quadModalRow !== null)); + if (expandedToggleRow) expandedToggleRow.classList.toggle('hidden', !manualModeState); + if (expandedToggle) expandedToggle.checked = manualModeState ? manualExpandedState : false; + if (focusRow) { + const showFocus = manualModeState && (currentPatternName.toLowerCase().includes('arch') || currentPatternName.toLowerCase().includes('column')); + focusRow.classList.toggle('hidden', !showFocus); + } + if (manualHub) manualHub.classList.toggle('hidden', !manualModeState); + if (floatingBar) floatingBar.classList.toggle('hidden', !manualModeState); + // No expanded toggle in bar (handled by main control) + [floatingPick, floatingErase].forEach(btn => { + if (!btn) return; + const active = manualModeState && manualTool === btn.dataset.tool; + btn.setAttribute('aria-pressed', active ? 'true' : 'false'); + btn.classList.toggle('active', active); + }); }; syncPatternStateFromSelect(); @@ -939,8 +1768,7 @@ function distinctPaletteSlots(palette) { topperType: getTopperType(), topperOffsetX, topperOffsetY, - topperSize: topperSizeInp?.value || '', - numberTint: numberTintSlider ? numberTintSlider.value : getNumberTintOpacity() + topperSize: topperSizeInput?.value || '' }; saveClassicState(state); } @@ -954,12 +1782,8 @@ function distinctPaletteSlots(palette) { if (topperEnabledCb) topperEnabledCb.checked = !!saved.topperEnabled; if (typeof saved.topperOffsetX === 'number') topperOffsetX = saved.topperOffsetX; if (typeof saved.topperOffsetY === 'number') topperOffsetY = saved.topperOffsetY; - if (topperSizeInp && saved.topperSize) topperSizeInp.value = saved.topperSize; + if (topperSizeInput && saved.topperSize) topperSizeInput.value = saved.topperSize; if (saved.topperType) setTopperType(saved.topperType); - if (numberTintSlider && typeof saved.numberTint !== 'undefined') { - numberTintSlider.value = saved.numberTint; - setNumberTintOpacity(saved.numberTint); - } syncPatternStateFromSelect(); lastPresetKey = 'custom'; } @@ -967,13 +1791,268 @@ function distinctPaletteSlots(palette) { applySavedState(); applyPatternButtons(); + const clampRow = (row) => { + const maxRow = Math.max(1, currentRowCount) - 1; + return Math.max(0, Math.min(maxRow, row|0)); + }; + const clampFocusStart = (start) => { + const maxStart = Math.max(0, currentRowCount - manualFocusSize); + return Math.max(0, Math.min(maxStart, start|0)); + }; + function setManualTargetRow(row, { redraw = true } = {}) { + if (!manualModeState) return; + const targetRow = clampRow(row); + manualDetailRow = targetRow; + manualFloatingQuad = targetRow; + manualFocusEnabled = false; + syncManualUi(); + if (redraw) updateClassicDesign(); + debug('setManualTargetRow', { targetRow, focusStart: manualFocusStart, clusters: currentRowCount }); + } + function syncManualUi() { + if (!manualModeState) return; + if (manualRange) { + manualRange.max = Math.max(1, currentRowCount); + manualRange.value = String(Math.min(currentRowCount, manualDetailRow + 1)); + } + if (manualRangeLabel) { + manualRangeLabel.textContent = `Cluster ${Math.min(currentRowCount, manualDetailRow + 1)} / ${Math.max(currentRowCount, 1)}`; + } + if (manualFullBtn) manualFullBtn.disabled = !manualModeState; + if (manualFocusBtn) manualFocusBtn.disabled = !manualModeState; + } + + const focusSectionForRow = (row) => { + manualFocusEnabled = false; + manualFloatingQuad = clampRow(row); + manualDetailRow = clampRow(row); + syncManualUi(); + debug('focusSectionForRow', { row, manualFloatingQuad, clusters: currentRowCount }); + }; + const getQuadNodes = (row) => { + const svg = document.querySelector('#classic-display svg'); + if (!svg) return []; + let nodes = Array.from(svg.querySelectorAll(`g[data-quad-number="${row + 1}"]`)); + if (!nodes.length) { + const fallback = Array.from(svg.querySelectorAll(`[data-quad-number="${row + 1}"]`)) + .map(n => n.closest('g')).filter(Boolean); + nodes = Array.from(new Set(fallback)); + } + if (!nodes.length) { + // Fallback: match by id suffix balloon_*_row + const byId = Array.from(svg.querySelectorAll('g[id^="balloon_"]')).filter(n => { + const m = n.id.match(/_(\d+)$/); + return m && parseInt(m[1], 10) === row; + }); + nodes = Array.from(new Set(byId)); + } + if (!nodes.length) { + // Last resort: any element with the quad data on parent chain + const byAny = Array.from(svg.querySelectorAll('[data-quad-number]')) + .filter(el => parseInt(el.getAttribute('data-quad-number') || '0', 10) === row + 1) + .map(n => n.closest('g')).filter(Boolean); + nodes = Array.from(new Set(byAny)); + } + debug('quad nodes', { row, count: nodes.length }); + return nodes; + }; + const rectUnion = (rects) => { + if (!rects.length) return null; + const first = rects[0]; + const box = { left: first.left, right: first.right, top: first.top, bottom: first.bottom }; + rects.slice(1).forEach(r => { + box.left = Math.min(box.left, r.left); + box.right = Math.max(box.right, r.right); + box.top = Math.min(box.top, r.top); + box.bottom = Math.max(box.bottom, r.bottom); + }); + box.width = box.right - box.left; + box.height = box.bottom - box.top; + return box; + }; + const bboxUnion = (bboxes) => { + if (!bboxes.length) return null; + const first = bboxes[0]; + const box = { minX: first.x, minY: first.y, maxX: first.x + first.width, maxY: first.y + first.height }; + bboxes.slice(1).forEach(b => { + box.minX = Math.min(box.minX, b.x); + box.minY = Math.min(box.minY, b.y); + box.maxX = Math.max(box.maxX, b.x + b.width); + box.maxY = Math.max(box.maxY, b.y + b.height); + }); + return { x: box.minX, y: box.minY, width: box.maxX - box.minX, height: box.maxY - box.minY }; + }; + function buildQuadModalSvg(row) { + if (!quadModalDisplay) return null; + const nodes = getQuadNodes(row); + if (!nodes.length) return null; + const sourceSvg = document.querySelector('#classic-display svg'); + const svgNS = 'http://www.w3.org/2000/svg'; + + // Build a temp svg to measure the cloned quad accurately (transforms included) + const tempSvg = document.createElementNS(svgNS, 'svg'); + tempSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); + tempSvg.style.position = 'absolute'; + tempSvg.style.left = '-99999px'; + tempSvg.style.top = '-99999px'; + + const tempDefs = document.createElementNS(svgNS, 'defs'); + if (sourceSvg) { + const defs = sourceSvg.querySelector('defs'); + if (defs) tempDefs.appendChild(defs.cloneNode(true)); + } + const palette = buildClassicPalette(); + Object.entries(palette).forEach(([slot, info]) => { + if (!info?.image) return; + const pat = document.createElementNS(svgNS, 'pattern'); + pat.setAttribute('id', `classic-pattern-slot-${slot}`); + pat.setAttribute('patternContentUnits', 'objectBoundingBox'); + pat.setAttribute('width', '1'); + pat.setAttribute('height', '1'); + const img = document.createElementNS(svgNS, 'image'); + img.setAttributeNS(null, 'href', info.image); + img.setAttribute('x', '-0.75'); + img.setAttribute('y', '-0.75'); + img.setAttribute('width', '2.5'); + img.setAttribute('height', '2.5'); + img.setAttribute('preserveAspectRatio', 'xMidYMid slice'); + pat.appendChild(img); + tempDefs.appendChild(pat); + }); + tempSvg.appendChild(tempDefs); + + const tempG = document.createElementNS(svgNS, 'g'); + nodes.forEach(node => tempG.appendChild(node.cloneNode(true))); + tempG.querySelectorAll('.balloon').forEach(el => { + const code = parseInt(el.getAttribute('data-color-code') || '0', 10); + const info = palette[code] || null; + const fill = info + ? (info.image ? `url(#classic-pattern-slot-${code})` : (info.colour || info.hex || '#cccccc')) + : '#cccccc'; + el.setAttribute('fill', fill); + el.setAttribute('stroke', '#111827'); + el.setAttribute('stroke-width', '0.6'); + el.setAttribute('paint-order', 'stroke fill'); + el.setAttribute('pointer-events', 'all'); // keep taps/clicks active in detail view + }); + tempSvg.appendChild(tempG); + document.body.appendChild(tempSvg); + + let box = null; + try { + box = tempG.getBBox(); + debug('detail bbox (temp measure)', { row, box }); + } catch (err) { + debug('detail bbox measure failed', { row, err }); + } + if (!box || box.width < 1 || box.height < 1) { + box = { x: 0, y: 0, width: 50, height: 50 }; + debug('detail bbox tiny fallback', { row, box }); + } + + const padBase = Math.max(box.width, box.height) * 0.25; + const PAD = Math.max(12, padBase); + const vbW = Math.max(1, box.width + PAD * 2); + const vbH = Math.max(1, box.height + PAD * 2); + + const svgEl = document.createElementNS(svgNS, 'svg'); + svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); + svgEl.setAttribute('viewBox', `0 0 ${vbW} ${vbH}`); + svgEl.setAttribute('width', '100%'); + svgEl.setAttribute('height', '100%'); + svgEl.setAttribute('preserveAspectRatio', 'xMidYMid meet'); + svgEl.style.display = 'block'; + svgEl.style.pointerEvents = 'auto'; + svgEl.style.pointerEvents = 'auto'; + + // Reuse the temp defs in the final svg + svgEl.appendChild(tempDefs.cloneNode(true)); + const finalG = tempG.cloneNode(true); + finalG.setAttribute('transform', `translate(${PAD - box.x}, ${PAD - box.y})`); + finalG.querySelectorAll('.balloon').forEach(el => { + let fillOverride = null; + let strokeOverride = null; + let strokeWidth = null; + if (manualModeState) { + const idMatch = (el.id || '').match(/balloon_(\d+)_(\d+)/); + if (idMatch) { + const bx = parseInt(idMatch[1], 10); + const by = parseInt(idMatch[2], 10); + const key = manualKey(currentPatternName, currentRowCount); + const overrides = manualOverrides[key] || {}; + const ov = overrides[`${bx},${by}`]; + if (ov && typeof ov === 'object') { + // For detail view, ignore textures; use hex/colour only. + fillOverride = ov.hex || ov.colour || null; + } else if (typeof ov === 'number') { + const palette = buildClassicPalette(); + const info = palette[ov] || null; + if (info) fillOverride = info.colour || info.hex || null; + } + } + } + const isEmpty = !fillOverride; + if (isEmpty) { + el.setAttribute('fill', 'none'); + strokeOverride = '#94a3b8'; + strokeWidth = '1.2'; + } else { + el.setAttribute('fill', fillOverride); + } + if (strokeOverride) el.setAttribute('stroke', strokeOverride); + if (strokeWidth) el.setAttribute('stroke-width', strokeWidth); + el.setAttribute('paint-order', 'stroke fill'); + el.setAttribute('pointer-events', 'all'); + }); + svgEl.appendChild(finalG); + + tempSvg.remove(); + return svgEl; + } + function scheduleManualDetail() { /* detail view disabled */ } + function openQuadModal(row) { + return; + } + function closeQuadModal() { + if (!quadModal || !quadModalDisplay) return; + quadModalRow = null; + quadModalStartRect = null; + quadModalDisplay.style.transition = 'transform 180ms ease, opacity 160ms ease'; + quadModalDisplay.style.transform = 'translate(0,-10px) scale(0.92)'; + quadModalDisplay.style.opacity = '0'; + setTimeout(() => { + quadModal.classList.add('hidden'); + quadModal.setAttribute('aria-hidden', 'true'); + quadModalDisplay.innerHTML = ''; + quadModalDisplay.style.transform = ''; + quadModalDisplay.style.opacity = ''; + quadModalDisplay.style.transition = ''; + }, 200); + } + function updateClassicDesign() { if (!lengthInp || !patSel) return; patSel.value = computePatternName(); const patternName = patSel.value || 'Arch 4'; + currentPatternName = patternName; + const clusterCount = Math.max(1, Math.round((parseFloat(lengthInp.value) || 0) * 2)); + currentRowCount = clusterCount; + const manualOn = manualModeState; + if (!manualOn) { + manualFocusEnabled = false; + manualFloatingQuad = null; + closeQuadModal(); + } else { + manualDetailRow = clampRow(manualDetailRow); + // Preserve a cleared floating state; only set if not explicitly reset. + if (manualFloatingQuad !== null) manualFloatingQuad = clampRow(manualDetailRow); + manualFocusEnabled = false; + } + manualFocusStart = 0; + window.ClassicDesigner.manualMode = manualOn; const isColumn = patternName.toLowerCase().includes('column'); const hasTopper = patternName.includes('4') || patternName.includes('5'); - const showToggle = isColumn && hasTopper; + const showToggle = isColumn && hasTopper; if (patternName.toLowerCase().includes('column')) { const baseName = patternName.includes('5') ? 'Column 5' : 'Column 4'; applyTopperPreset(baseName, getTopperType()); @@ -983,31 +2062,159 @@ function distinctPaletteSlots(palette) { const isNumberTopper = getTopperType().startsWith('num-'); topperControls.classList.toggle('hidden', !showTopper); - if (numberTintRow) numberTintRow.classList.toggle('hidden', !(showTopper && isNumberTopper)); + // Number tint controls removed; always use base SVG appearance for numbers. if (nudgeOpenBtn) nudgeOpenBtn.classList.toggle('hidden', !showTopper); + const showReverse = patternLayout === 'spiral' && !manualOn; + if (reverseLabel) reverseLabel.classList.toggle('hidden', !showReverse); + if (reverseHint) reverseHint.classList.toggle('hidden', !showReverse); + if (reverseCb) { + reverseCb.disabled = manualOn || !showReverse; + if (!showReverse) reverseCb.checked = false; + } GC.setTopperEnabled(showTopper); - GC.setClusters(Math.round((parseFloat(lengthInp.value) || 0) * 2)); + GC.setClusters(clusterCount); + GC.setManualMode(manualOn); GC.setReverse(!!reverseCb?.checked); GC.setTopperType(getTopperType()); - GC.setNumberTintHex(getNumberTintColor()); - GC.setNumberTintOpacity(numberTintSlider ? numberTintSlider.value : getNumberTintOpacity()); - applyNumberTopperTexture(getTopperType()); GC.setTopperOffsetX(topperOffsetX); GC.setTopperOffsetY(topperOffsetY); - GC.setTopperSize(topperSizeInp?.value); + GC.setTopperSize(topperSizeInput?.value); GC.setShineEnabled(!!shineEnabledCb?.checked); GC.setBorderEnabled(!!borderEnabledCb?.checked); + const expandedOn = manualOn && manualExpandedState; + GC.setExplodedSettings({ + scale: expandedOn ? 1.18 : 1, + gapPx: expandedOn ? 90 : 0, + staggerPx: expandedOn ? 6 : 0 + }); + if (display) { + display.classList.toggle('classic-expanded-canvas', expandedOn); + } + const totalClusters = clusterCount; + manualFocusStart = 0; + GC.setManualFocus({ start: manualFocusStart, size: manualFocusSize, enabled: false }); + GC.setManualFloatingQuad(manualFloatingQuad); + if (quadReset) { + quadReset.disabled = manualFloatingQuad === null; + } + if (toolbarReset) { + toolbarReset.disabled = manualFloatingQuad === null; + } + if (focusLabel) { + if (!manualFocusEnabled) { + focusLabel.textContent = 'Full design view'; + } else { + const end = Math.min(totalClusters, manualFocusStart + manualFocusSize); + focusLabel.textContent = `Clusters ${manualFocusStart + 1}–${end} of ${totalClusters}`; + } + } + if (focusLabelCanvas) { + if (!manualFocusEnabled) { + focusLabelCanvas.textContent = 'Full design view'; + } else { + const end = Math.min(totalClusters, manualFocusStart + manualFocusSize); + focusLabelCanvas.textContent = `Clusters ${manualFocusStart + 1}–${end} of ${totalClusters}`; + } + } if (document.body) { if (showTopper) document.body.dataset.topperOverlay = '1'; else delete document.body.dataset.topperOverlay; } window.__updateFloatingNudge?.(); - if(clusterHint) clusterHint.textContent = `≈ ${Math.round((parseFloat(lengthInp.value) || 0) * 2)} clusters (rule: 2 clusters/ft)`; + if(clusterHint) clusterHint.textContent = `≈ ${clusterCount} clusters (rule: 2 clusters/ft)`; refreshClassicPaletteUi?.(); ctrl.selectPattern(patternName); + syncManualUi(); + renderProjectPalette(); + scheduleManualDetail(); persistState(); } + function handleManualPaint(evt) { + if (!manualModeState) return; + const target = evt.target; + if (!target) return; + if (target.closest && target.closest('[data-is-topper]')) return; + const g = target.closest('g[id^="balloon_"]') || (target.id && target); + const id = g?.id || ''; + const match = id.match(/balloon_(\d+)_(\d+)/); + if (!match) { debug('manual paint click ignored (no match)', { id }); return; } + const x = parseInt(match[1], 10); + const y = parseInt(match[2], 10); + + if (manualFloatingQuad !== y) { + setManualTargetRow(y); + return; + } + + debug('manual paint click', { x, y, manualTool, mode: manualModeState, currentPatternName, currentRowCount }); + const prev = getManualOverride(currentPatternName, currentRowCount, x, y); + const palette = buildClassicPalette(); + const colorCode = parseInt(g?.getAttribute('data-color-code') || '0', 10); + const currentFill = prev || palette[colorCode] || { hex: '#ffffff', image: null }; + if (manualTool === 'pick') { + const picked = { hex: normHex(currentFill.hex || currentFill.colour || '#ffffff'), image: currentFill.image || null }; + manualActiveColorGlobal = window.shared?.setActiveColor?.(picked) || picked; + manualRedoStack.length = 0; + manualTool = 'paint'; + updateClassicDesign(); + return; + } + manualRedoStack.length = 0; + if (manualTool === 'erase') { + const ERASE_COLOR = { hex: 'transparent', colour: 'transparent', image: null }; + manualUndoStack.push({ pattern: currentPatternName, rows: currentRowCount, x, y, prev, next: ERASE_COLOR }); + setManualOverride(currentPatternName, currentRowCount, x, y, ERASE_COLOR); + manualRedoStack.length = 0; + updateClassicDesign(); + scheduleManualDetail(); + return; + } + const colorToUse = manualActiveColorGlobal || window.shared?.getActiveColor?.() || { hex: '#ffffff', image: null }; + const hexSource = colorToUse.hex || colorToUse.colour || '#ffffff'; + const next = { hex: normHex(hexSource), image: colorToUse.image || null }; + // If clicking a filled balloon with the same active fill, toggle it off (supports images and solids). + const isSameHex = normHex(currentFill.hex || currentFill.colour) === next.hex; + const sameImage = currentFill.image && next.image && currentFill.image === next.image; + const isSameFill = (sameImage || (!currentFill.image && !next.image && isSameHex)); + const finalNext = isSameFill ? { hex: 'transparent', colour: 'transparent', image: null } : next; + manualUndoStack.push({ pattern: currentPatternName, rows: currentRowCount, x, y, prev, next: finalNext }); + setManualOverride(currentPatternName, currentRowCount, x, y, finalNext); + updateClassicDesign(); + scheduleManualDetail(); + } + function undoLastManual() { + const last = manualUndoStack.pop(); + if (!last) return; + if (last.clear) { + const key = manualKey(last.pattern, last.rows); + delete manualOverrides[key]; + if (last.snapshot) manualOverrides[key] = { ...last.snapshot }; + } else if (last.pattern !== currentPatternName || last.rows !== currentRowCount) { + setManualOverride(last.pattern, last.rows, last.x, last.y, last.prev || undefined); + } else { + if (last.prev) setManualOverride(last.pattern, last.rows, last.x, last.y, last.prev); + else clearManualOverride(last.pattern, last.rows, last.x, last.y); + } + manualRedoStack.push(last); + updateClassicDesign(); + } + function redoLastManual() { + const last = manualRedoStack.pop(); + if (!last) return; + if (last.clear) { + const key = manualKey(last.pattern, last.rows); + const prevSnapshot = manualOverrides[key] ? { ...manualOverrides[key] } : null; + manualUndoStack.push({ clear: true, pattern: last.pattern, rows: last.rows, snapshot: prevSnapshot }); + delete manualOverrides[key]; + } else { + const prev = getManualOverride(last.pattern, last.rows, last.x, last.y); + manualUndoStack.push({ ...last, prev }); + if (last.next) setManualOverride(last.pattern, last.rows, last.x, last.y, last.next); + else clearManualOverride(last.pattern, last.rows, last.x, last.y); + } + updateClassicDesign(); + } const setLengthForPattern = () => { if (!lengthInp || !patSel) return; @@ -1022,16 +2229,177 @@ function distinctPaletteSlots(palette) { window.ClassicDesigner.getTopperColor = getTopperColor; document.querySelector('#mode-tabs')?.addEventListener('click', () => setTimeout(() => { if (window.updateExportButtonVisibility) window.updateExportButtonVisibility() }, 50)); + display?.addEventListener('click', handleManualPaint); patSel?.addEventListener('change', () => { lastPresetKey = null; syncPatternStateFromSelect(); + manualFocusEnabled = false; + manualFloatingQuad = null; + closeQuadModal(); applyPatternButtons(); setLengthForPattern(); updateClassicDesign(); }); patternShapeBtns.forEach(btn => btn.addEventListener('click', () => { patternShape = btn.dataset.patternShape; lastPresetKey = null; applyPatternButtons(); setLengthForPattern(); updateClassicDesign(); })); patternCountBtns.forEach(btn => btn.addEventListener('click', () => { patternCount = Number(btn.dataset.patternCount) === 5 ? 5 : 4; lastPresetKey = null; applyPatternButtons(); setLengthForPattern(); updateClassicDesign(); })); - patternLayoutBtns.forEach(btn => btn.addEventListener('click', () => { patternLayout = btn.dataset.patternLayout === 'stacked' ? 'stacked' : 'spiral'; lastPresetKey = null; applyPatternButtons(); updateClassicDesign(); })); + patternLayoutBtns.forEach(btn => btn.addEventListener('click', () => { + patternLayout = btn.dataset.patternLayout === 'stacked' ? 'stacked' : 'spiral'; + lastNonManualLayout = patternLayout; + manualModeState = false; + saveManualMode(false); + manualExpandedState = false; + saveManualExpanded(false); + manualFocusEnabled = false; + manualFocusStart = 0; + manualDetailRow = 0; + manualFloatingQuad = null; + closeQuadModal(); + applyPatternButtons(); + updateClassicDesign(); + })); + manualModeBtn?.addEventListener('click', () => { + const togglingOn = !manualModeState; + if (togglingOn) lastNonManualLayout = patternLayout === 'manual' ? 'spiral' : patternLayout; + manualModeState = togglingOn; + patternLayout = togglingOn ? 'manual' : lastNonManualLayout; + manualFocusStart = 0; + manualFocusEnabled = false; // keep full-view; quad pull-out handles focus + manualDetailRow = 0; + manualFloatingQuad = null; + closeQuadModal(); + saveManualMode(togglingOn); + applyPatternButtons(); + updateClassicDesign(); + if (togglingOn) scheduleManualDetail(); + debug('manual mode toggle', { on: togglingOn, clusters: currentRowCount }); + }); + const shiftFocus = (dir) => { + const next = clampRow(manualDetailRow + dir); + setManualTargetRow(next); + }; + focusPrev?.addEventListener('click', () => shiftFocus(-1)); + focusNext?.addEventListener('click', () => shiftFocus(1)); + focusZoomOut?.addEventListener('click', () => { manualFocusStart = 0; manualFloatingQuad = null; manualFocusEnabled = false; updateClassicDesign(); }); + toolbarPrev?.addEventListener('click', () => shiftFocus(-1)); + toolbarNext?.addEventListener('click', () => shiftFocus(1)); + toolbarZoomOut?.addEventListener('click', () => { manualFocusStart = 0; manualFloatingQuad = null; manualFocusEnabled = false; updateClassicDesign(); }); + toolbarReset?.addEventListener('click', () => { manualFloatingQuad = null; updateClassicDesign(); }); + quadReset?.addEventListener('click', () => { manualFloatingQuad = null; updateClassicDesign(); }); + quadModalClose?.addEventListener('click', closeQuadModal); + quadModal?.addEventListener('click', (e) => { + if (e.target === quadModal || (e.target && e.target.classList && e.target.classList.contains('quad-modal-backdrop'))) { + closeQuadModal(); + } + }); + quadModalDisplay?.addEventListener('click', handleManualPaint); + manualDetailDisplay?.addEventListener('click', handleManualPaint); + manualRange?.addEventListener('input', () => setManualTargetRow(Number(manualRange.value) - 1)); + manualPrevBtn?.addEventListener('click', () => setManualTargetRow(manualDetailRow - 1)); + manualNextBtn?.addEventListener('click', () => setManualTargetRow(manualDetailRow + 1)); + manualFullBtn?.addEventListener('click', () => { + manualFocusEnabled = true; // keep focus so detail/rail stay active + manualFloatingQuad = null; + updateClassicDesign(); + scheduleManualDetail(); + debug('manual full view'); + }); + manualFocusBtn?.addEventListener('click', () => setManualTargetRow(manualDetailRow)); + manualPaletteBtn?.addEventListener('click', () => { + if (!window.openColorPicker) return; + const items = flattenPalette().map(c => ({ + label: c.name || c.hex, + hex: c.hex, + meta: c, + metaText: c.family || '' + })); + window.openColorPicker({ + title: 'Manual paint color', + subtitle: 'Applies to manual paint tool', + items, + onSelect: (item) => { + const meta = item.meta || {}; + manualActiveColorGlobal = window.shared?.setActiveColor?.({ hex: meta.hex || item.hex, image: meta.image || null }) || { hex: meta.hex || item.hex, image: meta.image || null }; + updateClassicDesign(); + } + }); + }); + // Keep detail view in sync after initial render when manual mode is pre-enabled + if (manualModeState) { + scheduleManualDetail(); + } + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') closeQuadModal(); + }); + expandedToggle?.addEventListener('change', () => { + manualExpandedState = !!expandedToggle.checked; + saveManualExpanded(manualExpandedState); + updateClassicDesign(); + }); + floatingUndo?.addEventListener('click', undoLastManual); + floatingRedo?.addEventListener('click', redoLastManual); + floatingPick?.addEventListener('click', () => { manualTool = 'pick'; applyPatternButtons(); }); + floatingErase?.addEventListener('click', () => { manualTool = 'erase'; applyPatternButtons(); }); + floatingClear?.addEventListener('click', () => { + const key = manualKey(currentPatternName, currentRowCount); + const prev = manualOverrides[key] ? { ...manualOverrides[key] } : null; + manualUndoStack.push({ clear: true, pattern: currentPatternName, rows: currentRowCount, snapshot: prev }); + delete manualOverrides[key]; + manualRedoStack.length = 0; + updateClassicDesign(); + }); + floatingExport?.addEventListener('click', () => { + document.querySelector('[data-export="png"]')?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }); + floatingUndo?.addEventListener('click', undoLastManual); + // Reset floated quad by clicking empty canvas area. + display?.addEventListener('click', (e) => { + if (!manualModeState) return; + const hit = e.target?.closest?.('g[id^="balloon_"], [data-quad-number]'); + if (hit) return; + if (manualFloatingQuad !== null) { + manualFloatingQuad = null; + updateClassicDesign(); + } + }); + // Zoom: wheel and pinch on the display + const handleZoom = (factor) => { + classicZoom = clampZoom(classicZoom * factor); + updateClassicDesign(); + }; + display?.addEventListener('wheel', (e) => { + const dy = e.deltaY || 0; + if (dy === 0) return; + const factor = dy > 0 ? 0.9 : 1.1; + handleZoom(factor); + e.preventDefault(); + }, { passive: false }); + let pinchBase = null; + const pinchDistance = (touches) => { + if (!touches || touches.length < 2) return 0; + const dx = touches[0].clientX - touches[1].clientX; + const dy = touches[0].clientY - touches[1].clientY; + return Math.hypot(dx, dy); + }; + display?.addEventListener('touchstart', (e) => { + if (e.touches.length === 2) { + pinchBase = pinchDistance(e.touches); + e.preventDefault(); + } + }, { passive: false }); + display?.addEventListener('touchmove', (e) => { + if (pinchBase && e.touches.length === 2) { + const d = pinchDistance(e.touches); + if (d > 0) { + const factor = d / pinchBase; + handleZoom(factor); + pinchBase = d; + } + e.preventDefault(); + } + }, { passive: false }); + const resetPinch = () => { pinchBase = null; }; + display?.addEventListener('touchend', resetPinch); + display?.addEventListener('touchcancel', resetPinch); topperNudgeBtns.forEach(btn => btn.addEventListener('click', () => { const dx = Number(btn.dataset.dx || 0); const dy = Number(btn.dataset.dy || 0); @@ -1044,15 +2412,15 @@ function distinctPaletteSlots(palette) { })); topperTypeButtons.forEach(btn => btn.addEventListener('click', () => { setTopperType(btn.dataset.type); - applyNumberTopperTexture(btn.dataset.type); + ensureNumberTopperImage(btn.dataset.type); resetNonNumberTopperColor(btn.dataset.type); + if (topperEnabledCb) { + topperEnabledCb.checked = true; + GC.setTopperEnabled(true); + } lastPresetKey = null; updateClassicDesign(); })); - numberTintSlider?.addEventListener('input', () => { - GC.setNumberTintOpacity(numberTintSlider.value); - updateClassicDesign(); - }); nudgeOpenBtn?.addEventListener('click', (e) => { e.preventDefault(); window.__showFloatingNudge?.(); @@ -1076,8 +2444,8 @@ function distinctPaletteSlots(palette) { } }); document.addEventListener('fullscreenchange', updateFullscreenLabel); - [lengthInp, reverseCb, topperEnabledCb, topperSizeInp] - .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(); }); }); + [lengthInp, reverseCb, topperEnabledCb, topperSizeInput] + .forEach(el => { if (!el) return; const eventType = (el.type === 'range' || el.type === 'number') ? 'input' : 'change'; el.addEventListener(eventType, () => { if (el === topperSizeInput || 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); }); borderEnabledCb?.addEventListener('change', (e) => { @@ -1103,4 +2471,69 @@ function distinctPaletteSlots(palette) { window.ClassicDesigner = window.ClassicDesigner || { init: initClassic, api: null, redraw: null }; document.addEventListener('DOMContentLoaded', () => { if (document.getElementById('classic-display') && !window.__classicInit) { window.__classicInit = true; initClassic(); } }); + + // Export helper for tab-level routing + (function setupClassicExport() { + const { imageUrlToDataUrl, XLINK_NS } = window.shared || {}; + if (!imageUrlToDataUrl || !XLINK_NS) return; + function getImageHref(el) { return el.getAttribute('href') || el.getAttributeNS(XLINK_NS, 'href'); } + function setImageHref(el, val) { + el.setAttribute('href', val); + el.setAttributeNS(XLINK_NS, 'xlink:href', val); + } + async function buildClassicSvgPayload() { + const svgElement = document.querySelector('#classic-display svg'); + if (!svgElement) throw new Error('Classic design not found. Please create a design first.'); + const clonedSvg = svgElement.cloneNode(true); + let bbox = null; + try { + const temp = clonedSvg.cloneNode(true); + temp.style.position = 'absolute'; + temp.style.left = '-99999px'; + temp.style.top = '-99999px'; + temp.style.width = '0'; + temp.style.height = '0'; + document.body.appendChild(temp); + const target = temp.querySelector('g') || temp; + bbox = target.getBBox(); + temp.remove(); + } catch {} + + const allImages = Array.from(clonedSvg.querySelectorAll('image')); + await Promise.all(allImages.map(async img => { + const href = getImageHref(img); + if (!href || href.startsWith('data:')) return; + const dataUrl = await imageUrlToDataUrl(href); + if (dataUrl) setImageHref(img, dataUrl); + })); + + const viewBox = (clonedSvg.getAttribute('viewBox') || '0 0 1000 1000').split(/\s+/).map(Number); + let vbX = isFinite(viewBox[0]) ? viewBox[0] : 0; + let vbY = isFinite(viewBox[1]) ? viewBox[1] : 0; + let vbW = isFinite(viewBox[2]) ? viewBox[2] : (svgElement.clientWidth || 1000); + let vbH = isFinite(viewBox[3]) ? viewBox[3] : (svgElement.clientHeight || 1000); + if (bbox && isFinite(bbox.x) && isFinite(bbox.y) && isFinite(bbox.width) && isFinite(bbox.height)) { + const pad = 10; + vbX = bbox.x - pad; + vbY = bbox.y - pad; + vbW = Math.max(1, bbox.width + pad * 2); + vbH = Math.max(1, bbox.height + pad * 2); + } + clonedSvg.setAttribute('width', vbW); + clonedSvg.setAttribute('height', vbH); + if (!clonedSvg.getAttribute('xmlns')) clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); + if (!clonedSvg.getAttribute('xmlns:xlink')) clonedSvg.setAttribute('xmlns:xlink', XLINK_NS); + + clonedSvg.querySelectorAll('g.balloon, path.balloon, ellipse.balloon, circle.balloon').forEach(el => { + if (!el.getAttribute('stroke')) el.setAttribute('stroke', '#111827'); + if (!el.getAttribute('stroke-width')) el.setAttribute('stroke-width', '1'); + if (!el.getAttribute('paint-order')) el.setAttribute('paint-order', 'stroke fill'); + if (!el.getAttribute('vector-effect')) el.setAttribute('vector-effect', 'non-scaling-stroke'); + }); + + const svgString = new XMLSerializer().serializeToString(clonedSvg); + return { svgString, width: vbW, height: vbH, minX: vbX, minY: vbY }; + } + window.ClassicExport = { buildClassicSvgPayload }; + })(); })(); diff --git a/colors.js b/colors.js index 73a5692..c7f8401 100644 --- a/colors.js +++ b/colors.js @@ -12,7 +12,8 @@ const PALETTE = [ ]}, { family: "Oranges & Browns & Yellows", colors: [ {name:"Pastel Yellow",hex:"#fcfd96"},{name:"Yellow",hex:"#f5e812"},{name:"Goldenrod",hex:"#f7b615"}, - {name:"Orange",hex:"#ef6b24"},{name:"Coffee",hex:"#957461"},{name:"Burnt Orange",hex:"#9d4223"} + {name:"Orange",hex:"#ef6b24"},{name:"Coffee",hex:"#957461"},{name:"Burnt Orange",hex:"#9d4223"}, + {name:"Blended Brown",hex:"#c9aea0"} ]}, { family: "Greens", colors: [ {name:"Eucalyptus",hex:"#a3bba3"},{name:"Pastel Green",hex:"#acdba7"},{name:"Lime Green",hex:"#8fc73e"}, @@ -63,4 +64,4 @@ const PALETTE = [ ]; window.CLASSIC_COLORS = ['#D92E3A', '#FFFFFF', '#0055A4', '#40E0D0']; - window.PALETTE = window.PALETTE || (typeof PALETTE !== "undefined" ? PALETTE : []); \ No newline at end of file + window.PALETTE = window.PALETTE || (typeof PALETTE !== "undefined" ? PALETTE : []); diff --git a/index.html b/index.html index 65ed126..bfa26ac 100644 --- a/index.html +++ b/index.html @@ -24,9 +24,9 @@ -
+
-
+
@@ -37,14 +37,10 @@
-
-
- -
-
@@ -78,11 +74,11 @@
- - @@ -98,31 +94,17 @@ 1.0
-
-
- - - +
+
+ Main Colors +
+
+

Tap a chip to change it. You can add up to 10 main colors.

- - - -
-
- - - -
-
- - - -
-
- - - + Accent + +
@@ -137,7 +119,7 @@
-

Drag balloons to reposition. Use keyboard arrows for fine nudges.

+

Drag balloons to reposition. Use arrows/touches for fine nudges.

Resize @@ -173,38 +155,44 @@
-
Project Palette
-
-
- Built from the current design. Click a swatch to select that color. - -
-
-
- -
Color Library
-
-

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

-
- Active Color -
- +
Organic Colors
+
+
+
+ Active color +
+ +
-
-
-
Replace Color
-
-
- - +
+
+ Project Palette + +
+
+
- - +
+
Replace Color
+
+ + + + +
+
+ + + +

+
+
- -

+
+
Color Library
+
@@ -266,9 +254,24 @@
Layout
-
+
+ +
+ +
- Add Topper (24") + Add Topper
@@ -317,18 +320,6 @@
-
-
Topper Size
- -
-
@@ -359,49 +350,106 @@
Classic Colors
-
-
- +
+
+ +
+
+ Active color +
+
-
Pick a color for Slot #1 (from colors.js):
-
-
- +
+ -
-
Save & Share
-
-
- - -

SVG keeps the vector Classic layout; PNG is raster.

-
-

Classic JSON save/load coming soon.

-
+
+
Save & Share
+
+
+ + +

SVG keeps the vector Classic layout; PNG is raster.

+

Classic JSON save/load coming soon.

+
+
+ class="order-1 w-full lg:flex-1 grid grid-rows-[1fr] lg:grid-rows-[minmax(0,1fr)] gap-2 shadow-x3 rounded-2xl overflow-hidden bg-white">
+
+ +
@@ -435,6 +612,71 @@
+ + + + + + + + +