diff --git a/demo/index.html b/demo/index.html index 8d6e8c1..840bcad 100644 --- a/demo/index.html +++ b/demo/index.html @@ -328,8 +328,27 @@ border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); - padding: 0 1rem; - width: 100px; + padding: 0 0.75rem; + /* Wide enough for the longest value the sliders can produce (0.00402) + plus the number spinner. */ + width: 115px; + } + } + + .theme-export { + position: relative; + margin-top: 1.5rem; + + & .code-block { + margin: 0; + white-space: pre; + } + + & .copy-btn { + position: absolute; + top: 0.75rem; + right: 0.75rem; + background: var(--bg); } } @@ -394,6 +413,10 @@

Themes

@@ -675,131 +698,175 @@

Setup

} 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') + // Randomized once per page load, then owned by the editor — switching to a + // preset and back reopens the same theme rather than rolling a new one. + 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: [], + } + + // The snippet is built as [text, class] segments so the copied text and the + // highlighted markup are generated from one source and can't drift apart. + function themeSnippetSegments() { + const str = value => [`'${value}'`, 'str'] + const segments = [ + ['import ', 'kw'], ['{ tiks, defineTheme }'], [' from ', 'kw'], str('@rexa-developer/tiks'), ['\n\n'], + ['const ', 'kw'], ['myTheme = '], ['defineTheme', 'fn'], ['({\n'] + ] + for (const [property, value] of Object.entries(customThemeData)) { + segments.push([` ${property}: `], typeof value === 'string' ? str(value) : [String(value)], [',\n']) } - const themeInputs = { - baseFreq: [], - filterFreq: [], - filterQ: [], - attack: [], - decay: [], - brightness: [], + segments.push(['})\n\n'], ['tiks', 'fn'], ['.'], ['setTheme', 'fn'], ['(myTheme)']) + return segments + } + + const themeSnippetText = () => themeSnippetSegments().map(([text]) => text).join('') + + function renderThemeSnippet() { + document.getElementById('themeCode').replaceChildren(...themeSnippetSegments().map(([text, className]) => { + if (!className) return document.createTextNode(text) + const span = document.createElement('span') + span.className = className + span.textContent = text + return span + })) + } + + // `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 } - // `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) + for (const input of themeInputs[property] ?? []) { + if (input !== source) input.value = customThemeData[property] } + renderThemeSnippet() + 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) - }) + 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 display = document.createElement('input') - display.type = 'number' - display.min = min - display.max = max - display.step = step - display.setAttribute('aria-label', 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] - 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) + 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() + 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) - container.append(button) + 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() - return [label, container] + container.append(button) } - const editorGrid = document.querySelector('.editor-grid') - editorGrid.innerText = '' + return [label, container] + } - editorGrid.append(...createButtonList('noiseColor', ['white', 'pink'])) - editorGrid.append(...createButtonList('oscType', ['sine', 'triangle', 'square', 'sawtooth'])) - for (const property in themeInputs) editorGrid.append(...createEditorSlider(property)) + let editorBuilt = false + function initCustomTheme() { + if (!editorBuilt) { + const editorGrid = document.querySelector('.editor-grid') + editorGrid.append(...createButtonList('noiseColor', ['white', 'pink'])) + editorGrid.append(...createButtonList('oscType', ['sine', 'triangle', 'square', 'sawtooth'])) + for (const property in themeInputs) editorGrid.append(...createEditorSlider(property)) + renderThemeSnippet() + editorBuilt = true + } document.getElementById('theme-editor').hidden = false tiks.setTheme(customThemeData) } + + document.getElementById('copyThemeBtn').addEventListener('click', () => { + navigator.clipboard.writeText(themeSnippetText()) + const btn = document.getElementById('copyThemeBtn') + btn.textContent = 'Copied!' + tiks.success() + triggerWave(0.8, 6) + setTimeout(() => btn.textContent = 'Copy', 1500) + })