memlnaut-nisps/playground/js/ui/eoc-chain-ui.js
w1n5t0n 46e1e59aaf 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.
2026-04-03 17:42:59 +01:00

320 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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;
},
};