diff --git a/classic.js b/classic.js index 0a26015..992dd53 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,6 +39,8 @@ }); return 0.2126 * norm[0] + 0.7152 * norm[1] + 0.0722 * norm[2]; } + let classicZoom = 1; + const clampZoom = (z) => Math.min(2.2, Math.max(0.5, z)); function classicShineStyle(colorInfo) { const hex = normHex(colorInfo?.hex || colorInfo?.colour || ''); if (hex.startsWith('#')) { @@ -72,17 +75,20 @@ 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', - '2': 'output_webp/2.webp', - '3': 'output_webp/3.webp', - '4': 'output_webp/4.webp', - '5': 'output_webp/5.webp', - '6': 'output_webp/6.webp', - '7': 'output_webp/7.webp', - '8': 'output_webp/8.webp', - '9': 'output_webp/9.webp' + '0': 'output_webp/0.svg', + '1': 'output_webp/1.svg', + '2': 'output_webp/2.svg', + '3': 'output_webp/3.svg', + '4': 'output_webp/4.svg', + '5': 'output_webp/5.svg', + '6': 'output_webp/6.svg', + '7': 'output_webp/7.svg', + '8': 'output_webp/8.svg', + '9': 'output_webp/9.svg' }; const MAX_SLOTS = 20; @@ -150,13 +156,73 @@ const saved = parseFloat(localStorage.getItem(NUMBER_TINT_OPACITY_KEY)); if (!isNaN(saved)) return clamp01(saved); } catch {} - return 0.5; + return 1; // default to full tint so number color changes are obvious } function setNumberTintOpacity(v) { const clamped = clamp01(parseFloat(v)); try { localStorage.setItem(NUMBER_TINT_OPACITY_KEY, String(clamped)); } catch {} return clamped; } + function loadManualMode() { + try { + const saved = JSON.parse(localStorage.getItem(MANUAL_MODE_KEY)); + if (typeof saved === 'boolean') return saved; + } catch {} + return false; // default to pattern-driven mode; manual is opt-in + } + function saveManualMode(on) { + try { localStorage.setItem(MANUAL_MODE_KEY, JSON.stringify(!!on)); } catch {} + } + function loadManualExpanded() { + try { + const saved = JSON.parse(localStorage.getItem(MANUAL_EXPANDED_KEY)); + if (typeof saved === 'boolean') return saved; + } catch {} + return true; + } + 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); + } + } + let manualActiveColorGlobal = (window.shared?.getActiveColor?.()) || { hex: '#ffffff', image: null }; function getTopperTypeSafe() { try { return (window.ClassicDesigner?.lastTopperType) || null; } catch { return null; } } @@ -213,6 +279,14 @@ 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 = { @@ -229,7 +303,19 @@ 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 +338,24 @@ 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; + const manualScale = expandedOn ? 1.35 : 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,12 +366,15 @@ 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' })); + if (!isNumTopper) { + 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-')) { + if (tintOpacity > 0 && isNumTopper) { 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' }) @@ -289,6 +385,8 @@ mask: `url(#${maskId})`, style: 'mix-blend-mode:multiply; pointer-events:none' })); + // Also draw the image beneath with zero opacity to keep mask refs consistent + 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;opacity:0' })); } } else if (Array.isArray(base.paths)) { base.paths.forEach(p => { @@ -301,7 +399,7 @@ } const allowShine = base.allowShine !== false; - const applyShine = model.shineEnabled && (!cell.isTopper || allowShine); + const applyShine = !wireframe && model.shineEnabled && (!cell.isTopper || allowShine); if (applyShine) { const shine = classicShineStyle(colorInfo); const shineAttrs = { @@ -320,7 +418,25 @@ 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 along the arch tangent to increase spacing without distorting the curve. + const dist = Math.hypot(xPx, yPx) || 1; + const tx = -yPx / dist; + const ty = xPx / dist; + const push = rowIndex * gap; + xPx += tx * push; + yPx += ty * push; + } 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,10 +455,13 @@ function distinctPaletteSlots(palette) { function newGrid(pattern, cells, container, model){ - const kids = [], layers = [], bbox = new BBox(); + const kids = [], layers = [], bbox = new BBox(), focusBox = new BBox(); + 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)); @@ -359,6 +478,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 +503,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,24 +549,156 @@ 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) { + const isArch = (model.patternName || '').toLowerCase().includes('arch'); + let slideX = 80; + let slideY = 0; + if (isArch) { + // Radial slide outward; preserve layout. + const dist = Math.hypot(c.x, c.y) || 1; + const offset = 80; + slideX = (c.x / dist) * offset; + slideY = (c.y / dist) * offset; + } + let tx = c.x + slideX; + let ty = c.y + slideY; + // Keep shape intact; only fan columns slightly. + const idx = typeof cell.balloonIndexInCluster === 'number' ? cell.balloonIndexInCluster : 0; + const spread = idx - 1.5; + if (isArch) { + // no fan/scale for arches; preserve layout + } else { + tx += spread * 4; + ty += spread * 2; + } + 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(' '); + // 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 SVG_PATTERN_ZOOM = 2.5; @@ -443,6 +711,11 @@ 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' }) ] @@ -451,7 +724,14 @@ function distinctPaletteSlots(palette) { const svgDefs = svg('defs', {}, 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])); + m.render(container, svg('svg', { + xmlns: 'http://www.w3.org/2000/svg', + width:'100%', + height:'100%', + viewBox: vb, + preserveAspectRatio:'xMidYMid meet', + style: `isolation:isolate; transform:scale(${classicZoom}); transform-origin:center center;` + }, [svgDefs, mainGroup])); } function makeController(displayEl){ @@ -464,9 +744,24 @@ 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, + numberTintHex, + numberTintOpacity, + manualMode, + manualFocusEnabled, + manualFloatingQuad, + explodedScale, + explodedGapPx, + explodedStaggerPx, + manualFocusStart, + manualFocusSize }; const rows = pattern.cellsPerRow * model.rowCount, cols = pattern.cellsPerColumn; for (let y=0; y { + 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'); @@ -709,6 +1032,7 @@ function distinctPaletteSlots(palette) { 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]; @@ -756,7 +1080,39 @@ 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 }; + activeLabel.textContent = manualModeOn ? 'Manual paint color' : (activeTarget === 'T' ? 'Topper' : `Slot #${activeTarget}`); + 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; + 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'; + } } const allPaletteColors = flattenPalette(); swatchGrid.innerHTML = ''; @@ -776,14 +1132,17 @@ function distinctPaletteSlots(palette) { sw.addEventListener('click', () => { const selectedColor = { hex: colorItem.hex, image: colorItem.image }; + const currentType = document.querySelector('.topper-type-btn[aria-pressed="true"]')?.dataset.type || 'round'; 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(); + setNumberTintOpacity(1); + if (numberTintSlider) numberTintSlider.value = 1; } else { 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); } @@ -796,6 +1155,14 @@ function distinctPaletteSlots(palette) { swatchGrid.appendChild(row); }); topperSwatch.addEventListener('click', () => { activeTarget = 'T'; updateUI(); }); + activeChip?.addEventListener('click', () => { + if (openManualPicker()) return; + try { swatchGrid?.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch {} + }); + floatingChip?.addEventListener('click', () => { + if (openManualPicker()) return; + try { swatchGrid?.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch {} + }); randomizeBtn?.addEventListener('click', () => { const pool = allPaletteColors.slice(); const picks = []; const colorCount = visibleSlotCount(); @@ -829,10 +1196,19 @@ function distinctPaletteSlots(palette) { 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 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 numberTintRow = document.getElementById('classic-number-tint-row'), numberTintSlider = document.getElementById('classic-number-tint'); 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 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]')); @@ -843,6 +1219,26 @@ function distinctPaletteSlots(palette) { let lastPresetKey = null; // 'custom' means user-tweaked; otherwise `${pattern}:${type}` window.ClassicDesigner = window.ClassicDesigner || {}; window.ClassicDesigner.lastTopperType = window.ClassicDesigner.lastTopperType || 'round'; + let patternShape = 'arch', patternCount = 4, patternLayout = 'spiral', lastNonManualLayout = 'spiral'; + let 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 }; + let currentPatternName = ''; + let currentRowCount = Math.max(1, Math.round((parseFloat(lengthInp?.value) || 0) * 2)); + let manualFocusStart = 0; + const manualFocusSize = 8; + const manualUndoStack = []; + const manualRedoStack = []; + let manualTool = 'paint'; // paint | pick | erase + let manualFloatingQuad = null; + let quadModalRow = null; + let quadModalStartRect = null; + let manualDetailRow = 0; + let manualDetailFrame = null; + classicZoom = 1; + // Force UI to reflect initial manual state + if (manualModeState) patternLayout = 'manual'; if (numberTintSlider) numberTintSlider.value = getNumberTintOpacity(); const topperPresets = { 'Column 4:heart': { enabled: true, offsetX: 3, offsetY: -10.5, size: 1.05 }, @@ -904,7 +1300,6 @@ function distinctPaletteSlots(palette) { 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 +1313,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 +1322,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(); @@ -967,17 +1388,272 @@ 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; - if (patternName.toLowerCase().includes('column')) { - const baseName = patternName.includes('5') ? 'Column 5' : 'Column 4'; - applyTopperPreset(baseName, getTopperType()); - } + const showToggle = isColumn && hasTopper; + if (patternName.toLowerCase().includes('column')) { + const baseName = patternName.includes('5') ? 'Column 5' : 'Column 4'; + applyTopperPreset(baseName, getTopperType()); + } if (topperToggleRow) topperToggleRow.classList.toggle('hidden', !showToggle); const showTopper = showToggle && topperEnabledCb?.checked; const isNumberTopper = getTopperType().startsWith('num-'); @@ -985,9 +1661,14 @@ function distinctPaletteSlots(palette) { topperControls.classList.toggle('hidden', !showTopper); if (numberTintRow) numberTintRow.classList.toggle('hidden', !(showTopper && isNumberTopper)); if (nudgeOpenBtn) nudgeOpenBtn.classList.toggle('hidden', !showTopper); + if (reverseCb) { + reverseCb.disabled = manualOn; + if (manualOn) 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()); @@ -998,16 +1679,138 @@ function distinctPaletteSlots(palette) { GC.setTopperSize(topperSizeInp?.value); GC.setShineEnabled(!!shineEnabledCb?.checked); GC.setBorderEnabled(!!borderEnabledCb?.checked); + const expandedOn = manualOn && manualExpandedState; + GC.setExplodedSettings({ + scale: expandedOn ? 1.18 : 1, + gapPx: expandedOn ? 26 : 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(); + 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 +1825,148 @@ 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)); + // 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); + // 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); @@ -1106,8 +2041,8 @@ function distinctPaletteSlots(palette) { // Export helper for tab-level routing (function setupClassicExport() { - const { imageUrlToDataUrl, XLINK_NS } = window.shared || {}; - if (!imageUrlToDataUrl || !XLINK_NS) return; + 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); diff --git a/style.css b/style.css index e2930f0..7f03fe0 100644 --- a/style.css +++ b/style.css @@ -31,6 +31,13 @@ body[data-active-tab="#tab-wall"] #clear-canvas-btn-top { #balloon-canvas { touch-action: none; } +.classic-expanded-canvas { + height: 130vh !important; + min-height: 130vh; + overflow: auto; +} + + .balloon-canvas { background: #fff; border-radius: 1rem; @@ -429,7 +436,7 @@ body[data-active-tab="#tab-wall"] #clear-canvas-btn-top { #classic-display, #wall-display, #balloon-canvas { - margin-bottom: 5rem; + margin-bottom: 6.5rem; } /* Stack switching: show only the active mobile tab stack across panels */ @@ -464,6 +471,8 @@ body[data-active-tab="#tab-wall"] #clear-canvas-btn-top { .swatch.tiny { width: 1.8rem; height: 1.8rem; } .select { min-height: 44px; } .panel-card { padding: 0.85rem; } + .manual-hub { position: sticky; top: 0; z-index: 6; } + .manual-detail-stage { min-height: 260px; } } .mobile-action-bar { @@ -607,6 +616,57 @@ body[data-active-tab="#tab-wall"] #clear-canvas-btn-top { box-shadow: 0 0 0 2px rgba(37,99,235,0.18), 0 6px 16px rgba(37,99,235,0.2); } +.manual-hub { + background: linear-gradient(135deg, rgba(255,255,255,0.9), rgba(240,249,255,0.92)); + border: 1px solid rgba(226,232,240,0.9); + border-radius: 1rem; + padding: 0.9rem 1rem; + box-shadow: 0 12px 28px rgba(15,23,42,0.08); + display: flex; + flex-direction: column; + gap: 0.6rem; +} +.manual-hub-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; +} +.manual-hub-title { font-weight: 800; color: #0f172a; letter-spacing: -0.015em; } +.manual-hub-subtitle { font-size: 0.85rem; color: #475569; line-height: 1.3; } +.manual-hub-actions { display: flex; gap: 0.35rem; } +.manual-hub-track { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 0.55rem; +} +.manual-hub-count { grid-column: 1 / -1; text-align: right; } +.manual-range { + width: 100%; + accent-color: #2563eb; +} +.manual-detail { + display: none; +} +.manual-detail-stage { display: none; } +.manual-detail-empty { display: none; } +.manual-hub-hint { line-height: 1.4; } + +.chip-btn { + background: rgba(255,255,255,0.85); + border: 1px solid rgba(148,163,184,0.4); + border-radius: 999px; + padding: 0.45rem 0.75rem; + font-weight: 700; + font-size: 0.85rem; + color: #0f172a; + box-shadow: 0 6px 16px rgba(15,23,42,0.08); + transition: transform 0.15s ease, box-shadow 0.15s ease; +} +.chip-btn:active { transform: translateY(1px); } +.chip-btn:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; } + .mobile-tabbar { position: fixed; inset-inline: 0; @@ -631,7 +691,17 @@ body[data-active-tab="#tab-wall"] #clear-canvas-btn-top { #classic-display, #wall-display, #balloon-canvas { - margin-bottom: 5.5rem; + margin-bottom: 0; + height: calc(100vh - 190px) !important; /* tie to viewport minus header/controls */ + max-height: calc(100vh - 190px) !important; + } + /* Keep the main canvas panels above the tabbar/action bar */ + #canvas-panel, + #classic-canvas-panel { + padding-bottom: 12vh; + } + #classic-canvas-panel { + padding-bottom: 14vh; /* leave space for action bar */ } } .mobile-tabbar .mobile-tab-btn { @@ -666,6 +736,39 @@ body[data-active-tab="#tab-wall"] #clear-canvas-btn-top { transform: translateY(-2px); } +.canvas-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: .75rem; + padding: .6rem .85rem; + border-bottom: 1px solid #e5e7eb; + background: linear-gradient(90deg, #f8fafc, #fff); + position: sticky; + top: 0; + z-index: 5; +} +.canvas-toolbar .toolbar-left, +.canvas-toolbar .toolbar-right { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: .5rem; +} +.canvas-toolbar button { white-space: nowrap; } + +.quad-modal{position:fixed;inset:0;z-index:999;display:flex;align-items:center;justify-content:center;pointer-events:none;} +.quad-modal.hidden{display:none;} +.quad-modal:not(.hidden){pointer-events:auto;} +.quad-modal-backdrop{position:absolute;inset:0;background:rgba(15,23,42,0.45);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);} +.quad-modal-panel{position:relative;z-index:1;pointer-events:auto;background:#fff;border-radius:1rem;padding:1rem;box-shadow:0 22px 50px rgba(15,23,42,0.25);width:min(560px,90vw);} +.quad-modal-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:.5rem;} +.quad-modal-title{font-weight:700;font-size:1rem;color:#0f172a;} +.quad-modal-body{border:1px solid #e5e7eb;border-radius:.75rem;overflow:hidden;background:#f8fafc;} +.quad-modal-display{width:100%;height:360px;max-height:70vh;position:relative;background:#fff;perspective:1200px;} +.quad-modal-display svg{transform-style:preserve-3d;transition:transform 240ms ease, opacity 200ms ease;} +.quad-modal-display svg{width:100%;height:100%;} + @media (min-width: 1024px) { .control-sheet { @@ -673,7 +776,7 @@ body[data-active-tab="#tab-wall"] #clear-canvas-btn-top { top: 7rem; bottom: auto; width: 340px; - max-height: calc(100vh - 8rem); + max-height: calc(93vh - 8rem); border-radius: 1.5rem; position: sticky; overflow-y: auto;