feat(chain-ui): drawer layout + signal-based reactive updates
Replace the inline chain builder with a full-screen slide-up drawer (overlay + glass backdrop + drag handle). Param sliders now subscribe to a vanilla signal (createArraySignal) for O(changed params) updates instead of querySelectorAll sweeps. Card list uses keyed reconciliation.
This commit is contained in:
parent
4240b03c1c
commit
8bb965740e
3 changed files with 460 additions and 42 deletions
|
|
@ -1,16 +1,20 @@
|
|||
/**
|
||||
* ShapeSeq Chain Builder UI — vertical stack of primitive cards
|
||||
* ShapeSeq Chain Builder UI — full-screen slide-up drawer with signal-based updates
|
||||
*
|
||||
* Pedalboard-style chain editor: expandable cards with param sliders,
|
||||
* drag-to-reorder, delete, add-primitive palette, generator combine toggle.
|
||||
* Mobile-first, dark glass theme matching the immersive playground.
|
||||
*
|
||||
* Uses vanilla signals for reactive slider updates at 60fps — O(changed params)
|
||||
* instead of O(all sliders) via querySelectorAll.
|
||||
*
|
||||
* All styles are inline (no external CSS dependency).
|
||||
*
|
||||
* @module shapeseq/chain-ui
|
||||
*/
|
||||
|
||||
import { PRIMITIVE_REGISTRY } from './primitives.js';
|
||||
import { createArraySignal } from './signal.js';
|
||||
|
||||
// ── Category colors ──────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -30,6 +34,98 @@ function injectStyles() {
|
|||
stylesInjected = true;
|
||||
|
||||
const css = `
|
||||
/* ── Chain drawer overlay + slide-up drawer ────────────────────── */
|
||||
|
||||
.chain-drawer-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9998;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.chain-drawer-overlay.open {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.chain-drawer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 85vh;
|
||||
z-index: 9999;
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.3s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
background: rgba(13, 13, 13, 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 16px 16px 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||
font-size: 13px;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.chain-drawer.open {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.chain-drawer-handle {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 10px 0 4px;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.chain-drawer-handle::after {
|
||||
content: '';
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.chain-drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 16px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #ff6a00;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.chain-drawer-close {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: transparent;
|
||||
color: #888;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.chain-drawer-close:hover {
|
||||
border-color: #ff6a00;
|
||||
color: #ff6a00;
|
||||
}
|
||||
|
||||
/* ── Chain Builder container ─────────────────────────────────── */
|
||||
|
||||
.chain-builder {
|
||||
|
|
@ -49,9 +145,8 @@ function injectStyles() {
|
|||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||
font-size: 13px;
|
||||
color: var(--cb-text);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ── Primitive card ──────────────────────────────────────────── */
|
||||
|
|
@ -328,7 +423,7 @@ function injectStyles() {
|
|||
.chain-palette-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
|
|
@ -461,6 +556,15 @@ export class ChainBuilderUI {
|
|||
/** @private @type {Float32Array|null} current param values for display */
|
||||
this._currentParams = null;
|
||||
|
||||
/** @private @type {import('./signal.js').ArraySignal|null} */
|
||||
this._paramSignal = null;
|
||||
|
||||
/** @private @type {Function[]} signal unsubscribers for cleanup */
|
||||
this._signalUnsubs = [];
|
||||
|
||||
/** @private @type {Map<number, HTMLElement>} chainIndex -> card element */
|
||||
this._cardMap = new Map();
|
||||
|
||||
// ── Drag state ──
|
||||
/** @private */
|
||||
this._dragIndex = -1;
|
||||
|
|
@ -477,52 +581,96 @@ export class ChainBuilderUI {
|
|||
/** @private @type {HTMLElement|null} */
|
||||
this._paletteOverlay = null;
|
||||
|
||||
// ── Drawer elements ──
|
||||
/** @private @type {HTMLElement|null} */
|
||||
this._drawerOverlay = null;
|
||||
/** @private @type {HTMLElement|null} */
|
||||
this._drawer = null;
|
||||
/** @private @type {boolean} */
|
||||
this._isOpen = false;
|
||||
|
||||
// ── Bound handlers for cleanup ──
|
||||
this._onPointerMove = this._handlePointerMove.bind(this);
|
||||
this._onPointerUp = this._handlePointerUp.bind(this);
|
||||
|
||||
this._buildDrawer();
|
||||
this.render();
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Rebuild the entire UI from current chain state.
|
||||
* Open the drawer.
|
||||
*/
|
||||
open() {
|
||||
if (this._isOpen) return;
|
||||
this._isOpen = true;
|
||||
if (this._drawerOverlay) this._drawerOverlay.classList.add('open');
|
||||
if (this._drawer) this._drawer.classList.add('open');
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the drawer.
|
||||
*/
|
||||
close() {
|
||||
if (!this._isOpen) return;
|
||||
this._isOpen = false;
|
||||
if (this._drawerOverlay) this._drawerOverlay.classList.remove('open');
|
||||
if (this._drawer) this._drawer.classList.remove('open');
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the card list from current chain state.
|
||||
* Uses keyed reconciliation: only adds/removes/moves the cards that changed.
|
||||
*/
|
||||
render() {
|
||||
this._container.innerHTML = '';
|
||||
this._container.classList.add('chain-builder');
|
||||
|
||||
const primitives = this._chain.getPrimitives();
|
||||
const cardList = this._cardList;
|
||||
if (!cardList) return;
|
||||
|
||||
// Card list wrapper
|
||||
const cardList = document.createElement('div');
|
||||
cardList.style.display = 'flex';
|
||||
cardList.style.flexDirection = 'column';
|
||||
cardList.style.gap = '6px';
|
||||
this._cardList = cardList;
|
||||
// Clean up old signal subscriptions
|
||||
for (const unsub of this._signalUnsubs) unsub();
|
||||
this._signalUnsubs = [];
|
||||
|
||||
// Compute total param count and create signal
|
||||
let totalParams = 0;
|
||||
for (const p of primitives) totalParams += p.paramCount;
|
||||
this._paramSignal = createArraySignal(totalParams);
|
||||
|
||||
// Build desired order: chainIndex list
|
||||
const desiredKeys = primitives.map((_, i) => i);
|
||||
const existingKeys = [...this._cardMap.keys()];
|
||||
|
||||
// Remove cards no longer present
|
||||
for (const key of existingKeys) {
|
||||
if (!desiredKeys.includes(key)) {
|
||||
const el = this._cardMap.get(key);
|
||||
if (el && el.parentElement) el.remove();
|
||||
this._cardMap.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// For a full reconciliation with reindexing, rebuild all cards
|
||||
// (primitive indices change on add/remove/reorder)
|
||||
const oldMap = this._cardMap;
|
||||
this._cardMap = new Map();
|
||||
|
||||
// Remove all existing cards from DOM
|
||||
while (cardList.firstChild) cardList.removeChild(cardList.firstChild);
|
||||
|
||||
// Compute flat param offset for each primitive
|
||||
let flatOffset = 0;
|
||||
for (let i = 0; i < primitives.length; i++) {
|
||||
const prim = primitives[i];
|
||||
const card = this._createCard(prim, i, flatOffset);
|
||||
this._cardMap.set(i, card);
|
||||
cardList.appendChild(card);
|
||||
flatOffset += prim.paramCount;
|
||||
}
|
||||
|
||||
this._container.appendChild(cardList);
|
||||
|
||||
// Add button
|
||||
const addBtn = document.createElement('button');
|
||||
addBtn.className = 'chain-add-btn';
|
||||
addBtn.innerHTML = '<span style="font-size:18px;line-height:1">+</span> Add Primitive';
|
||||
addBtn.addEventListener('click', () => this._openPalette());
|
||||
this._container.appendChild(addBtn);
|
||||
|
||||
// Generator combine mode toggle
|
||||
const toggle = this._createCombineToggle();
|
||||
this._container.appendChild(toggle);
|
||||
// Seed signal with current param values if available
|
||||
if (this._currentParams && this._currentParams.length >= totalParams) {
|
||||
this._paramSignal.update(this._currentParams);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -531,8 +679,8 @@ export class ChainBuilderUI {
|
|||
*/
|
||||
setLiveParams(paramIndices) {
|
||||
this._liveParams = new Set(paramIndices);
|
||||
// Update existing rows without full re-render
|
||||
const rows = this._container.querySelectorAll('.chain-param-row');
|
||||
if (!this._cardList) return;
|
||||
const rows = this._cardList.querySelectorAll('.chain-param-row');
|
||||
rows.forEach((row) => {
|
||||
const idx = parseInt(row.dataset.flatIndex, 10);
|
||||
if (isNaN(idx)) return;
|
||||
|
|
@ -554,34 +702,112 @@ export class ChainBuilderUI {
|
|||
|
||||
/**
|
||||
* Update displayed param values without re-rendering.
|
||||
* Backed by signal — only subscribers for changed indices are notified.
|
||||
* @param {Float32Array|Array<number>} params - flat param array
|
||||
*/
|
||||
updateParamValues(params) {
|
||||
this._currentParams = params;
|
||||
const sliders = this._container.querySelectorAll('.chain-param-slider');
|
||||
sliders.forEach((slider) => {
|
||||
const idx = parseInt(slider.dataset.flatIndex, 10);
|
||||
if (!isNaN(idx) && idx < params.length) {
|
||||
slider.value = params[idx];
|
||||
// Update adjacent value display
|
||||
const valEl = slider.parentElement && slider.parentElement.querySelector('.chain-param-value');
|
||||
if (valEl) {
|
||||
valEl.textContent = params[idx].toFixed(2);
|
||||
if (this._paramSignal) {
|
||||
this._paramSignal.update(params);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup all DOM and listeners.
|
||||
*/
|
||||
destroy() {
|
||||
this._closePalette();
|
||||
this.close();
|
||||
document.removeEventListener('pointermove', this._onPointerMove);
|
||||
document.removeEventListener('pointerup', this._onPointerUp);
|
||||
|
||||
// Clean up signal subscriptions
|
||||
for (const unsub of this._signalUnsubs) unsub();
|
||||
this._signalUnsubs = [];
|
||||
|
||||
// Remove drawer from DOM
|
||||
if (this._drawerOverlay) {
|
||||
this._drawerOverlay.remove();
|
||||
this._drawerOverlay = null;
|
||||
}
|
||||
if (this._drawer) {
|
||||
this._drawer.remove();
|
||||
this._drawer = null;
|
||||
}
|
||||
|
||||
this._container.innerHTML = '';
|
||||
this._container.classList.remove('chain-builder');
|
||||
this._paramChangeCb = null;
|
||||
this._cardMap.clear();
|
||||
}
|
||||
|
||||
// ── Drawer construction ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Build the drawer shell (overlay + drawer container + header).
|
||||
* The card list and controls go inside the drawer body.
|
||||
*/
|
||||
_buildDrawer() {
|
||||
// Overlay
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'chain-drawer-overlay';
|
||||
overlay.addEventListener('click', () => this.close());
|
||||
this._drawerOverlay = overlay;
|
||||
|
||||
// Drawer
|
||||
const drawer = document.createElement('div');
|
||||
drawer.className = 'chain-drawer';
|
||||
this._drawer = drawer;
|
||||
|
||||
// Drag handle
|
||||
const handle = document.createElement('div');
|
||||
handle.className = 'chain-drawer-handle';
|
||||
drawer.appendChild(handle);
|
||||
|
||||
// Header
|
||||
const header = document.createElement('div');
|
||||
header.className = 'chain-drawer-header';
|
||||
|
||||
const title = document.createElement('span');
|
||||
title.textContent = 'Chain Builder';
|
||||
header.appendChild(title);
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'chain-drawer-close';
|
||||
closeBtn.textContent = '\u00D7';
|
||||
closeBtn.addEventListener('click', () => this.close());
|
||||
header.appendChild(closeBtn);
|
||||
|
||||
drawer.appendChild(header);
|
||||
|
||||
// Body (scrollable card list + controls)
|
||||
const body = document.createElement('div');
|
||||
body.classList.add('chain-builder');
|
||||
|
||||
const cardList = document.createElement('div');
|
||||
cardList.style.display = 'flex';
|
||||
cardList.style.flexDirection = 'column';
|
||||
cardList.style.gap = '6px';
|
||||
this._cardList = cardList;
|
||||
body.appendChild(cardList);
|
||||
|
||||
// Add button
|
||||
const addBtn = document.createElement('button');
|
||||
addBtn.className = 'chain-add-btn';
|
||||
addBtn.innerHTML = '<span style="font-size:18px;line-height:1">+</span> Add Primitive';
|
||||
addBtn.addEventListener('click', () => this._openPalette());
|
||||
body.appendChild(addBtn);
|
||||
|
||||
// Generator combine mode toggle
|
||||
this._combineToggle = this._createCombineToggle();
|
||||
body.appendChild(this._combineToggle);
|
||||
|
||||
drawer.appendChild(body);
|
||||
|
||||
// Mount to document body
|
||||
document.body.appendChild(overlay);
|
||||
document.body.appendChild(drawer);
|
||||
}
|
||||
|
||||
// ── Card creation ──────────────────────────────────────────────────
|
||||
|
|
@ -681,6 +907,14 @@ export class ChainBuilderUI {
|
|||
body.appendChild(paramsDiv);
|
||||
card.appendChild(body);
|
||||
|
||||
// Subscribe summary to signal updates
|
||||
if (this._paramSignal) {
|
||||
const unsub = this._paramSignal.subscribe(() => {
|
||||
summary.textContent = this._buildSummary(prim, flatOffset);
|
||||
});
|
||||
this._signalUnsubs.push(unsub);
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
|
|
@ -702,6 +936,8 @@ export class ChainBuilderUI {
|
|||
|
||||
/**
|
||||
* @private
|
||||
* Creates a param row with a slider that subscribes to the param signal
|
||||
* for its specific flat index — O(1) updates per changed param.
|
||||
*/
|
||||
_createParamRow(schema, flatIdx, prim) {
|
||||
const row = document.createElement('div');
|
||||
|
|
@ -744,6 +980,18 @@ export class ChainBuilderUI {
|
|||
row.appendChild(slider);
|
||||
row.appendChild(valDisplay);
|
||||
|
||||
// Subscribe to signal for reactive updates (only this slider's index)
|
||||
if (this._paramSignal) {
|
||||
const unsub = this._paramSignal.subscribe((params) => {
|
||||
if (flatIdx < params.length) {
|
||||
const newVal = params[flatIdx];
|
||||
slider.value = newVal;
|
||||
valDisplay.textContent = newVal.toFixed(2);
|
||||
}
|
||||
});
|
||||
this._signalUnsubs.push(unsub);
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
|
|
|
|||
54
playground/js/shapeseq/signal.js
Normal file
54
playground/js/shapeseq/signal.js
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/**
|
||||
* Minimal signal/observable for reactive UI updates.
|
||||
* No framework dependency. ~40 lines.
|
||||
*
|
||||
* @module shapeseq/signal
|
||||
*/
|
||||
|
||||
export function createSignal(initialValue) {
|
||||
let value = initialValue;
|
||||
const subscribers = new Set();
|
||||
|
||||
function get() { return value; }
|
||||
|
||||
function set(newValue) {
|
||||
if (newValue === value) return;
|
||||
value = newValue;
|
||||
for (const fn of subscribers) fn(value);
|
||||
}
|
||||
|
||||
function subscribe(fn) {
|
||||
subscribers.add(fn);
|
||||
return () => subscribers.delete(fn);
|
||||
}
|
||||
|
||||
return { get, set, subscribe };
|
||||
}
|
||||
|
||||
/**
|
||||
* Signal backed by a Float32Array for param vectors.
|
||||
* Subscribers are called when any element changes.
|
||||
*/
|
||||
export function createArraySignal(length) {
|
||||
const value = new Float32Array(length);
|
||||
const subscribers = new Set();
|
||||
|
||||
function get() { return value; }
|
||||
|
||||
function update(newArray) {
|
||||
let changed = false;
|
||||
for (let i = 0; i < value.length && i < newArray.length; i++) {
|
||||
if (value[i] !== newArray[i]) { value[i] = newArray[i]; changed = true; }
|
||||
}
|
||||
if (changed) {
|
||||
for (const fn of subscribers) fn(value);
|
||||
}
|
||||
}
|
||||
|
||||
function subscribe(fn) {
|
||||
subscribers.add(fn);
|
||||
return () => subscribers.delete(fn);
|
||||
}
|
||||
|
||||
return { get, update, subscribe };
|
||||
}
|
||||
116
playground/js/shapeseq/tests/signal.test.js
Normal file
116
playground/js/shapeseq/tests/signal.test.js
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { describe, it } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { createSignal, createArraySignal } from '../signal.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createSignal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('createSignal', () => {
|
||||
it('get returns the initial value', () => {
|
||||
const s = createSignal(42);
|
||||
assert.strictEqual(s.get(), 42);
|
||||
});
|
||||
|
||||
it('set updates the value and notifies subscribers', () => {
|
||||
const s = createSignal(0);
|
||||
const calls = [];
|
||||
s.subscribe(v => calls.push(v));
|
||||
s.set(10);
|
||||
assert.strictEqual(s.get(), 10);
|
||||
assert.deepStrictEqual(calls, [10]);
|
||||
});
|
||||
|
||||
it('does not notify on same value', () => {
|
||||
const s = createSignal('hello');
|
||||
const calls = [];
|
||||
s.subscribe(v => calls.push(v));
|
||||
s.set('hello');
|
||||
assert.deepStrictEqual(calls, []);
|
||||
assert.strictEqual(s.get(), 'hello');
|
||||
});
|
||||
|
||||
it('unsubscribe works', () => {
|
||||
const s = createSignal(0);
|
||||
const calls = [];
|
||||
const unsub = s.subscribe(v => calls.push(v));
|
||||
s.set(1);
|
||||
unsub();
|
||||
s.set(2);
|
||||
assert.deepStrictEqual(calls, [1]);
|
||||
});
|
||||
|
||||
it('multiple subscribers each get notified', () => {
|
||||
const s = createSignal(0);
|
||||
const a = [], b = [];
|
||||
s.subscribe(v => a.push(v));
|
||||
s.subscribe(v => b.push(v));
|
||||
s.set(5);
|
||||
assert.deepStrictEqual(a, [5]);
|
||||
assert.deepStrictEqual(b, [5]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createArraySignal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('createArraySignal', () => {
|
||||
it('initial value is all zeros', () => {
|
||||
const s = createArraySignal(4);
|
||||
const v = s.get();
|
||||
assert.strictEqual(v.length, 4);
|
||||
for (let i = 0; i < 4; i++) assert.strictEqual(v[i], 0);
|
||||
});
|
||||
|
||||
it('update triggers only on actual changes', () => {
|
||||
const s = createArraySignal(3);
|
||||
const calls = [];
|
||||
s.subscribe(() => calls.push('changed'));
|
||||
|
||||
// Same values (all zero) should not trigger
|
||||
s.update(new Float32Array([0, 0, 0]));
|
||||
assert.strictEqual(calls.length, 0);
|
||||
|
||||
// Different values should trigger
|
||||
s.update(new Float32Array([0.5, 0, 0]));
|
||||
assert.strictEqual(calls.length, 1);
|
||||
});
|
||||
|
||||
it('partial updates work (shorter array)', () => {
|
||||
const s = createArraySignal(4);
|
||||
s.update(new Float32Array([1, 2, 3, 4]));
|
||||
assert.strictEqual(s.get()[3], 4);
|
||||
|
||||
// Partial update: only first 2 elements
|
||||
const calls = [];
|
||||
s.subscribe(() => calls.push('changed'));
|
||||
s.update(new Float32Array([10, 20]));
|
||||
|
||||
// First two updated, last two unchanged
|
||||
assert.strictEqual(s.get()[0], 10);
|
||||
assert.strictEqual(s.get()[1], 20);
|
||||
assert.strictEqual(s.get()[2], 3);
|
||||
assert.strictEqual(s.get()[3], 4);
|
||||
assert.strictEqual(calls.length, 1);
|
||||
});
|
||||
|
||||
it('subscriber receives the backing array', () => {
|
||||
const s = createArraySignal(2);
|
||||
let received = null;
|
||||
s.subscribe(v => { received = v; });
|
||||
s.update(new Float32Array([0.1, 0.2]));
|
||||
assert.ok(received instanceof Float32Array);
|
||||
assert.strictEqual(received[0], Float32Array.of(0.1)[0]);
|
||||
});
|
||||
|
||||
it('unsubscribe works', () => {
|
||||
const s = createArraySignal(2);
|
||||
const calls = [];
|
||||
const unsub = s.subscribe(() => calls.push('x'));
|
||||
s.update(new Float32Array([1, 0]));
|
||||
unsub();
|
||||
s.update(new Float32Array([2, 0]));
|
||||
assert.strictEqual(calls.length, 1);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue