feat(playground): EOC chain drawer UI with drag-and-drop + bypass toggles (meml-u6x)
Adds eoc-chain-ui.js with NISPS mode selector, draggable module rows, bypass pill toggles, remove buttons, param count badges, add-module bar, and stub moduleFactory. Wires into a-immersive.html dock + drawer-stack and a-app.js init.
This commit is contained in:
parent
67d2230ba8
commit
46e1e59aaf
4 changed files with 545 additions and 0 deletions
|
|
@ -150,6 +150,12 @@
|
|||
</svg>
|
||||
<span class="dock-label">Engine</span>
|
||||
</button>
|
||||
<button class="dock-icon" data-drawer="eoc" title="Effects Chain" aria-label="Effects Chain">
|
||||
<svg viewBox="0 0 16 16" width="20" height="20" fill="currentColor">
|
||||
<path d="M4 8a4 4 0 0 1 4-4h1a1 1 0 0 0 0-2H8a6 6 0 0 0 0 12h1a1 1 0 0 0 0-2H8a4 4 0 0 1-4-4zm8 0a4 4 0 0 1-4 4H7a1 1 0 0 0 0 2h1a6 6 0 0 0 0-12H7a1 1 0 0 0 0 2h1a4 4 0 0 1 4 4z"/>
|
||||
</svg>
|
||||
<span class="dock-label">FX</span>
|
||||
</button>
|
||||
<button class="dock-icon" data-drawer="help" title="Help">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="8" cy="8" r="6"/><path d="M6 6a2 2 0 0 1 4 0c0 1.5-2 1.5-2 3"/><circle cx="8" cy="12" r="0.5" fill="currentColor"/>
|
||||
|
|
@ -307,6 +313,17 @@
|
|||
<div class="engine-params" id="engine-params"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- EOC Effects Chain drawer -->
|
||||
<div class="drawer hidden" id="drawer-eoc" data-drawer="eoc">
|
||||
<div class="drawer-header">
|
||||
<span>Effects Chain</span>
|
||||
<button class="drawer-close" data-drawer="eoc">×</button>
|
||||
</div>
|
||||
<div class="drawer-body" id="eoc-drawer-body">
|
||||
<!-- EOCChainUI renders here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Help modal (unchanged) -->
|
||||
|
|
|
|||
|
|
@ -2021,3 +2021,201 @@ html, body {
|
|||
min-width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ---- EOC Chain UI ---- */
|
||||
|
||||
.eoc-nisps-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.eoc-section-label {
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
min-width: 36px;
|
||||
}
|
||||
|
||||
.eoc-nisps-toggle {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.eoc-module-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.eoc-empty-hint {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
padding: 10px 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.eoc-module-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 6px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
cursor: default;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.eoc-module-row:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.eoc-module-row.dragging {
|
||||
opacity: 0.4;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.eoc-drag-handle {
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
color: var(--text-dim);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
padding: 0 2px;
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.eoc-drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.eoc-module-name {
|
||||
flex: 1;
|
||||
font-size: 11px;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.eoc-param-badge {
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
padding: 1px 6px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Bypass pill toggle */
|
||||
.eoc-bypass-toggle {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 30px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.eoc-bypass-toggle input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.eoc-bypass-slider {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.eoc-bypass-slider::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-dim);
|
||||
transition: transform 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.eoc-bypass-toggle input:checked + .eoc-bypass-slider {
|
||||
background: rgba(255, 106, 0, 0.25);
|
||||
border-color: rgba(255, 106, 0, 0.4);
|
||||
}
|
||||
|
||||
.eoc-bypass-toggle input:checked + .eoc-bypass-slider::after {
|
||||
transform: translateY(-50%) translateX(14px);
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.eoc-remove-btn {
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-dim);
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.eoc-remove-btn:hover {
|
||||
color: var(--danger);
|
||||
border-color: rgba(255, 68, 102, 0.3);
|
||||
background: rgba(255, 68, 102, 0.08);
|
||||
}
|
||||
|
||||
/* Drop indicator line between rows during drag */
|
||||
.eoc-drop-indicator {
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
background: var(--accent);
|
||||
margin: 2px 0;
|
||||
pointer-events: none;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Add module bar */
|
||||
.eoc-add-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.eoc-add-hint {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
opacity: 0.5;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.eoc-add-btn {
|
||||
font-size: 10px;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import { GamepadInput } from './ui/gamepad.js';
|
|||
import { HandTracker } from './ui/hand-tracker.js';
|
||||
import { createDevPanel } from './ui/dev-panel.js';
|
||||
import { SYNTH_PRESETS, PRESET_TIERS } from './synth/presets.js';
|
||||
import { EOCChain } from './eoc/index.js';
|
||||
import { EOCChainUI, moduleFactory } from './ui/eoc-chain-ui.js';
|
||||
|
||||
// ---- Constants ----
|
||||
const N_JOY_INPUTS = 2;
|
||||
|
|
@ -898,6 +900,13 @@ async function init() {
|
|||
// Auto-save every 10 seconds
|
||||
setInterval(saveState, 10000);
|
||||
|
||||
// EOC Effects Chain — initialise chain and wire drawer UI
|
||||
const eocChain = new EOCChain();
|
||||
const eocDrawerBody = document.getElementById('eoc-drawer-body');
|
||||
if (eocDrawerBody) {
|
||||
EOCChainUI.init(eocChain, eocDrawerBody);
|
||||
}
|
||||
|
||||
// Debug probe — exposed on window when ?debug=1 is in the URL.
|
||||
// Used by Playwright e2e tests. Zero footprint in production.
|
||||
if (new URLSearchParams(window.location.search).has('debug')) {
|
||||
|
|
@ -946,6 +955,7 @@ async function init() {
|
|||
evalLoss: () => iml.evalLoss(),
|
||||
inferBatch: (points) => iml.inferBatch(points),
|
||||
getLayerStats:() => iml.getLayerStats(),
|
||||
eocChain,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
320
playground/js/ui/eoc-chain-ui.js
Normal file
320
playground/js/ui/eoc-chain-ui.js
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
// eoc-chain-ui.js — Drawer UI for the End-of-Chain effects rack.
|
||||
//
|
||||
// Usage:
|
||||
// import { EOCChainUI, moduleFactory } from './ui/eoc-chain-ui.js';
|
||||
// EOCChainUI.init(chain, document.getElementById('eoc-drawer-body'));
|
||||
//
|
||||
// The drawer renders:
|
||||
// 1. NISPS mode selector (Bypass / Shared / Linked / Independent)
|
||||
// 2. Ordered, draggable list of active modules (bypass toggle, remove, param count)
|
||||
// 3. Add-module bar (one button per available but absent module)
|
||||
//
|
||||
// Reacts to 'eoc:change' events on window to stay in sync with external mutations.
|
||||
|
||||
import { EOCModule } from '../eoc/eoc-module.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stub module factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const STUB_DEFS = {
|
||||
saturation: { displayName: 'Saturation', paramMeta: [] },
|
||||
eq: { displayName: 'EQ', paramMeta: [] },
|
||||
compressor: { displayName: 'Compressor', paramMeta: [] },
|
||||
reverb: { displayName: 'Reverb', paramMeta: [] },
|
||||
delay: { displayName: 'Delay', paramMeta: [] },
|
||||
master: { displayName: 'Master Bus', paramMeta: [] },
|
||||
};
|
||||
|
||||
// Canonical slot order (matches EOCChain's DEFAULT_ORDER)
|
||||
const MODULE_ORDER = ['saturation', 'eq', 'compressor', 'reverb', 'delay', 'master'];
|
||||
|
||||
/**
|
||||
* Build a minimal EOCModule stub for the given id.
|
||||
* Exported so a-app.js can replace it with real implementations later.
|
||||
*
|
||||
* @param {string} id — one of saturation | eq | compressor | reverb | delay | master
|
||||
* @returns {EOCModule}
|
||||
*/
|
||||
export function moduleFactory(id) {
|
||||
const def = STUB_DEFS[id];
|
||||
if (!def) throw new Error(`moduleFactory: unknown module id '${id}'`);
|
||||
|
||||
// Build stub class inline, scoped to the specific id/def
|
||||
class StubModule extends EOCModule {
|
||||
get id() { return id; }
|
||||
get displayName() { return def.displayName; }
|
||||
get paramMeta() { return def.paramMeta; }
|
||||
|
||||
async init(audioCtx) {
|
||||
// Minimal super call to satisfy the base class contract without
|
||||
// requiring a real AudioContext in stub/test scenarios
|
||||
if (audioCtx && typeof audioCtx.createGain === 'function') {
|
||||
await super.init(audioCtx);
|
||||
this._finishInit();
|
||||
} else {
|
||||
this._initialized = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new StubModule();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EOCChainUI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const EOCChainUI = {
|
||||
/**
|
||||
* @type {import('../eoc/eoc-chain.js').EOCChain|null}
|
||||
*/
|
||||
_chain: null,
|
||||
/** @type {HTMLElement|null} */
|
||||
_container: null,
|
||||
/** Dragged module id during HTML5 DnD */
|
||||
_dragId: null,
|
||||
|
||||
/**
|
||||
* Initialise the UI.
|
||||
*
|
||||
* @param {import('../eoc/eoc-chain.js').EOCChain} chain
|
||||
* @param {HTMLElement} containerEl
|
||||
*/
|
||||
init(chain, containerEl) {
|
||||
this._chain = chain;
|
||||
this._container = containerEl;
|
||||
|
||||
// Listen for external chain changes
|
||||
window.addEventListener('eoc:change', () => this._render());
|
||||
|
||||
this._render();
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
_render() {
|
||||
if (!this._chain || !this._container) return;
|
||||
|
||||
const c = this._container;
|
||||
c.innerHTML = '';
|
||||
|
||||
c.appendChild(this._buildNispsBar());
|
||||
c.appendChild(this._buildModuleList());
|
||||
c.appendChild(this._buildAddBar());
|
||||
},
|
||||
|
||||
// NISPS mode selector bar
|
||||
_buildNispsBar() {
|
||||
const modes = [
|
||||
{ id: 'bypass', label: 'Bypass' },
|
||||
{ id: 'shared', label: 'Shared' },
|
||||
{ id: 'linked', label: 'Linked' },
|
||||
{ id: 'independent', label: 'Indep' },
|
||||
];
|
||||
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'eoc-nisps-bar';
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'eoc-section-label';
|
||||
label.textContent = 'NISPS';
|
||||
bar.appendChild(label);
|
||||
|
||||
const toggle = document.createElement('div');
|
||||
toggle.className = 'pill-toggle pill-toggle-sm eoc-nisps-toggle';
|
||||
|
||||
modes.forEach(({ id, label: lbl }) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'pill-opt' + (this._chain.nispsMode === id ? ' active' : '');
|
||||
btn.textContent = lbl;
|
||||
btn.addEventListener('click', () => {
|
||||
this._chain.nispsMode = id;
|
||||
// re-render is triggered by eoc:change event from chain
|
||||
});
|
||||
toggle.appendChild(btn);
|
||||
});
|
||||
|
||||
bar.appendChild(toggle);
|
||||
return bar;
|
||||
},
|
||||
|
||||
// Ordered list of module rows
|
||||
_buildModuleList() {
|
||||
const list = document.createElement('div');
|
||||
list.className = 'eoc-module-list';
|
||||
|
||||
const modules = this._chain.modules;
|
||||
|
||||
if (modules.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'eoc-empty-hint';
|
||||
empty.textContent = 'No modules — add one below';
|
||||
list.appendChild(empty);
|
||||
return list;
|
||||
}
|
||||
|
||||
modules.forEach((mod, idx) => {
|
||||
const row = this._buildModuleRow(mod, idx, modules.length, list);
|
||||
list.appendChild(row);
|
||||
});
|
||||
|
||||
return list;
|
||||
},
|
||||
|
||||
_buildModuleRow(mod, idx, totalCount, listEl) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'eoc-module-row';
|
||||
row.dataset.id = mod.id;
|
||||
row.draggable = true;
|
||||
|
||||
// --- Drag handle ---
|
||||
const handle = document.createElement('span');
|
||||
handle.className = 'eoc-drag-handle';
|
||||
handle.title = 'Drag to reorder';
|
||||
handle.textContent = '⠿';
|
||||
row.appendChild(handle);
|
||||
|
||||
// --- Module name ---
|
||||
const name = document.createElement('span');
|
||||
name.className = 'eoc-module-name';
|
||||
name.textContent = mod.displayName;
|
||||
row.appendChild(name);
|
||||
|
||||
// --- Param count badge ---
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'eoc-param-badge';
|
||||
badge.textContent = mod.paramCount === 1
|
||||
? '1 param'
|
||||
: `${mod.paramCount} params`;
|
||||
row.appendChild(badge);
|
||||
|
||||
// --- Bypass toggle ---
|
||||
const toggleLabel = document.createElement('label');
|
||||
toggleLabel.className = 'eoc-bypass-toggle';
|
||||
toggleLabel.title = mod.enabled ? 'Active — click to bypass' : 'Bypassed — click to enable';
|
||||
|
||||
const toggleInput = document.createElement('input');
|
||||
toggleInput.type = 'checkbox';
|
||||
toggleInput.checked = mod.enabled;
|
||||
toggleInput.addEventListener('change', () => {
|
||||
mod.enabled = toggleInput.checked;
|
||||
// _applyBypass wires audio graph internally; dispatch change so UI refreshes
|
||||
window.dispatchEvent(new CustomEvent('eoc:change', {
|
||||
detail: { reason: 'bypass-toggled', chain: this._chain }
|
||||
}));
|
||||
});
|
||||
|
||||
const toggleSlider = document.createElement('span');
|
||||
toggleSlider.className = 'eoc-bypass-slider';
|
||||
|
||||
toggleLabel.appendChild(toggleInput);
|
||||
toggleLabel.appendChild(toggleSlider);
|
||||
row.appendChild(toggleLabel);
|
||||
|
||||
// --- Remove button ---
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'eoc-remove-btn';
|
||||
removeBtn.title = `Remove ${mod.displayName}`;
|
||||
removeBtn.textContent = '×';
|
||||
removeBtn.addEventListener('click', () => {
|
||||
this._chain.removeModule(mod.id);
|
||||
});
|
||||
row.appendChild(removeBtn);
|
||||
|
||||
// --- HTML5 Drag-and-drop ---
|
||||
row.addEventListener('dragstart', (e) => {
|
||||
this._dragId = mod.id;
|
||||
row.classList.add('dragging');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', mod.id);
|
||||
});
|
||||
|
||||
row.addEventListener('dragend', () => {
|
||||
this._dragId = null;
|
||||
row.classList.remove('dragging');
|
||||
// Clean up all drop indicators
|
||||
listEl.querySelectorAll('.eoc-drop-indicator').forEach(el => el.remove());
|
||||
});
|
||||
|
||||
row.addEventListener('dragover', (e) => {
|
||||
if (!this._dragId || this._dragId === mod.id) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
|
||||
// Show drop-indicator above or below based on cursor position
|
||||
const rect = row.getBoundingClientRect();
|
||||
const midY = rect.top + rect.height / 2;
|
||||
const insertBefore = e.clientY < midY;
|
||||
|
||||
// Remove all existing indicators then place one
|
||||
listEl.querySelectorAll('.eoc-drop-indicator').forEach(el => el.remove());
|
||||
const indicator = document.createElement('div');
|
||||
indicator.className = 'eoc-drop-indicator';
|
||||
if (insertBefore) {
|
||||
listEl.insertBefore(indicator, row);
|
||||
} else {
|
||||
row.insertAdjacentElement('afterend', indicator);
|
||||
}
|
||||
});
|
||||
|
||||
row.addEventListener('drop', (e) => {
|
||||
if (!this._dragId || this._dragId === mod.id) return;
|
||||
e.preventDefault();
|
||||
|
||||
// Determine new position
|
||||
const rect = row.getBoundingClientRect();
|
||||
const midY = rect.top + rect.height / 2;
|
||||
const insertBefore = e.clientY < midY;
|
||||
|
||||
const allRows = [...listEl.querySelectorAll('.eoc-module-row')];
|
||||
const targetIdx = allRows.findIndex(r => r.dataset.id === mod.id);
|
||||
// After moving the dragged element out, the target index needs adjustment
|
||||
const dragIdx = allRows.findIndex(r => r.dataset.id === this._dragId);
|
||||
let newPos = insertBefore ? targetIdx : targetIdx + 1;
|
||||
if (dragIdx < targetIdx) newPos -= 1; // account for removal shifting indices
|
||||
|
||||
this._chain.moveModule(this._dragId, Math.max(0, newPos));
|
||||
// eoc:change will re-render
|
||||
});
|
||||
|
||||
return row;
|
||||
},
|
||||
|
||||
// Add-module bar
|
||||
_buildAddBar() {
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'eoc-add-bar';
|
||||
|
||||
const activeIds = new Set(this._chain.modules.map(m => m.id));
|
||||
|
||||
const available = MODULE_ORDER.filter(id => !activeIds.has(id));
|
||||
|
||||
if (available.length === 0) {
|
||||
const hint = document.createElement('span');
|
||||
hint.className = 'eoc-add-hint';
|
||||
hint.textContent = 'All modules added';
|
||||
bar.appendChild(hint);
|
||||
return bar;
|
||||
}
|
||||
|
||||
available.forEach(id => {
|
||||
const def = STUB_DEFS[id];
|
||||
if (!def) return;
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'action-btn eoc-add-btn';
|
||||
btn.textContent = `+ ${def.displayName}`;
|
||||
btn.title = `Add ${def.displayName} to chain`;
|
||||
btn.addEventListener('click', () => {
|
||||
this._chain.addModule(moduleFactory(id));
|
||||
// eoc:change re-renders
|
||||
});
|
||||
bar.appendChild(btn);
|
||||
});
|
||||
|
||||
return bar;
|
||||
},
|
||||
};
|
||||
Loading…
Reference in a new issue