@playgenx/components 0.1.0 → 0.2.1
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/bin/state-cli.mjs +196 -0
- package/dist/index.d.mts +328 -16
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +576 -25
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -2
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
|
-
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
3
|
//#region src/theme.ts
|
|
4
4
|
/**
|
|
5
5
|
* Shared theme tokens for @playgenx/components.
|
|
@@ -125,15 +125,285 @@ function TextField({ label, value, placeholder, disabled = false, onChange }) {
|
|
|
125
125
|
});
|
|
126
126
|
}
|
|
127
127
|
//#endregion
|
|
128
|
+
//#region src/state.tsx
|
|
129
|
+
/**
|
|
130
|
+
* Per-render interactive state store. Used by PlaygroundStateProvider to
|
|
131
|
+
* back interactive components (Slider, TextField, Button, Chart) without
|
|
132
|
+
* requiring them to be fully controlled.
|
|
133
|
+
*
|
|
134
|
+
* Design notes:
|
|
135
|
+
* - The store is a singleton instance per Provider, NOT React context.
|
|
136
|
+
* Components consume it via usePlaygroundState() inside React renders,
|
|
137
|
+
* and via .subscribe() / .get() / .set() from non-React callers.
|
|
138
|
+
* - `set` is synchronous; subscribers fire in registration order on the
|
|
139
|
+
* next microtask (so multiple sets in a single tick produce a single
|
|
140
|
+
* re-render per subscriber).
|
|
141
|
+
* - `set` with `===` equal value is a no-op (no subscriber fires).
|
|
142
|
+
* - Throws if used outside a PlaygroundStateProvider (when required by
|
|
143
|
+
* the caller's contract).
|
|
144
|
+
*
|
|
145
|
+
* @packageDocumentation
|
|
146
|
+
*/
|
|
147
|
+
/**
|
|
148
|
+
* Create a fresh state store. Public so PlaygroundStateProvider can
|
|
149
|
+
* construct one, but consumers should use Provider/usePlaygroundState
|
|
150
|
+
* to access stores (not direct construction).
|
|
151
|
+
*
|
|
152
|
+
* The optional type parameter is the "shape" of values you intend to
|
|
153
|
+
* store. It's purely a documentation convenience — the store does
|
|
154
|
+
* not enforce it (so you can store heterogeneous values for
|
|
155
|
+
* playgrounds that mix kinds), but `get<T>()` and `set<T>()` will
|
|
156
|
+
* infer correctly when you pass it here.
|
|
157
|
+
*/
|
|
158
|
+
function createPlaygroundState(initial = {}) {
|
|
159
|
+
const values = new Map(Object.entries(initial));
|
|
160
|
+
const subs = /* @__PURE__ */ new Map();
|
|
161
|
+
let pending = [];
|
|
162
|
+
let scheduled = false;
|
|
163
|
+
let batchDepth = 0;
|
|
164
|
+
let batchedPending = [];
|
|
165
|
+
const flush = () => {
|
|
166
|
+
if (pending.length === 0) return;
|
|
167
|
+
const batch = pending;
|
|
168
|
+
pending = [];
|
|
169
|
+
scheduled = false;
|
|
170
|
+
for (const { key, value } of batch) {
|
|
171
|
+
const list = subs.get(key);
|
|
172
|
+
if (list) for (const cb of [...list]) try {
|
|
173
|
+
cb(value);
|
|
174
|
+
} catch (err) {
|
|
175
|
+
if (process.env.NODE_ENV !== "production") console.error("[playgenx] subscriber for key %s threw:", key, err);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
const schedule = () => {
|
|
180
|
+
if (scheduled) return;
|
|
181
|
+
scheduled = true;
|
|
182
|
+
if (typeof queueMicrotask === "function") queueMicrotask(flush);
|
|
183
|
+
else Promise.resolve().then(flush);
|
|
184
|
+
};
|
|
185
|
+
const state = {
|
|
186
|
+
get(key) {
|
|
187
|
+
return values.get(key);
|
|
188
|
+
},
|
|
189
|
+
set(key, value) {
|
|
190
|
+
if (Object.is(values.get(key), value)) return;
|
|
191
|
+
values.set(key, value);
|
|
192
|
+
if (batchDepth > 0) batchedPending.push({
|
|
193
|
+
key,
|
|
194
|
+
value
|
|
195
|
+
});
|
|
196
|
+
else pending.push({
|
|
197
|
+
key,
|
|
198
|
+
value
|
|
199
|
+
});
|
|
200
|
+
schedule();
|
|
201
|
+
},
|
|
202
|
+
subscribe(key, cb) {
|
|
203
|
+
let list = subs.get(key);
|
|
204
|
+
if (!list) {
|
|
205
|
+
list = /* @__PURE__ */ new Set();
|
|
206
|
+
subs.set(key, list);
|
|
207
|
+
}
|
|
208
|
+
list.add(cb);
|
|
209
|
+
return () => {
|
|
210
|
+
const l = subs.get(key);
|
|
211
|
+
if (l) {
|
|
212
|
+
l.delete(cb);
|
|
213
|
+
if (l.size === 0) subs.delete(key);
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
},
|
|
217
|
+
snapshot() {
|
|
218
|
+
return Object.fromEntries(values);
|
|
219
|
+
},
|
|
220
|
+
replaceAll(values) {
|
|
221
|
+
for (const [k, v] of Object.entries(values)) state.set(k, v);
|
|
222
|
+
},
|
|
223
|
+
_flush() {
|
|
224
|
+
flush();
|
|
225
|
+
},
|
|
226
|
+
_batch(fn) {
|
|
227
|
+
batchDepth++;
|
|
228
|
+
try {
|
|
229
|
+
return fn();
|
|
230
|
+
} finally {
|
|
231
|
+
batchDepth--;
|
|
232
|
+
if (batchDepth === 0 && batchedPending.length > 0) {
|
|
233
|
+
const lastPerKey = /* @__PURE__ */ new Map();
|
|
234
|
+
for (let i = batchedPending.length - 1; i >= 0; i--) {
|
|
235
|
+
const { key, value } = batchedPending[i];
|
|
236
|
+
if (!lastPerKey.has(key)) {
|
|
237
|
+
lastPerKey.set(key, value);
|
|
238
|
+
pending.push({
|
|
239
|
+
key,
|
|
240
|
+
value
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
batchedPending = [];
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
_clearAllSubscribers() {
|
|
249
|
+
subs.clear();
|
|
250
|
+
pending = [];
|
|
251
|
+
batchedPending = [];
|
|
252
|
+
scheduled = false;
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
return state;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* React context. Exposed as a type only — consumers should use
|
|
259
|
+
* PlaygroundStateProvider and usePlaygroundState rather than reading
|
|
260
|
+
* the context directly.
|
|
261
|
+
*/
|
|
262
|
+
const PlaygroundStateContext = React.createContext(null);
|
|
263
|
+
/**
|
|
264
|
+
* Provide a PlaygroundState scope to descendant components.
|
|
265
|
+
*
|
|
266
|
+
* Each Provider instance has its own store; nested Providers create
|
|
267
|
+
* independent scopes. Components rendered outside any Provider will
|
|
268
|
+
* throw a clear error if they call usePlaygroundState.
|
|
269
|
+
*/
|
|
270
|
+
function PlaygroundStateProvider(props) {
|
|
271
|
+
const [state] = React.useState(() => {
|
|
272
|
+
return createPlaygroundState({
|
|
273
|
+
...props.seed,
|
|
274
|
+
...props.initial
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
React.useEffect(() => {
|
|
278
|
+
return () => {
|
|
279
|
+
state._clearAllSubscribers?.();
|
|
280
|
+
};
|
|
281
|
+
}, [state]);
|
|
282
|
+
const value = React.useMemo(() => ({ state }), [state]);
|
|
283
|
+
return React.createElement(PlaygroundStateContext.Provider, { value }, props.children);
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Hook to access the nearest PlaygroundState store. Throws a clear
|
|
287
|
+
* error if called outside a Provider so consumers get a usable
|
|
288
|
+
* diagnostic instead of a `Cannot read properties of undefined`.
|
|
289
|
+
*
|
|
290
|
+
* Note for @playgenx/renderer users: the renderer's `renderBody`
|
|
291
|
+
* does NOT yet auto-wrap with a PlaygroundStateProvider. You'll
|
|
292
|
+
* need to wrap your own tree with `<PlaygroundStateProvider>` (or
|
|
293
|
+
* use the lower-level `renderNodes` API) before state-bound
|
|
294
|
+
* components can resolve. The `withState` option on `renderBody`
|
|
295
|
+
* is planned for a future renderer release (tracked in v0.6.0).
|
|
296
|
+
*/
|
|
297
|
+
function usePlaygroundState() {
|
|
298
|
+
const ctx = React.useContext(PlaygroundStateContext);
|
|
299
|
+
if (ctx === null) throw new Error("usePlaygroundState() must be called inside a <PlaygroundStateProvider>. Wrap your component tree with the Provider. (A future renderer release will auto-wrap; for now, do it explicitly.)");
|
|
300
|
+
return ctx.state;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Optional helper for binding a component prop to a state key. Returns
|
|
304
|
+
* the live value (re-renders subscribers on change) and a setter. Pass
|
|
305
|
+
* `undefined` for `key` to disable the binding — the component behaves
|
|
306
|
+
* as if it had no stateKey, and `set` becomes a no-op. Safe to call
|
|
307
|
+
* outside a Provider.
|
|
308
|
+
*
|
|
309
|
+
* Implementation note: this hook ALWAYS calls the same number of
|
|
310
|
+
* React hooks regardless of whether `key` is set or a Provider is in
|
|
311
|
+
* scope. The "no binding" / "no Provider" branch uses dummy local
|
|
312
|
+
* state so the hook count stays aligned across renders. That means a
|
|
313
|
+
* component can flip `stateKey` from defined to undefined (or move in
|
|
314
|
+
* or out of a Provider) without crashing with a hooks-order error.
|
|
315
|
+
*
|
|
316
|
+
* Components that accept a `stateKey` prop should call this internally
|
|
317
|
+
* and use the returned tuple as a drop-in for the underlying
|
|
318
|
+
* value/onChange pair. The return type widens to `readonly [...] | undefined`
|
|
319
|
+
* so the caller can still branch on `if (bound)` — but they don't have
|
|
320
|
+
* to (the tuple is always safe to index).
|
|
321
|
+
*
|
|
322
|
+
* @example
|
|
323
|
+
* const bound = useBoundValueOrUndefined<number>(stateKey, 50);
|
|
324
|
+
* // ... onChange={(e) => bound?.[1](Number(e.currentTarget.value))}
|
|
325
|
+
*/
|
|
326
|
+
function useBoundValueOrUndefined(key, fallback) {
|
|
327
|
+
const ctx = React.useContext(PlaygroundStateContext);
|
|
328
|
+
const bound = key !== void 0 && ctx !== null;
|
|
329
|
+
const [value, setValue] = React.useState(fallback);
|
|
330
|
+
const [, force] = React.useState(0);
|
|
331
|
+
React.useEffect(() => {
|
|
332
|
+
if (!bound || key === void 0 || ctx === null) return;
|
|
333
|
+
const initial = ctx.state.get(key) ?? fallback;
|
|
334
|
+
setValue(initial);
|
|
335
|
+
return ctx.state.subscribe(key, (next) => {
|
|
336
|
+
setValue(next);
|
|
337
|
+
force((n) => n + 1);
|
|
338
|
+
});
|
|
339
|
+
}, [
|
|
340
|
+
bound,
|
|
341
|
+
ctx,
|
|
342
|
+
key
|
|
343
|
+
]);
|
|
344
|
+
const set = React.useCallback((next) => {
|
|
345
|
+
if (!bound || key === void 0 || ctx === null) return;
|
|
346
|
+
ctx.state.set(key, next);
|
|
347
|
+
}, [
|
|
348
|
+
bound,
|
|
349
|
+
ctx,
|
|
350
|
+
key
|
|
351
|
+
]);
|
|
352
|
+
if (!bound) return void 0;
|
|
353
|
+
return [value, set];
|
|
354
|
+
}
|
|
355
|
+
function useBoundValue(key, fallback) {
|
|
356
|
+
const bound = useBoundValueOrUndefined(key, fallback);
|
|
357
|
+
if (bound === void 0) throw new Error("useBoundValue(key, ...) requires both a non-undefined `key` and a <PlaygroundStateProvider> ancestor. Use useBoundValueOrUndefined if you want optional binding.");
|
|
358
|
+
return bound;
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Apply a StateAction to the nearest PlaygroundState. Returns an
|
|
362
|
+
* onClick handler that fires the action. If action is undefined
|
|
363
|
+
* the handler is a no-op.
|
|
364
|
+
*/
|
|
365
|
+
function useStateAction(action) {
|
|
366
|
+
const state = usePlaygroundState();
|
|
367
|
+
return React.useCallback((event) => {
|
|
368
|
+
if (action === void 0) return;
|
|
369
|
+
if (action.set) for (const [k, v] of Object.entries(action.set)) state.set(k, v);
|
|
370
|
+
if (action.toggle !== void 0) {
|
|
371
|
+
const key = action.toggle;
|
|
372
|
+
const current = state.get(key);
|
|
373
|
+
state.set(key, !current);
|
|
374
|
+
}
|
|
375
|
+
}, [action, state]);
|
|
376
|
+
}
|
|
377
|
+
//#endregion
|
|
128
378
|
//#region src/Slider.tsx
|
|
129
379
|
/**
|
|
130
|
-
* Range input.
|
|
131
|
-
*
|
|
380
|
+
* Range input. Three operating modes:
|
|
381
|
+
*
|
|
382
|
+
* 1. **Uncontrolled** (no `value`, no `stateKey`): the slider owns
|
|
383
|
+
* its value as local React state. The onChange mutates that state.
|
|
384
|
+
* Use this when nothing else needs to observe the value.
|
|
385
|
+
*
|
|
386
|
+
* 2. **Controlled by `value` prop**: the slider is read-only —
|
|
387
|
+
* user input is ignored because there is no `onChange` callback
|
|
388
|
+
* wired by the deterministic renderer. Use this for "static"
|
|
389
|
+
* sliders in artifacts. (If you want fully interactive control,
|
|
390
|
+
* use mode 3.)
|
|
391
|
+
*
|
|
392
|
+
* 3. **Bound to PlaygroundState via `stateKey`**: the slider reads
|
|
393
|
+
* its value from the nearest Provider's store (initial: either
|
|
394
|
+
* the store's current value OR `value` prop, whichever is
|
|
395
|
+
* present; the live store wins when bound). On change, the
|
|
396
|
+
* slider writes to the store. Other components can subscribe
|
|
397
|
+
* to the same key.
|
|
398
|
+
*
|
|
399
|
+
* In all modes the displayed value is clamped to `[min, max]`. NaN
|
|
400
|
+
* inputs clamp to `min`. When `min === max` the input is disabled.
|
|
132
401
|
*/
|
|
133
|
-
function Slider({ min, max, value, step = 1, label }) {
|
|
402
|
+
function Slider({ min, max, value, step = 1, label, stateKey }) {
|
|
134
403
|
const [internal, setInternal] = React.useState(() => clamp(value ?? min, min, max));
|
|
404
|
+
const bound = useBoundValueOrUndefined(stateKey, internal);
|
|
135
405
|
const controlled = value !== void 0;
|
|
136
|
-
const current = controlled ? clamp(value, min, max) : internal;
|
|
406
|
+
const current = stateKey !== void 0 && bound ? clamp(bound[0], min, max) : controlled ? clamp(value, min, max) : internal;
|
|
137
407
|
const trackStyle = {
|
|
138
408
|
display: "flex",
|
|
139
409
|
flexDirection: "column",
|
|
@@ -179,7 +449,8 @@ function Slider({ min, max, value, step = 1, label }) {
|
|
|
179
449
|
"aria-label": label,
|
|
180
450
|
onChange: (e) => {
|
|
181
451
|
const next = Number(e.currentTarget.value);
|
|
182
|
-
if (
|
|
452
|
+
if (stateKey !== void 0 && bound) bound[1](next);
|
|
453
|
+
else if (!controlled) setInternal(next);
|
|
183
454
|
},
|
|
184
455
|
style: inputStyle
|
|
185
456
|
}), /* @__PURE__ */ jsx("span", {
|
|
@@ -604,35 +875,315 @@ function List({ items, ordered, children }) {
|
|
|
604
875
|
}));
|
|
605
876
|
}
|
|
606
877
|
//#endregion
|
|
607
|
-
//#region src/
|
|
878
|
+
//#region src/stateHelpers.ts
|
|
879
|
+
function diffSnapshots(prev, next) {
|
|
880
|
+
const added = [];
|
|
881
|
+
const changed = [];
|
|
882
|
+
const removed = [];
|
|
883
|
+
const prevKeys = Object.keys(prev);
|
|
884
|
+
const nextKeys = Object.keys(next);
|
|
885
|
+
const prevSet = new Set(prevKeys);
|
|
886
|
+
const nextSet = new Set(nextKeys);
|
|
887
|
+
for (const k of nextKeys) {
|
|
888
|
+
if (!prevSet.has(k)) {
|
|
889
|
+
added.push(k);
|
|
890
|
+
continue;
|
|
891
|
+
}
|
|
892
|
+
if (!Object.is(prev[k], next[k])) changed.push(k);
|
|
893
|
+
}
|
|
894
|
+
for (const k of prevKeys) if (!nextSet.has(k)) removed.push(k);
|
|
895
|
+
added.sort();
|
|
896
|
+
changed.sort();
|
|
897
|
+
removed.sort();
|
|
898
|
+
if (added.length === 0 && changed.length === 0 && removed.length === 0) return null;
|
|
899
|
+
return {
|
|
900
|
+
added,
|
|
901
|
+
changed,
|
|
902
|
+
removed
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
/**
|
|
906
|
+
* Convenience: take snapshots of both stores and diff them. Returns
|
|
907
|
+
* `null` when equal. Useful in tests and debug endpoints.
|
|
908
|
+
*/
|
|
909
|
+
function diffState(_a, _b, prev, next) {
|
|
910
|
+
return diffSnapshots(prev ?? _a.snapshot(), next ?? _b.snapshot());
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Validate a state-key string. Returns `null` on success, or a
|
|
914
|
+
* human-readable error message on failure. The rules:
|
|
915
|
+
*
|
|
916
|
+
* - 1..64 characters
|
|
917
|
+
* - No whitespace
|
|
918
|
+
* - Only alphanumerics, dots, dashes, underscores
|
|
919
|
+
* - Must start with an alphabetic character (so a parser can
|
|
920
|
+
* safely infer `<Slider stateKey="...">` from a body without
|
|
921
|
+
* swallowing accidental numeric keys).
|
|
922
|
+
*
|
|
923
|
+
* Note: keys like `with/slash` or `with spaces` are rejected because
|
|
924
|
+
* the schema-driven body parser uses them as attribute names —
|
|
925
|
+
* a key with weird characters would break the round-trip.
|
|
926
|
+
*/
|
|
927
|
+
function validateStateKey(key) {
|
|
928
|
+
if (key.length === 0) return "stateKey must be a non-empty string";
|
|
929
|
+
if (key.length > 64) return "stateKey must be at most 64 characters";
|
|
930
|
+
if (/\s/.test(key)) return "stateKey must not contain whitespace";
|
|
931
|
+
if (!/^[A-Za-z][A-Za-z0-9._-]*$/.test(key)) return "stateKey contains forbidden characters (allowed: A-Z, a-z, 0-9, dot, dash, underscore; must start with a letter)";
|
|
932
|
+
return null;
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Remove every key from the store, firing subscribers with `undefined`.
|
|
936
|
+
* After this call, `state.snapshot()` is `{}`.
|
|
937
|
+
*/
|
|
938
|
+
function clearState(state) {
|
|
939
|
+
for (const k of Object.keys(state.snapshot())) state.set(k, void 0);
|
|
940
|
+
}
|
|
941
|
+
function dumpState(state) {
|
|
942
|
+
const snap = state.snapshot();
|
|
943
|
+
const envelope = {
|
|
944
|
+
version: 1,
|
|
945
|
+
keys: Object.keys(snap).sort(),
|
|
946
|
+
values: { ...snap }
|
|
947
|
+
};
|
|
948
|
+
return JSON.stringify(envelope, null, 2);
|
|
949
|
+
}
|
|
950
|
+
/**
|
|
951
|
+
* Apply multiple `set` calls as a single atomic batch. Each
|
|
952
|
+
* subscriber sees only the LAST value written to its key in this
|
|
953
|
+
* batch. The store still batches subscriber fires via the microtask
|
|
954
|
+
* scheduler — `batch` does not flush synchronously, it only ensures
|
|
955
|
+
* that within the batch, repeated `set` calls to the same key don't
|
|
956
|
+
* queue redundant fires.
|
|
957
|
+
*
|
|
958
|
+
* Useful for: undo/redo, multi-field form commits, hydrated state
|
|
959
|
+
* restore where you want each subscriber to fire exactly once with
|
|
960
|
+
* the final value.
|
|
961
|
+
*
|
|
962
|
+
* @example
|
|
963
|
+
* batch(state, (s) => {
|
|
964
|
+
* s.set('x', 1);
|
|
965
|
+
* s.set('y', 2);
|
|
966
|
+
* s.set('x', 3); // overwrites; subscriber sees only 3
|
|
967
|
+
* });
|
|
968
|
+
*/
|
|
969
|
+
function batch(state, fn) {
|
|
970
|
+
return state._batch(() => fn(state));
|
|
971
|
+
}
|
|
972
|
+
//#endregion
|
|
973
|
+
//#region src/ShowSource.tsx
|
|
608
974
|
/**
|
|
609
|
-
*
|
|
610
|
-
*
|
|
611
|
-
* implementation at runtime:
|
|
975
|
+
* Render-prop component that shows a toggle-able raw-source panel.
|
|
976
|
+
* Consumer controls the trigger UI via a children render prop.
|
|
612
977
|
*
|
|
613
|
-
*
|
|
614
|
-
*
|
|
978
|
+
* Design notes:
|
|
979
|
+
* - Render-prop pattern (not props.children-as-trigger) because the
|
|
980
|
+
* trigger UI varies wildly across consumers (button, link, icon,
|
|
981
|
+
* inline text). The consumer gets `(toggle, showing)` and decides
|
|
982
|
+
* what to render.
|
|
983
|
+
* - The source panel is a `<pre>` with `<code>` inside. Plain-text;
|
|
984
|
+
* no syntax highlighting (avoiding a 50KB highlight.js dep).
|
|
985
|
+
* Consumers who need highlighting can compose with their own
|
|
986
|
+
* syntax-highlighter.
|
|
987
|
+
* - The panel is rendered INLINE (not in a portal/tooltip) so the
|
|
988
|
+
* toggle's visual feedback is immediate.
|
|
989
|
+
*
|
|
990
|
+
* @packageDocumentation
|
|
991
|
+
*/
|
|
992
|
+
/**
|
|
993
|
+
* Toggle button + expandable source panel. The trigger UI is the
|
|
994
|
+
* consumer's choice; the panel is plain `<pre><code>`.
|
|
995
|
+
*/
|
|
996
|
+
function ShowSource(props) {
|
|
997
|
+
const { body, language, children } = props;
|
|
998
|
+
const [showing, setShowing] = React.useState(false);
|
|
999
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [children(React.useCallback(() => {
|
|
1000
|
+
setShowing((s) => !s);
|
|
1001
|
+
}, []), showing), showing ? /* @__PURE__ */ jsx("pre", {
|
|
1002
|
+
"data-pgx": "ShowSource",
|
|
1003
|
+
"data-language": language ?? "text",
|
|
1004
|
+
style: {
|
|
1005
|
+
background: "#0f172a",
|
|
1006
|
+
color: "#e2e8f0",
|
|
1007
|
+
padding: "12px",
|
|
1008
|
+
borderRadius: "6px",
|
|
1009
|
+
overflowX: "auto",
|
|
1010
|
+
fontFamily: "ui-monospace, Menlo, monospace",
|
|
1011
|
+
fontSize: "12px",
|
|
1012
|
+
lineHeight: 1.5,
|
|
1013
|
+
margin: "8px 0 0",
|
|
1014
|
+
maxHeight: "320px",
|
|
1015
|
+
whiteSpace: "pre"
|
|
1016
|
+
},
|
|
1017
|
+
children: /* @__PURE__ */ jsx("code", { children: body })
|
|
1018
|
+
}) : null] });
|
|
1019
|
+
}
|
|
1020
|
+
//#endregion
|
|
1021
|
+
//#region src/ArtifactErrorBoundary.tsx
|
|
1022
|
+
/**
|
|
1023
|
+
* Error boundary for rendered artifacts. Catches render-time errors
|
|
1024
|
+
* (malformed body, throw from a component, etc.) and shows a recoverable
|
|
1025
|
+
* fallback. Without this, a single bad artifact blanks the student's
|
|
1026
|
+
* whole page.
|
|
615
1027
|
*
|
|
616
|
-
*
|
|
1028
|
+
* Design notes:
|
|
1029
|
+
* - Uses React's class-component error boundary API (the only
|
|
1030
|
+
* first-class option in React 18/19).
|
|
1031
|
+
* - Default fallback shows the error message + an optional "Show source"
|
|
1032
|
+
* toggle that reveals the raw body. Consumer can override with
|
|
1033
|
+
* `fallback` for custom UX.
|
|
1034
|
+
* - `onError` fires before the fallback renders so consumers can log
|
|
1035
|
+
* to Sentry / Datadog / etc.
|
|
617
1036
|
*
|
|
618
|
-
*
|
|
619
|
-
|
|
620
|
-
|
|
1037
|
+
* @packageDocumentation
|
|
1038
|
+
*/
|
|
1039
|
+
/**
|
|
1040
|
+
* Default fallback panel. Renders the error message and, if `body` is
|
|
1041
|
+
* provided, a ShowSource toggle. Designed to be a calm, recoverable
|
|
1042
|
+
* surface — not a stack-trace dump that panics users.
|
|
621
1043
|
*/
|
|
622
|
-
|
|
1044
|
+
function DefaultFallback({ error, body, language, kind }) {
|
|
1045
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1046
|
+
role: "alert",
|
|
1047
|
+
"data-pgx": "ArtifactErrorBoundary",
|
|
1048
|
+
"data-pgx-kind": kind,
|
|
1049
|
+
style: {
|
|
1050
|
+
border: "1px solid #fca5a5",
|
|
1051
|
+
background: "#fef2f2",
|
|
1052
|
+
color: "#7f1d1d",
|
|
1053
|
+
borderRadius: "8px",
|
|
1054
|
+
padding: "12px 16px",
|
|
1055
|
+
fontFamily: "ui-sans-serif, system-ui, sans-serif",
|
|
1056
|
+
fontSize: "14px",
|
|
1057
|
+
maxWidth: "720px",
|
|
1058
|
+
margin: "12px 0"
|
|
1059
|
+
},
|
|
1060
|
+
children: [
|
|
1061
|
+
/* @__PURE__ */ jsx("div", {
|
|
1062
|
+
style: {
|
|
1063
|
+
fontWeight: 600,
|
|
1064
|
+
marginBottom: "6px"
|
|
1065
|
+
},
|
|
1066
|
+
children: kind ? `${kind} artifact failed to render` : "Artifact failed to render"
|
|
1067
|
+
}),
|
|
1068
|
+
/* @__PURE__ */ jsx("code", {
|
|
1069
|
+
"data-pgx": "error-message",
|
|
1070
|
+
style: {
|
|
1071
|
+
display: "block",
|
|
1072
|
+
fontFamily: "ui-monospace, Menlo, monospace",
|
|
1073
|
+
fontSize: "12px",
|
|
1074
|
+
background: "rgba(0,0,0,0.04)",
|
|
1075
|
+
padding: "6px 8px",
|
|
1076
|
+
borderRadius: "4px",
|
|
1077
|
+
whiteSpace: "pre-wrap",
|
|
1078
|
+
wordBreak: "break-word"
|
|
1079
|
+
},
|
|
1080
|
+
children: error.message
|
|
1081
|
+
}),
|
|
1082
|
+
body !== void 0 ? /* @__PURE__ */ jsx("div", {
|
|
1083
|
+
style: { marginTop: "8px" },
|
|
1084
|
+
children: /* @__PURE__ */ jsx(ShowSource, {
|
|
1085
|
+
body,
|
|
1086
|
+
language,
|
|
1087
|
+
children: (toggle, showing) => /* @__PURE__ */ jsx("button", {
|
|
1088
|
+
type: "button",
|
|
1089
|
+
onClick: toggle,
|
|
1090
|
+
"data-pgx": "show-source-toggle",
|
|
1091
|
+
style: {
|
|
1092
|
+
background: "transparent",
|
|
1093
|
+
border: "1px solid #fca5a5",
|
|
1094
|
+
color: "#7f1d1d",
|
|
1095
|
+
padding: "4px 10px",
|
|
1096
|
+
fontSize: "12px",
|
|
1097
|
+
borderRadius: "4px",
|
|
1098
|
+
cursor: "pointer"
|
|
1099
|
+
},
|
|
1100
|
+
children: showing ? "Hide source" : "Show source"
|
|
1101
|
+
})
|
|
1102
|
+
})
|
|
1103
|
+
}) : null
|
|
1104
|
+
]
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
/**
|
|
1108
|
+
* Error boundary. Catches render-time errors in `children` and shows
|
|
1109
|
+
* the fallback. The error state is reset on prop change of `children`
|
|
1110
|
+
* (or explicit `resetKey` bump) so the consumer can recover by
|
|
1111
|
+
* re-rendering.
|
|
1112
|
+
*/
|
|
1113
|
+
var ArtifactErrorBoundary = class extends React.Component {
|
|
1114
|
+
state = { error: null };
|
|
1115
|
+
static getDerivedStateFromError(error) {
|
|
1116
|
+
return { error };
|
|
1117
|
+
}
|
|
1118
|
+
componentDidCatch(error, info) {
|
|
1119
|
+
if (this.props.onError) try {
|
|
1120
|
+
this.props.onError(error, { componentStack: info.componentStack ?? "" });
|
|
1121
|
+
} catch {}
|
|
1122
|
+
}
|
|
1123
|
+
render() {
|
|
1124
|
+
const { error } = this.state;
|
|
1125
|
+
if (error === null) return this.props.children;
|
|
1126
|
+
const { fallback, body, language, kind } = this.props;
|
|
1127
|
+
if (typeof fallback === "function") return fallback(error);
|
|
1128
|
+
if (fallback !== void 0) return fallback;
|
|
1129
|
+
return /* @__PURE__ */ jsx(DefaultFallback, {
|
|
1130
|
+
error,
|
|
1131
|
+
body,
|
|
1132
|
+
language,
|
|
1133
|
+
kind
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
};
|
|
1137
|
+
//#endregion
|
|
1138
|
+
//#region src/createRegistry.ts
|
|
1139
|
+
const DEFAULT_REGISTRY = Object.freeze({
|
|
623
1140
|
Button,
|
|
624
|
-
|
|
625
|
-
Slider,
|
|
1141
|
+
Card,
|
|
626
1142
|
Chart,
|
|
627
|
-
Container,
|
|
628
1143
|
Code,
|
|
1144
|
+
Container,
|
|
629
1145
|
Heading,
|
|
630
|
-
|
|
1146
|
+
List,
|
|
1147
|
+
Slider,
|
|
631
1148
|
Stepper,
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
};
|
|
1149
|
+
Text,
|
|
1150
|
+
TextField
|
|
1151
|
+
});
|
|
1152
|
+
/**
|
|
1153
|
+
* The full default registry (Button, Card, Chart, Code, Container,
|
|
1154
|
+
* Heading, List, Slider, Stepper, Text, TextField). Frozen. Use
|
|
1155
|
+
* `createRegistry(overrides)` to extend.
|
|
1156
|
+
*/
|
|
1157
|
+
const componentMap = DEFAULT_REGISTRY;
|
|
1158
|
+
/**
|
|
1159
|
+
* Build a new registry by merging the defaults with caller-supplied
|
|
1160
|
+
* overrides. The returned map is frozen (Object.freeze) to prevent
|
|
1161
|
+
* post-hoc mutation that would surprise concurrent renders.
|
|
1162
|
+
*
|
|
1163
|
+
* **Lowercase HTML tags are NOT in this map.** The renderer handles
|
|
1164
|
+
* `div`, `span`, etc. as React intrinsic elements separately. Calling
|
|
1165
|
+
* `createRegistry({ div: StubDiv })` will NOT override the renderer
|
|
1166
|
+
* for `<div>` (the renderer uses the lowercase lookup path first).
|
|
1167
|
+
*
|
|
1168
|
+
* @example
|
|
1169
|
+
* ```ts
|
|
1170
|
+
* const map = createRegistry({
|
|
1171
|
+
* MathExpression: MathExpressionComponent,
|
|
1172
|
+
* LatexBlock: LatexBlockComponent,
|
|
1173
|
+
* });
|
|
1174
|
+
* ```
|
|
1175
|
+
*/
|
|
1176
|
+
function createRegistry(overrides) {
|
|
1177
|
+
const merged = { ...DEFAULT_REGISTRY };
|
|
1178
|
+
for (const key of Object.keys(overrides)) {
|
|
1179
|
+
const value = overrides[key];
|
|
1180
|
+
if (value === void 0) continue;
|
|
1181
|
+
if (key[0] !== key[0].toUpperCase()) continue;
|
|
1182
|
+
merged[key] = value;
|
|
1183
|
+
}
|
|
1184
|
+
return Object.freeze(merged);
|
|
1185
|
+
}
|
|
635
1186
|
//#endregion
|
|
636
|
-
export { Button, Card, Chart, Code, Container, Heading, List, Slider, Stepper, Text, TextField, componentMap };
|
|
1187
|
+
export { ArtifactErrorBoundary, Button, Card, Chart, Code, Container, Heading, List, PlaygroundStateProvider, ShowSource, Slider, Stepper, Text, TextField, batch, clearState, componentMap, createPlaygroundState, createRegistry, diffSnapshots, diffState, dumpState, useBoundValue, useBoundValueOrUndefined, usePlaygroundState, useStateAction, validateStateKey };
|
|
637
1188
|
|
|
638
1189
|
//# sourceMappingURL=index.mjs.map
|