diff --git a/demo/index.html b/demo/index.html index c73cd2d..8d6e8c1 100644 --- a/demo/index.html +++ b/demo/index.html @@ -308,6 +308,31 @@ accent-color: var(--accent); } + /* Editor */ + + .editor-grid{ + display: grid; + grid-template-columns: min-content 1fr min-content; + gap: 0.75rem; + + & .field-label { + text-wrap: nowrap; + } + + & .wide { + grid-column: span 2; + } + + & input:not([type=range]) { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + padding: 0 1rem; + width: 100px; + } + } + /* Footer */ footer { text-align: center; @@ -362,6 +387,12 @@

Themes

+ + + + + @@ -570,11 +601,14 @@

Setup

}) // Theme switcher - document.querySelectorAll('.theme-btn').forEach(btn => { + document.querySelectorAll('.theme-btn[data-theme]').forEach(btn => { btn.addEventListener('click', () => { - document.querySelectorAll('.theme-btn').forEach(b => b.classList.remove('active')) + document.querySelectorAll('.theme-btn[data-theme]').forEach(b => b.classList.remove('active')) btn.classList.add('active') - tiks.setTheme(btn.dataset.theme) + if (btn.dataset.theme !== 'custom') { + tiks.setTheme(btn.dataset.theme) + document.getElementById('theme-editor').hidden = true + } else initCustomTheme() tiks.click() triggerWave(0.5, 8) }) @@ -598,6 +632,174 @@

Setup

triggerWave(0.8, 6) setTimeout(() => btn.textContent = 'Copy', 1500) }) + + // Custom theme + const themeTranslations = { + baseFreq: 'Base Frequency', + noiseColor: 'Noise Color', + oscType: 'Oscillator Type', + filterFreq: 'Filter Frequency', + filterQ: 'Filter Resonance', + attack: 'Attack Time', + decay: 'Decay Time', + brightness: 'Brightness', + + white: 'White', + pink: 'Pink', + sine: 'Sine', + triangle: 'Triangle', + square: 'Square', + sawtooth: 'Sawtooth' + } + const propertyRanges = { + baseFreq: [150, 500, 1], + filterFreq: [1500, 6000, 1], + filterQ: [1, 5, 0.01], + attack: [0.00001, 0.005, 0.00001], + decay: [0.3, 1.5, 0.01], + brightness: [1500, 5000, 1] + } + + // Snap to the slider's own step and clamp to its bounds, so the number box + // and the slider thumb can never disagree. toFixed kills the float noise + // that min + n * step introduces (0.00399 -> 0.0039900000000000005). + function snapToRange(value, [min, max, step]) { + const snapped = min + Math.round((value - min) / step) * step + const decimals = (String(step).split('.')[1] ?? '').length + return Number(Math.min(max, Math.max(min, snapped)).toFixed(decimals)) + } + + const randomInRange = property => { + const [min, max] = propertyRanges[property] + return snapToRange(min + Math.random() * (max - min), propertyRanges[property]) + } + const randomOf = values => values[Math.floor(Math.random() * values.length)] + + function initCustomTheme() { + const customThemeData = { + name: 'custom', + baseFreq: randomInRange('baseFreq'), + noiseColor: randomOf(['white', 'pink']), + oscType: randomOf(['sine', 'triangle', 'square', 'sawtooth']), + filterFreq: randomInRange('filterFreq'), + filterQ: randomInRange('filterQ'), + attack: randomInRange('attack'), + decay: randomInRange('decay'), + brightness: randomInRange('brightness') + } + const themeInputs = { + baseFreq: [], + filterFreq: [], + filterQ: [], + attack: [], + decay: [], + brightness: [], + } + // `source` is the control the edit came from — never write back to it, or + // typing in the number box fights the caret. + function updateTheme(property, value, source) { + const range = propertyRanges[property] + if (range) { + const parsed = parseFloat(value) + // Empty or non-numeric input: leave the theme alone. Letting "" or NaN + // through reaches Web Audio and throws on the next sound. + if (!Number.isFinite(parsed)) return + customThemeData[property] = snapToRange(parsed, range) + } else { + customThemeData[property] = value + } + for (const input of themeInputs[property] ?? []) { + if (input !== source) input.value = customThemeData[property] + } + tiks.setTheme(customThemeData) + } + + function createEditorSlider(property) { + const [min, max, step] = propertyRanges[property] + + const label = document.createElement('label') + label.className = 'field-label' + label.htmlFor = `theme-${property}` + label.innerText = themeTranslations[property] + + const input = document.createElement('input') + input.type = 'range' + input.id = `theme-${property}` + input.min = min + input.max = max + input.step = step + input.value = customThemeData[property] + input.addEventListener('input', () => { + updateTheme(property, input.value, input) + }) + + const display = document.createElement('input') + display.type = 'number' + display.min = min + display.max = max + display.step = step + display.setAttribute('aria-label', themeTranslations[property]) + display.value = customThemeData[property] + display.addEventListener('input', () => { + updateTheme(property, display.value, display) + }) + // Typing is left alone as it happens; on commit, show what actually + // landed — including snapping back if the box was cleared or out of range. + display.addEventListener('change', () => { + updateTheme(property, display.value, null) + display.value = customThemeData[property] + }) + + themeInputs[property].push(input, display) + + return [label, input, display] + } + function createButtonList(property, values) { + const label = document.createElement('span') + label.className = 'field-label' + label.id = `theme-${property}-label` + label.innerText = themeTranslations[property] + + const container = document.createElement('span') + container.className = 'theme-switcher wide' + container.setAttribute('role', 'group') + container.setAttribute('aria-labelledby', label.id) + + for (const value of values) { + const button = document.createElement('button') + button.className = 'theme-btn' + button.setAttribute('aria-pressed', 'false') + button.innerText = themeTranslations[value] + const select = () => { + for (const sibling of container.children) { + sibling.className = 'theme-btn' + sibling.setAttribute('aria-pressed', 'false') + } + button.className = 'theme-btn active' + button.setAttribute('aria-pressed', 'true') + } + button.addEventListener('click', () => { + select() + updateTheme(property, value) + }) + if (value === customThemeData[property]) select() + + container.append(button) + } + + return [label, container] + } + + const editorGrid = document.querySelector('.editor-grid') + editorGrid.innerText = '' + + editorGrid.append(...createButtonList('noiseColor', ['white', 'pink'])) + editorGrid.append(...createButtonList('oscType', ['sine', 'triangle', 'square', 'sawtooth'])) + for (const property in themeInputs) editorGrid.append(...createEditorSlider(property)) + + document.getElementById('theme-editor').hidden = false + tiks.setTheme(customThemeData) + }