@jxsuite/studio 0.1.0 → 0.5.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.
Files changed (40) hide show
  1. package/dist/studio.js +50941 -34749
  2. package/dist/studio.js.map +461 -345
  3. package/package.json +46 -35
  4. package/src/browse/browse.js +414 -0
  5. package/src/editor/context-menu.js +48 -1
  6. package/src/editor/convert-to-component.js +208 -0
  7. package/src/editor/inline-edit.js +33 -6
  8. package/src/editor/shortcuts.js +6 -1
  9. package/src/files/components.js +4 -2
  10. package/src/files/file-ops.js +102 -54
  11. package/src/files/files.js +22 -8
  12. package/src/markdown/md-convert.js +309 -11
  13. package/src/panels/activity-bar.js +3 -0
  14. package/src/panels/head-panel.js +576 -0
  15. package/src/panels/overlays.js +133 -0
  16. package/src/panels/right-panel.js +130 -0
  17. package/src/panels/shared.js +41 -0
  18. package/src/panels/signals-panel.js +95 -94
  19. package/src/panels/statusbar.js +15 -1
  20. package/src/panels/toolbar.js +223 -0
  21. package/src/platforms/devserver.js +58 -16
  22. package/src/settings/collections-editor.js +428 -0
  23. package/src/settings/defs-editor.js +418 -0
  24. package/src/settings/schema-field-ui.js +329 -0
  25. package/src/state.js +99 -2
  26. package/src/store.js +112 -41
  27. package/src/studio.js +1551 -1565
  28. package/src/ui/button-group.js +91 -0
  29. package/src/ui/color-selector.js +299 -0
  30. package/src/ui/field-row.js +47 -0
  31. package/src/ui/media-picker.js +172 -0
  32. package/src/ui/panel-resize.js +96 -0
  33. package/src/ui/spectrum.js +36 -2
  34. package/src/ui/unit-selector.js +106 -0
  35. package/src/ui/{jx-styled-combobox.js → value-selector.js} +7 -7
  36. package/src/ui/widgets.js +106 -0
  37. package/src/utils/canvas-media.js +151 -0
  38. package/src/utils/inherited-style.js +54 -0
  39. package/src/utils/studio-utils.js +32 -0
  40. package/src/view.js +68 -0
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Schema field UI — shared field-card and add-field-dialog templates for the collections and
3
+ * definitions editors.
4
+ */
5
+
6
+ import { html, nothing } from "lit-html";
7
+
8
+ export const FIELD_TYPES = ["string", "number", "boolean", "array", "object", "date"];
9
+
10
+ /**
11
+ * @typedef {{
12
+ * type?: string;
13
+ * properties?: Record<string, SchemaProperty>;
14
+ * required?: string[];
15
+ * items?: any;
16
+ * format?: string;
17
+ * }} SchemaProperty
18
+ */
19
+
20
+ /**
21
+ * @typedef {{
22
+ * onDelete: (name: string) => void;
23
+ * onToggleRequired: (name: string) => void;
24
+ * onRename: (oldName: string, newName: string) => void;
25
+ * onChangeType: (name: string, newType: string) => void;
26
+ * onAddNestedField?: (
27
+ * parentName: string,
28
+ * state: { name: string; type: string; required: boolean },
29
+ * ) => void;
30
+ * onDeleteNested?: (parentName: string, childName: string) => void;
31
+ * onToggleNestedRequired?: (parentName: string, childName: string) => void;
32
+ * onRenameNested?: (parentName: string, oldChild: string, newChild: string) => void;
33
+ * onChangeNestedType?: (parentName: string, childName: string, newType: string) => void;
34
+ * }} FieldHandlers
35
+ */
36
+
37
+ /**
38
+ * Render a single schema field as an inline-editable form row.
39
+ *
40
+ * @param {string} fieldName
41
+ * @param {SchemaProperty} fieldSchema — JSON Schema property definition
42
+ * @param {boolean} isRequired
43
+ * @param {FieldHandlers} handlers
44
+ * @returns {any}
45
+ */
46
+ export function fieldCardTpl(fieldName, fieldSchema, isRequired, handlers) {
47
+ const type = fieldSchema.format === "date" ? "date" : fieldSchema.type || "string";
48
+ const isNested = type === "object";
49
+ const nestedProps = fieldSchema.properties || {};
50
+ const nestedRequired = fieldSchema.required || [];
51
+
52
+ return html`
53
+ <div class="schema-field-card">
54
+ <div class="schema-field-row">
55
+ <sp-textfield
56
+ size="s"
57
+ quiet
58
+ value=${fieldName}
59
+ class="schema-field-name-input"
60
+ @change=${(/** @type {any} */ e) => {
61
+ const newName = e.target.value.trim();
62
+ if (newName && newName !== fieldName) handlers.onRename(fieldName, newName);
63
+ else e.target.value = fieldName;
64
+ }}
65
+ @keydown=${(/** @type {any} */ e) => {
66
+ if (e.key === "Enter") e.target.blur();
67
+ if (e.key === "Escape") {
68
+ e.target.value = fieldName;
69
+ e.target.blur();
70
+ }
71
+ }}
72
+ ></sp-textfield>
73
+ ${typePickerTpl(type, (newType) => handlers.onChangeType(fieldName, newType))}
74
+ <sp-switch
75
+ size="s"
76
+ ?checked=${isRequired}
77
+ @change=${() => handlers.onToggleRequired(fieldName)}
78
+ >
79
+ Req
80
+ </sp-switch>
81
+ <sp-action-button
82
+ size="xs"
83
+ quiet
84
+ title="Delete field"
85
+ @click=${() => handlers.onDelete(fieldName)}
86
+ >
87
+ <sp-icon-delete slot="icon"></sp-icon-delete>
88
+ </sp-action-button>
89
+ </div>
90
+ ${isNested
91
+ ? html`
92
+ <div class="schema-field-nested">
93
+ ${Object.entries(nestedProps).map(([name, sub]) =>
94
+ nestedFieldCardTpl(
95
+ fieldName,
96
+ name,
97
+ /** @type {SchemaProperty} */ (sub),
98
+ nestedRequired.includes(name),
99
+ handlers,
100
+ ),
101
+ )}
102
+ ${nestedAddFieldTpl(fieldName, handlers)}
103
+ </div>
104
+ `
105
+ : nothing}
106
+ </div>
107
+ `;
108
+ }
109
+
110
+ /**
111
+ * Render a nested (child) field card — same inline-editable pattern but delegates to nested
112
+ * handlers.
113
+ *
114
+ * @param {string} parentName
115
+ * @param {string} childName
116
+ * @param {SchemaProperty} childSchema
117
+ * @param {boolean} isRequired
118
+ * @param {FieldHandlers} handlers
119
+ * @returns {any}
120
+ */
121
+ function nestedFieldCardTpl(parentName, childName, childSchema, isRequired, handlers) {
122
+ const type = childSchema.format === "date" ? "date" : childSchema.type || "string";
123
+
124
+ return html`
125
+ <div class="schema-field-card schema-field-card--nested">
126
+ <div class="schema-field-row">
127
+ <sp-textfield
128
+ size="s"
129
+ quiet
130
+ value=${childName}
131
+ class="schema-field-name-input"
132
+ @change=${(/** @type {any} */ e) => {
133
+ const newName = e.target.value.trim();
134
+ if (newName && newName !== childName && handlers.onRenameNested) {
135
+ handlers.onRenameNested(parentName, childName, newName);
136
+ } else {
137
+ e.target.value = childName;
138
+ }
139
+ }}
140
+ @keydown=${(/** @type {any} */ e) => {
141
+ if (e.key === "Enter") e.target.blur();
142
+ if (e.key === "Escape") {
143
+ e.target.value = childName;
144
+ e.target.blur();
145
+ }
146
+ }}
147
+ ></sp-textfield>
148
+ ${typePickerTpl(type, (newType) => {
149
+ if (handlers.onChangeNestedType)
150
+ handlers.onChangeNestedType(parentName, childName, newType);
151
+ })}
152
+ <sp-switch
153
+ size="s"
154
+ ?checked=${isRequired}
155
+ @change=${() => {
156
+ if (handlers.onToggleNestedRequired)
157
+ handlers.onToggleNestedRequired(parentName, childName);
158
+ }}
159
+ >
160
+ Req
161
+ </sp-switch>
162
+ <sp-action-button
163
+ size="xs"
164
+ quiet
165
+ title="Delete field"
166
+ @click=${() => {
167
+ if (handlers.onDeleteNested) handlers.onDeleteNested(parentName, childName);
168
+ }}
169
+ >
170
+ <sp-icon-delete slot="icon"></sp-icon-delete>
171
+ </sp-action-button>
172
+ </div>
173
+ </div>
174
+ `;
175
+ }
176
+
177
+ /**
178
+ * Render an inline "Add Field" row for nested objects.
179
+ *
180
+ * @param {string} parentName
181
+ * @param {FieldHandlers} handlers
182
+ * @returns {any}
183
+ */
184
+ function nestedAddFieldTpl(parentName, handlers) {
185
+ return html`
186
+ <div class="schema-nested-add">
187
+ <sp-textfield
188
+ size="s"
189
+ placeholder="field name"
190
+ class="schema-nested-add-name"
191
+ @keydown=${(/** @type {any} */ e) => {
192
+ if (e.key === "Enter") {
193
+ const row = e.target.closest(".schema-nested-add");
194
+ const name = e.target.value.trim();
195
+ const typePicker = row?.querySelector("sp-picker");
196
+ const type = typePicker?.value || "string";
197
+ if (name && handlers.onAddNestedField) {
198
+ handlers.onAddNestedField(parentName, { name, type, required: false });
199
+ e.target.value = "";
200
+ }
201
+ }
202
+ }}
203
+ ></sp-textfield>
204
+ ${typePickerTpl("string", () => {})}
205
+ <sp-action-button
206
+ size="xs"
207
+ quiet
208
+ title="Add nested field"
209
+ @click=${(/** @type {any} */ e) => {
210
+ const row = e.target.closest(".schema-nested-add");
211
+ const nameInput = /** @type {any} */ (row?.querySelector(".schema-nested-add-name"));
212
+ const typePicker = /** @type {any} */ (row?.querySelector("sp-picker"));
213
+ const name = nameInput?.value?.trim();
214
+ const type = typePicker?.value || "string";
215
+ if (name && handlers.onAddNestedField) {
216
+ handlers.onAddNestedField(parentName, { name, type, required: false });
217
+ nameInput.value = "";
218
+ }
219
+ }}
220
+ >
221
+ <sp-icon-add slot="icon"></sp-icon-add>
222
+ </sp-action-button>
223
+ </div>
224
+ `;
225
+ }
226
+
227
+ /**
228
+ * Render the type picker as an sp-picker dropdown.
229
+ *
230
+ * @param {string} value
231
+ * @param {(type: string) => void} onChange
232
+ * @returns {any}
233
+ */
234
+ export function typePickerTpl(value, onChange) {
235
+ return html`
236
+ <sp-picker
237
+ size="s"
238
+ label="Type"
239
+ value=${value}
240
+ @change=${(/** @type {any} */ e) => onChange(e.target.value)}
241
+ >
242
+ ${FIELD_TYPES.map((t) => html`<sp-menu-item value=${t}>${t}</sp-menu-item>`)}
243
+ </sp-picker>
244
+ `;
245
+ }
246
+
247
+ /**
248
+ * Render the add-field form (inline, not a dialog).
249
+ *
250
+ * @param {{ name: string; type: string; required: boolean }} state
251
+ * @param {{
252
+ * onInput: (field: string, value: any) => void;
253
+ * onConfirm: () => void;
254
+ * onCancel: () => void;
255
+ * }} handlers
256
+ * @returns {any}
257
+ */
258
+ export function addFieldFormTpl(state, handlers) {
259
+ return html`
260
+ <div class="schema-add-field">
261
+ <sp-textfield
262
+ size="s"
263
+ placeholder="Field name"
264
+ .value=${state.name}
265
+ @input=${(/** @type {any} */ e) => handlers.onInput("name", e.target.value)}
266
+ @keydown=${(/** @type {any} */ e) => {
267
+ if (e.key === "Enter") handlers.onConfirm();
268
+ if (e.key === "Escape") handlers.onCancel();
269
+ }}
270
+ ></sp-textfield>
271
+ ${typePickerTpl(state.type, (t) => handlers.onInput("type", t))}
272
+ <sp-switch
273
+ size="s"
274
+ ?checked=${state.required}
275
+ @change=${(/** @type {any} */ e) => handlers.onInput("required", e.target.checked)}
276
+ >
277
+ Required
278
+ </sp-switch>
279
+ <sp-action-button size="s" @click=${handlers.onConfirm}>Add</sp-action-button>
280
+ <sp-action-button size="s" quiet @click=${handlers.onCancel}>Cancel</sp-action-button>
281
+ </div>
282
+ `;
283
+ }
284
+
285
+ /**
286
+ * Build a JSON Schema property definition from a type string.
287
+ *
288
+ * @param {string} type
289
+ * @returns {object}
290
+ */
291
+ export function schemaForType(type) {
292
+ switch (type) {
293
+ case "number":
294
+ return { type: "number" };
295
+ case "boolean":
296
+ return { type: "boolean" };
297
+ case "array":
298
+ return { type: "array", items: { type: "string" } };
299
+ case "object":
300
+ return { type: "object", properties: {}, required: [] };
301
+ case "date":
302
+ return { type: "string", format: "date" };
303
+ default:
304
+ return { type: "string" };
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Generate a YAML frontmatter default value for a given schema type.
310
+ *
311
+ * @param {string} type
312
+ * @param {string} [format]
313
+ * @returns {string}
314
+ */
315
+ export function yamlDefault(type, format) {
316
+ if (format === "date") return new Date().toISOString().split("T")[0];
317
+ switch (type) {
318
+ case "boolean":
319
+ return "false";
320
+ case "number":
321
+ return "0";
322
+ case "array":
323
+ return "[]";
324
+ case "object":
325
+ return "{}";
326
+ default:
327
+ return '""';
328
+ }
329
+ }
package/src/state.js CHANGED
@@ -129,8 +129,8 @@ export function flattenTree(doc, path = [], depth = 0) {
129
129
  /** @type {{ node: any; path: JxPath; depth: number; nodeType: string }[]} */
130
130
  const rows = [{ node: doc, path, depth, nodeType: "element" }];
131
131
 
132
- // Custom component instances are atomic in the layer tree — don't recurse into internals
133
- if (doc.$props && (doc.tagName || "").includes("-")) {
132
+ // Custom component instances without user-authored children are atomic in the layer tree
133
+ if (doc.$props && (doc.tagName || "").includes("-") && !Array.isArray(doc.children)) {
134
134
  return rows;
135
135
  }
136
136
 
@@ -226,10 +226,63 @@ export function createState(doc) {
226
226
  stylebookTab: "elements", // "elements" | "variables"
227
227
  stylebookFilter: "", // search filter text
228
228
  stylebookCustomizedOnly: false, // show only customized elements
229
+ settingsTab: "stylebook", // "stylebook" | "definitions" | "collections"
229
230
  },
230
231
  };
231
232
  }
232
233
 
234
+ // ─── Doc/Session slice helpers ───────────────────────────────────────────────
235
+
236
+ /**
237
+ * Compose a flat StudioState from separate doc and session slices.
238
+ *
239
+ * @param {any} doc
240
+ * @param {any} session
241
+ * @returns {StudioState}
242
+ */
243
+ export function toFlat(doc, session) {
244
+ return { ...doc, ...session };
245
+ }
246
+
247
+ /**
248
+ * Decompose a flat StudioState into doc and session slices.
249
+ *
250
+ * @param {StudioState} S
251
+ * @returns {{ doc: any; session: any }}
252
+ */
253
+ export function fromFlat(S) {
254
+ const {
255
+ document,
256
+ dirty,
257
+ fileHandle,
258
+ documentPath,
259
+ documentStack,
260
+ handlersSource,
261
+ mode,
262
+ content,
263
+ history,
264
+ historyIndex,
265
+ selection,
266
+ hover,
267
+ ui,
268
+ } = S;
269
+ return {
270
+ doc: {
271
+ document,
272
+ dirty,
273
+ fileHandle,
274
+ documentPath,
275
+ documentStack,
276
+ handlersSource,
277
+ mode,
278
+ content,
279
+ history,
280
+ historyIndex,
281
+ },
282
+ session: { selection, hover, ui },
283
+ };
284
+ }
285
+
233
286
  // ─── Project state (persists across document switches) ────────────────────────
234
287
  //
235
288
  // Shape: { root, name, projectRoot, isSiteProject, projectConfig,
@@ -245,6 +298,28 @@ export function setProjectState(ps) {
245
298
  projectState = ps;
246
299
  }
247
300
 
301
+ // ─── Frontmatter mutation ───────────────────────────────────────────────────
302
+
303
+ /**
304
+ * Update a frontmatter field. Does not use applyMutation because frontmatter lives in S.content,
305
+ * not S.document.
306
+ *
307
+ * @param {StudioState} state
308
+ * @param {string} field
309
+ * @param {any} value
310
+ * @returns {StudioState}
311
+ */
312
+ export function updateFrontmatter(state, field, value) {
313
+ const fm = { ...state.content?.frontmatter };
314
+ if (value === undefined || value === null || value === "") delete fm[field];
315
+ else fm[field] = value;
316
+ return {
317
+ ...state,
318
+ content: { ...state.content, frontmatter: fm },
319
+ dirty: true,
320
+ };
321
+ }
322
+
248
323
  // ─── Core mutation ────────────────────────────────────────────────────────────
249
324
 
250
325
  /**
@@ -377,6 +452,28 @@ export function duplicateNode(state, path) {
377
452
  return selectNode(newState, [...elemPath, "children", idx + 1]);
378
453
  }
379
454
 
455
+ /**
456
+ * Wrap the node at `path` in a new wrapper element (e.g. a div).
457
+ *
458
+ * @param {StudioState} state
459
+ * @param {JxPath} path
460
+ * @param {string} wrapperTag
461
+ * @returns {StudioState}
462
+ */
463
+ export function wrapNode(state, path, wrapperTag = "div") {
464
+ if (!path || path.length < 2) return state;
465
+ const node = getNodeAtPath(state.document, path);
466
+ if (!node) return state;
467
+ const elemPath = /** @type {JxPath} */ (parentElementPath(path));
468
+ const idx = /** @type {number} */ (childIndex(path));
469
+ const wrapper = { tagName: wrapperTag, children: [structuredClone(node)] };
470
+ const newState = applyMutation(state, (doc) => {
471
+ const parent = getNodeAtPath(doc, elemPath);
472
+ parent.children.splice(idx, 1, wrapper);
473
+ });
474
+ return selectNode(newState, [...elemPath, "children", idx]);
475
+ }
476
+
380
477
  /**
381
478
  * @param {StudioState} state
382
479
  * @param {JxPath} fromPath
package/src/store.js CHANGED
@@ -17,6 +17,7 @@ export {
17
17
  insertNode,
18
18
  removeNode,
19
19
  duplicateNode,
20
+ wrapNode,
20
21
  moveNode,
21
22
  updateProperty,
22
23
  updateStyle,
@@ -46,6 +47,9 @@ export {
46
47
  isAncestor,
47
48
  projectState,
48
49
  setProjectState,
50
+ updateFrontmatter,
51
+ toFlat,
52
+ fromFlat,
49
53
  } from "./state.js";
50
54
 
51
55
  // ─── DOM shortcuts & element refs ────────────────────────────────────────────
@@ -60,45 +64,6 @@ export const rightPanel = /** @type {any} */ (document.querySelector("#right-pan
60
64
  export const toolbarEl = /** @type {any} */ (document.querySelector("#toolbar"));
61
65
  export const statusbarEl = /** @type {any} */ (document.querySelector("#statusbar"));
62
66
 
63
- // ─── Shared mutable state container ─────────────────────────────────────────
64
- // A plain object so all importers share the same reference and see mutations.
65
- // Used by extracted modules; studio.js keeps local aliases during migration.
66
-
67
- /**
68
- * @type {{
69
- * S: any;
70
- * canvasMode: string;
71
- * panX: number;
72
- * panY: number;
73
- * panzoomWrap: any;
74
- * componentInlineEdit: any;
75
- * pendingInlineEdit: any;
76
- * monacoEditor: any;
77
- * functionEditor: any;
78
- * liveScope: any;
79
- * blockActionBarEl: any;
80
- * inlineEditCleanup: any;
81
- * selDragCleanup: any;
82
- * componentSlashMenu: any;
83
- * }}
84
- */
85
- export const ctx = {
86
- S: undefined,
87
- canvasMode: "design",
88
- panX: 0,
89
- panY: 0,
90
- panzoomWrap: null,
91
- componentInlineEdit: null,
92
- pendingInlineEdit: null,
93
- monacoEditor: null,
94
- functionEditor: null,
95
- liveScope: null,
96
- blockActionBarEl: null,
97
- inlineEditCleanup: null,
98
- selDragCleanup: null,
99
- componentSlashMenu: null,
100
- };
101
-
102
67
  // ─── Shared containers (mutated in place by owner modules) ───────────────────
103
68
 
104
69
  /** WeakMap<HTMLElement, Array> — maps rendered DOM elements to their JSON paths */
@@ -225,7 +190,13 @@ export function registerRenderer(name, fn) {
225
190
 
226
191
  /** Call all registered renderers (full repaint). */
227
192
  export function render() {
228
- for (const fn of _renderers.values()) fn();
193
+ for (const [name, fn] of _renderers.entries()) {
194
+ try {
195
+ fn();
196
+ } catch (e) {
197
+ console.error(`Renderer "${name}" failed:`, e);
198
+ }
199
+ }
229
200
  }
230
201
 
231
202
  /**
@@ -236,7 +207,12 @@ export function render() {
236
207
  export function renderOnly(...names) {
237
208
  for (const name of names) {
238
209
  const fn = _renderers.get(name);
239
- if (fn) fn();
210
+ if (!fn) continue;
211
+ try {
212
+ fn();
213
+ } catch (e) {
214
+ console.error(`Renderer "${name}" failed:`, e);
215
+ }
240
216
  }
241
217
  }
242
218
 
@@ -288,6 +264,101 @@ export function update(newState) {
288
264
  _updateFn(newState);
289
265
  }
290
266
 
267
+ // ─── Session dispatch (late-bound) ──────────────────────────────────────────
268
+ // Lightweight dispatcher for session-only changes (selection, hover, ui).
269
+ // Does NOT trigger autosave middleware or push history.
270
+
271
+ /** @type {Function} */
272
+ let _updateSessionFn = () => {
273
+ throw new Error("updateSession() called before setUpdateSessionFn() — bootstrap not complete");
274
+ };
275
+
276
+ /** @type {Function} */
277
+ let _getDocFn = () => null;
278
+
279
+ /** @type {Function} */
280
+ let _getSessionFn = () => null;
281
+
282
+ /** @param {Function} fn */
283
+ export function setUpdateSessionFn(fn) {
284
+ _updateSessionFn = fn;
285
+ }
286
+
287
+ /** @param {Function} fn */
288
+ export function setGetDocFn(fn) {
289
+ _getDocFn = fn;
290
+ }
291
+
292
+ /** @param {Function} fn */
293
+ export function setGetSessionFn(fn) {
294
+ _getSessionFn = fn;
295
+ }
296
+
297
+ /** @returns {any} */
298
+ export function getDoc() {
299
+ return _getDocFn();
300
+ }
301
+
302
+ /** @returns {any} */
303
+ export function getSession() {
304
+ return _getSessionFn();
305
+ }
306
+
307
+ /**
308
+ * Dispatch a session-only state update (selection, hover, ui). Does not trigger autosave.
309
+ *
310
+ * @param {any} patch — partial session object, e.g. { ui: { zoom: 2 } }
311
+ */
312
+ export function updateSession(patch) {
313
+ _updateSessionFn(patch);
314
+ }
315
+
316
+ /**
317
+ * Update a single UI field and re-render. Routes through session dispatch (no autosave, no
318
+ * history).
319
+ *
320
+ * @param {string} field
321
+ * @param {any} value
322
+ */
323
+ export function updateUi(field, value) {
324
+ _updateSessionFn({ ui: { [field]: value } });
325
+ }
326
+
327
+ // ─── Subscription system ────────────────────────────────────────────────────
328
+ // Panels subscribe to state changes and decide when to re-render, rather than
329
+ // being called unconditionally from _update/_updateSession.
330
+
331
+ /** @typedef {{ doc: boolean; selection: boolean; hover: boolean; ui: boolean; mode: boolean }} Change */
332
+
333
+ /** @type {Set<(change: Change) => void>} */
334
+ const _subscribers = new Set();
335
+
336
+ /**
337
+ * Subscribe to state changes. Returns an unsubscribe function.
338
+ *
339
+ * @param {(change: Change) => void} fn
340
+ * @returns {() => void}
341
+ */
342
+ export function subscribe(fn) {
343
+ _subscribers.add(fn);
344
+ return () => _subscribers.delete(fn);
345
+ }
346
+
347
+ /**
348
+ * Notify all subscribers of a state change.
349
+ *
350
+ * @param {Change} change
351
+ */
352
+ export function notify(change) {
353
+ for (const fn of _subscribers) {
354
+ try {
355
+ fn(change);
356
+ } catch (e) {
357
+ console.error("Subscriber failed:", e);
358
+ }
359
+ }
360
+ }
361
+
291
362
  /** @type {Function[]} */
292
363
  const _updateMiddleware = [];
293
364