@m4l-jweb/surface 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/index.ts +78 -3
- package/src/react.tsx +44 -2
- package/src/store.ts +87 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m4l-jweb/surface",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "m4l-jweb: declare a device's Live parameters as code - the surface Push actually sees - plus a mocked-Live dev harness.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
],
|
|
25
25
|
"peerDependencies": {
|
|
26
26
|
"react": ">=18",
|
|
27
|
-
"@m4l-jweb/bridge": "0.
|
|
27
|
+
"@m4l-jweb/bridge": "0.6.0"
|
|
28
28
|
},
|
|
29
29
|
"peerDependenciesMeta": {
|
|
30
30
|
"react": {
|
package/src/index.ts
CHANGED
|
@@ -97,6 +97,61 @@ export type ParamSpec = DialSpec | ToggleSpec | MenuSpec;
|
|
|
97
97
|
/** The value type a given parameter carries. `useParam` will be typed by this. */
|
|
98
98
|
export type ParamValue<P extends ParamSpec> = P extends DialSpec ? number : P extends ToggleSpec ? boolean : P extends MenuSpec<infer O> ? O : never;
|
|
99
99
|
|
|
100
|
+
/* ------------------------------------------------------------------ *
|
|
101
|
+
* Windows and State
|
|
102
|
+
* ------------------------------------------------------------------ */
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A floating window: a SECOND page, in a window of its own.
|
|
106
|
+
*
|
|
107
|
+
* The device view in Live is a fixed ~169 px tall and does not scroll, so a UI
|
|
108
|
+
* that needs room (a pattern editor, a waveform) has nowhere to grow inside it.
|
|
109
|
+
* A declared window compiles to a subpatcher holding its own [jweb], and
|
|
110
|
+
* `useWindow()` opens it.
|
|
111
|
+
*
|
|
112
|
+
* `entry` names the component to bundle, from the device's own folder -
|
|
113
|
+
* `entry: "Window"` is `src/app/<device>/Window.tsx`. It is a separate BUNDLE, so
|
|
114
|
+
* it shares no React state with the device view: two pages, two Chromium
|
|
115
|
+
* contexts, talking only through Max.
|
|
116
|
+
*/
|
|
117
|
+
export interface WindowSpec {
|
|
118
|
+
kind: "window";
|
|
119
|
+
title: string;
|
|
120
|
+
width: number;
|
|
121
|
+
height: number;
|
|
122
|
+
entry: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* A slot of JSON that survives a save - persisted in the LIVE SET, per instance.
|
|
127
|
+
*
|
|
128
|
+
* Not a parameter: Live never looks inside it, it does not automate, and it is not
|
|
129
|
+
* on Push. That is exactly what makes it the right home for the things a parameter
|
|
130
|
+
* cannot hold - a pattern, a preset, a grid of steps.
|
|
131
|
+
*/
|
|
132
|
+
export interface StateSpec<T = unknown> {
|
|
133
|
+
kind: "state";
|
|
134
|
+
default: T;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The value type a state slot carries, so `useStateSync` is typed from the declaration. */
|
|
138
|
+
export type StateValue<S extends StateSpec> = S extends StateSpec<infer T> ? T : never;
|
|
139
|
+
|
|
140
|
+
export const window = (spec: Omit<WindowSpec, "kind">): WindowSpec => ({
|
|
141
|
+
kind: "window",
|
|
142
|
+
...spec,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The default is the TYPE. `state({ default: { voices: 4 } })` makes the slot
|
|
147
|
+
* `{ voices: number }` everywhere - in `useStateSync`, and in the setter that
|
|
148
|
+
* writes it back - with no type argument to pass and no `as` to remember.
|
|
149
|
+
*/
|
|
150
|
+
export const state = <T>(spec: Omit<StateSpec<T>, "kind">): StateSpec<T> => ({
|
|
151
|
+
kind: "state",
|
|
152
|
+
...spec,
|
|
153
|
+
});
|
|
154
|
+
|
|
100
155
|
export const dial = (spec: Omit<DialSpec, "kind">): DialSpec => ({
|
|
101
156
|
kind: "dial",
|
|
102
157
|
...spec,
|
|
@@ -129,12 +184,28 @@ export interface Bank<K extends string> {
|
|
|
129
184
|
params: readonly K[];
|
|
130
185
|
}
|
|
131
186
|
|
|
132
|
-
|
|
187
|
+
/**
|
|
188
|
+
* The declaration. `P`, `S` and `W` are inferred from what you write - they exist so
|
|
189
|
+
* that `useParam`, `useStateSync` and `useWindow` are typed against THIS surface: a
|
|
190
|
+
* parameter, slot or window that is not declared here is a build error at the call
|
|
191
|
+
* site, not a control that silently does nothing.
|
|
192
|
+
*/
|
|
193
|
+
export interface SurfaceDef<
|
|
194
|
+
P extends Record<string, ParamSpec>,
|
|
195
|
+
S extends Record<string, StateSpec> = Record<string, StateSpec>,
|
|
196
|
+
W extends Record<string, WindowSpec> = Record<string, WindowSpec>,
|
|
197
|
+
> {
|
|
133
198
|
params: P;
|
|
134
199
|
banks?: readonly Bank<Extract<keyof P, string>>[];
|
|
200
|
+
windows?: W;
|
|
201
|
+
state?: S;
|
|
135
202
|
}
|
|
136
203
|
|
|
137
|
-
export interface Surface<
|
|
204
|
+
export interface Surface<
|
|
205
|
+
P extends Record<string, ParamSpec> = Record<string, ParamSpec>,
|
|
206
|
+
S extends Record<string, StateSpec> = Record<string, StateSpec>,
|
|
207
|
+
W extends Record<string, WindowSpec> = Record<string, WindowSpec>,
|
|
208
|
+
> extends SurfaceDef<P, S, W> {
|
|
138
209
|
/** Declaration order. This is also the order Push falls back to without banks. */
|
|
139
210
|
readonly ids: readonly Extract<keyof P, string>[];
|
|
140
211
|
}
|
|
@@ -154,7 +225,11 @@ export const BANK_SIZE = 8;
|
|
|
154
225
|
* imports this module to generate the patcher, so a violation fails `pnpm
|
|
155
226
|
* build` and fails CI. It is only a less pretty error message.
|
|
156
227
|
*/
|
|
157
|
-
export function defineSurface<
|
|
228
|
+
export function defineSurface<
|
|
229
|
+
const P extends Record<string, ParamSpec>,
|
|
230
|
+
const S extends Record<string, StateSpec>,
|
|
231
|
+
const W extends Record<string, WindowSpec>,
|
|
232
|
+
>(def: SurfaceDef<P, S, W>): Surface<P, S, W> {
|
|
158
233
|
const ids = Object.keys(def.params) as Extract<keyof P, string>[];
|
|
159
234
|
|
|
160
235
|
for (const id of ids) {
|
package/src/react.tsx
CHANGED
|
@@ -17,8 +17,9 @@
|
|
|
17
17
|
* has no React in it and is tested without a DOM. This file is the hook.
|
|
18
18
|
*/
|
|
19
19
|
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
|
20
|
-
import
|
|
21
|
-
import {
|
|
20
|
+
import { outlet } from "@m4l-jweb/bridge";
|
|
21
|
+
import type { ParamSpec, ParamValue, StateSpec, StateValue, Surface, WindowSpec } from "./index";
|
|
22
|
+
import { paramStore, stateStore } from "./store";
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* A two-way binding to one Live parameter. `[value, setValue]`, like useState -
|
|
@@ -43,3 +44,44 @@ export function useSurface<P extends Record<string, ParamSpec>>(
|
|
|
43
44
|
const set = useCallback(<K extends Extract<keyof P, string>>(id: K, value: ParamValue<P[K]>) => store.write(id, value), [store]);
|
|
44
45
|
return [values as { [K in keyof P]: ParamValue<P[K]> }, set];
|
|
45
46
|
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Open and close a declared floating window.
|
|
50
|
+
*
|
|
51
|
+
* The selectors are DERIVED from the declaration, exactly as the patcher's
|
|
52
|
+
* `[route window_<id>_open ...]` is - so the window id is typed against the
|
|
53
|
+
* surface and a typo is a build error, not a button that does nothing.
|
|
54
|
+
*/
|
|
55
|
+
export function useWindow<
|
|
56
|
+
P extends Record<string, ParamSpec>,
|
|
57
|
+
S extends Record<string, StateSpec>,
|
|
58
|
+
W extends Record<string, WindowSpec>,
|
|
59
|
+
K extends Extract<keyof W, string>,
|
|
60
|
+
>(_surface: Surface<P, S, W>, id: K): { open: () => void; close: () => void } {
|
|
61
|
+
return useMemo(
|
|
62
|
+
() => ({
|
|
63
|
+
open: () => outlet(`window_${id}_open`, 1),
|
|
64
|
+
close: () => outlet(`window_${id}_close`, 1),
|
|
65
|
+
}),
|
|
66
|
+
[id],
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A two-way binding to a JSON state slot, persisted in the Live SET.
|
|
72
|
+
*
|
|
73
|
+
* `[value, setValue]`, like useState - except the value survives saving, closing
|
|
74
|
+
* and reopening the set, and each instance of the device keeps its own. The type
|
|
75
|
+
* comes from the declaration's `default`, so there is nothing to cast.
|
|
76
|
+
*/
|
|
77
|
+
export function useStateSync<
|
|
78
|
+
P extends Record<string, ParamSpec>,
|
|
79
|
+
S extends Record<string, StateSpec>,
|
|
80
|
+
W extends Record<string, WindowSpec>,
|
|
81
|
+
K extends Extract<keyof S, string>,
|
|
82
|
+
>(surface: Surface<P, S, W>, id: K): [StateValue<S[K]>, (value: StateValue<S[K]>) => void] {
|
|
83
|
+
const store = useMemo(() => stateStore(surface), [surface]);
|
|
84
|
+
const values = useSyncExternalStore(store.subscribe, store.get, store.get);
|
|
85
|
+
const set = useCallback((value: StateValue<S[K]>) => store.write(id, value), [store, id]);
|
|
86
|
+
return [values[id] as StateValue<S[K]>, set];
|
|
87
|
+
}
|
package/src/store.ts
CHANGED
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
* the user's hand wins, and the next value after the window lands normally.
|
|
31
31
|
*/
|
|
32
32
|
import { bindInlet, outlet } from "@m4l-jweb/bridge";
|
|
33
|
-
import { defaults, type ParamSpec, type Surface } from "./index";
|
|
33
|
+
import { defaults, type ParamSpec, type StateSpec, type Surface } from "./index";
|
|
34
34
|
|
|
35
35
|
/** How long the user's hand beats an inbound value: long enough to cover the gaps between drag events, short enough to be imperceptible. */
|
|
36
36
|
export const GUARD_MS = 120;
|
|
@@ -133,3 +133,89 @@ export function paramStore<P extends Record<string, ParamSpec>>(surface: Surface
|
|
|
133
133
|
stores.set(surface, store);
|
|
134
134
|
return store;
|
|
135
135
|
}
|
|
136
|
+
|
|
137
|
+
/* ------------------------------------------------------------------ *
|
|
138
|
+
* The state store - the JSON behind useStateSync(), persisted in the Live SET.
|
|
139
|
+
*
|
|
140
|
+
* A parameter is a Live parameter: a number, automatable, on Push. A state slot is
|
|
141
|
+
* none of those things - it is whatever JSON the app wants to survive a save (a
|
|
142
|
+
* pattern, a preset, a grid of steps), and Live never looks inside it.
|
|
143
|
+
*
|
|
144
|
+
* ------------------------------------------------------------------------------
|
|
145
|
+
* THE ID IS AN ARGUMENT, NOT PART OF THE SELECTOR - and getting that wrong is why
|
|
146
|
+
* nothing this store wrote was ever saved.
|
|
147
|
+
*
|
|
148
|
+
* It used to emit `sync_state_<id> <json>`. Max dispatches a message on its FIRST
|
|
149
|
+
* WORD, so `sync_state_config` went looking for a `function sync_state_config()`,
|
|
150
|
+
* found none, and fell into the wrapper's anything() - which exists precisely to
|
|
151
|
+
* swallow messages meant for somebody else, silently, by design. The read path
|
|
152
|
+
* (`get_state <id>`, reply `state_<id> <json>`) had it right, so state loaded and
|
|
153
|
+
* never saved: the failure mode that looks like Live losing your data.
|
|
154
|
+
*
|
|
155
|
+
* OUT: `get_state <id>`, `sync_state <id> <json>` - one handler each, in the wrapper.
|
|
156
|
+
* IN: `state_<id> <json>` - one binding per slot, so the
|
|
157
|
+
* bridge can dispatch it without the app unpacking an id.
|
|
158
|
+
* ------------------------------------------------------------------ */
|
|
159
|
+
|
|
160
|
+
export interface StateStore {
|
|
161
|
+
get(): Values;
|
|
162
|
+
subscribe(fn: () => void): () => void;
|
|
163
|
+
write(id: string, value: unknown): void;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const stateStores = new WeakMap<object, StateStore>();
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* One store per surface, for the same reason paramStore is: one binding per selector.
|
|
170
|
+
*
|
|
171
|
+
* It asks for the SLOTS, not for a `Surface<P, S>` - a store that named the
|
|
172
|
+
* parameter types would make every caller prove theirs match, and it does not read
|
|
173
|
+
* a single parameter. The hook keeps the types; this keeps the state.
|
|
174
|
+
*/
|
|
175
|
+
export function stateStore(surface: { state?: Record<string, StateSpec> }): StateStore {
|
|
176
|
+
const existing = stateStores.get(surface);
|
|
177
|
+
if (existing) return existing;
|
|
178
|
+
|
|
179
|
+
const ids = surface.state ? Object.keys(surface.state) : [];
|
|
180
|
+
|
|
181
|
+
// What the app shows before Live has replied: the declared defaults.
|
|
182
|
+
let values: Values = {};
|
|
183
|
+
for (const id of ids) values[id] = surface.state![id].default;
|
|
184
|
+
|
|
185
|
+
const listeners = new Set<() => void>();
|
|
186
|
+
const notify = () => {
|
|
187
|
+
for (const fn of listeners) fn();
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
for (const id of ids) {
|
|
191
|
+
bindInlet(`state_${id}`, (raw) => {
|
|
192
|
+
try {
|
|
193
|
+
values = { ...values, [id]: JSON.parse(String(raw)) };
|
|
194
|
+
notify();
|
|
195
|
+
} catch {
|
|
196
|
+
// An empty dict stringifies to "{}" and parses fine; anything that does
|
|
197
|
+
// not parse is a slot we have no value for, and the default already in
|
|
198
|
+
// `values` is the honest answer. Keep it rather than blanking the app.
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
// The page loads asynchronously and long after the device did, so nothing was
|
|
202
|
+
// listening when Live restored the pattr. Ask for it.
|
|
203
|
+
outlet("get_state", id);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const store: StateStore = {
|
|
207
|
+
get: () => values,
|
|
208
|
+
subscribe(fn) {
|
|
209
|
+
listeners.add(fn);
|
|
210
|
+
return () => listeners.delete(fn);
|
|
211
|
+
},
|
|
212
|
+
write(id, value) {
|
|
213
|
+
values = { ...values, [id]: value };
|
|
214
|
+
notify();
|
|
215
|
+
outlet("sync_state", id, JSON.stringify(value));
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
stateStores.set(surface, store);
|
|
220
|
+
return store;
|
|
221
|
+
}
|