bireactive 0.3.3 → 0.3.5

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.
@@ -56,23 +56,23 @@ export class Str extends Cell {
56
56
  * write. A write's own edge whitespace is stripped first. */
57
57
  trim() {
58
58
  return Str.lens(this, {
59
- init: (s) => {
59
+ complement: (s) => {
60
60
  const lead = /^\s*/.exec(s)?.[0] ?? "";
61
61
  // Slice lead off first so trail can't overlap it on all-whitespace.
62
62
  const remain = s.slice(lead.length);
63
63
  const trail = /\s*$/.exec(remain)?.[0] ?? "";
64
64
  return { lead, trail };
65
65
  },
66
- fwd: (s) => {
66
+ // `get` is the sole refresh: re-capture the edge padding (pure idempotent).
67
+ get: (s, c) => {
67
68
  const lead = /^\s*/.exec(s)?.[0] ?? "";
68
69
  const remain = s.slice(lead.length);
69
70
  const trail = /\s*$/.exec(remain)?.[0] ?? "";
71
+ c.lead = lead;
72
+ c.trail = trail;
70
73
  return remain.slice(0, remain.length - trail.length);
71
74
  },
72
- bwd: (target, _s, c) => ({
73
- update: c.lead + target.replace(/^\s+/, "").replace(/\s+$/, "") + c.trail,
74
- complement: c,
75
- }),
75
+ put: (target, _s, c) => c.lead + target.replace(/^\s+/, "").replace(/\s+$/, "") + c.trail,
76
76
  });
77
77
  }
78
78
  /** Windowed view `s.slice(start, end)` (JS semantics). A write splices the
@@ -6,13 +6,13 @@
6
6
  // value this text last agreed with (by identity — the hub only changes
7
7
  // identity on a real change). The spoke:
8
8
  //
9
- // bwd — parse the written text. Clean ⇒ push the recovered value to
10
- // the hub. Errors ⇒ `updates: [SKIP]` (hub untouched),
11
- // but the complement keeps the broken text so the view echoes
12
- // it back instead of trampling the editor.
13
- // step — when the hub moved away from `synced`, absorb it by
14
- // three-way surgical merge around any error regions.
15
- // fwd — the complement's text.
9
+ // put — parse the written text. Clean ⇒ push the recovered value to
10
+ // the hub. Errors ⇒ `SKIP` (hub untouched), but the complement
11
+ // keeps the broken text so the view echoes it back instead of
12
+ // trampling the editor.
13
+ // get — when the hub moved away from `synced`, absorb it by three-way
14
+ // surgical merge around any error regions (the sole refresh,
15
+ // idempotent once synced); then emit the complement's text.
16
16
  import { cell, lens, SKIP } from "../core/cell.js";
17
17
  import { deepEqual, mergeText, valueOf, } from "./cst.js";
18
18
  function fromValue(adapter, v) {
@@ -36,16 +36,24 @@ export function valueHub(initial) {
36
36
  * the error regions. */
37
37
  export function formatSpoke(hub, adapter) {
38
38
  return lens(hub, {
39
- init: v => fromValue(adapter, v),
40
- step: (v, c) => (v === c.synced ? c : absorb(adapter, c, v)),
41
- fwd: (_v, c) => c.text,
42
- bwd: (target, _v, c) => {
39
+ complement: (v) => fromValue(adapter, v),
40
+ // `get` is the sole refresh: when the hub moved off `synced`, absorb it by
41
+ // surgical merge (idempotent once `synced === v`, re-reading is a no-op).
42
+ get: (v, c) => {
43
+ if (v !== c.synced)
44
+ Object.assign(c, absorb(adapter, c, v));
45
+ return c.text;
46
+ },
47
+ put: (target, _v, c) => {
43
48
  const { tree, errors } = adapter.parse(target);
44
49
  if (errors.length === 0) {
45
50
  const v = valueOf(tree);
46
- return { update: v, complement: { text: target, tree, errors, synced: v } };
51
+ Object.assign(c, { text: target, tree, errors, synced: v });
52
+ return v;
47
53
  }
48
- return { update: SKIP, complement: { text: target, tree, errors, synced: c.synced } };
54
+ // Broken edit: keep the text (so the view echoes it) but hold `synced`.
55
+ Object.assign(c, { text: target, tree, errors });
56
+ return SKIP;
49
57
  },
50
58
  });
51
59
  }
@@ -118,30 +118,59 @@ function reactiveText(get) {
118
118
  }));
119
119
  return node;
120
120
  }
121
- /** Two-way bind a form control to a writable cell: read forward into the
122
- * control, write back on input. The forward write is skipped while the
123
- * control is focused, so a live edit is never clobbered mid-drag (the
121
+ /** Two-way bind a form control or `contenteditable` to a writable cell: read
122
+ * forward into the control, write back on input. The forward write is skipped
123
+ * while the control is focused, so a live edit is never clobbered mid-drag (the
124
124
  * controlled-input focus guard, written once). */
125
125
  function bindLens(el, lens) {
126
- const checkbox = el.type === "checkbox";
126
+ if (el.isContentEditable)
127
+ return bindContentEditable(el, lens);
128
+ const input = el;
129
+ const checkbox = input.type === "checkbox";
127
130
  track(effect(() => {
128
131
  const v = lens.value;
129
132
  if (checkbox) {
130
- el.checked = !!v;
133
+ input.checked = !!v;
131
134
  return;
132
135
  }
133
136
  const next = v == null ? "" : String(v);
134
- const root = el.getRootNode();
135
- if (root.activeElement !== el && el.value !== next)
136
- el.value = next;
137
+ const root = input.getRootNode();
138
+ if (root.activeElement !== input && input.value !== next)
139
+ input.value = next;
137
140
  }));
138
- const evt = checkbox || el.tagName === "SELECT" ? "change" : "input";
139
- el.addEventListener(evt, () => {
141
+ const evt = checkbox || input.tagName === "SELECT" ? "change" : "input";
142
+ input.addEventListener(evt, () => {
140
143
  lens.value = checkbox
141
- ? el.checked
142
- : el.type === "range" || el.type === "number"
143
- ? Number(el.value)
144
- : el.value;
144
+ ? input.checked
145
+ : input.type === "range" || input.type === "number"
146
+ ? Number(input.value)
147
+ : input.value;
148
+ });
149
+ }
150
+ /** Bind a `contenteditable` element to a writable string cell via `textContent`.
151
+ * Forward writes are skipped while focused or mid IME composition; the back-write
152
+ * fires on input and at composition end. Assumes plaintext — prefer
153
+ * `contenteditable="plaintext-only"` where supported; rich markup is the caller's. */
154
+ function bindContentEditable(el, lens) {
155
+ let composing = false;
156
+ track(effect(() => {
157
+ const v = lens.value;
158
+ const next = v == null ? "" : String(v);
159
+ const root = el.getRootNode();
160
+ if (root.activeElement !== el && el.textContent !== next)
161
+ el.textContent = next;
162
+ }));
163
+ const writeBack = () => {
164
+ if (!composing)
165
+ lens.value = el.textContent ?? "";
166
+ };
167
+ el.addEventListener("input", writeBack);
168
+ el.addEventListener("compositionstart", () => {
169
+ composing = true;
170
+ });
171
+ el.addEventListener("compositionend", () => {
172
+ composing = false;
173
+ writeBack();
145
174
  });
146
175
  }
147
176
  /** Render `component` into `host`, collecting reactive teardowns. The returned
@@ -172,6 +201,50 @@ export function scope(fn) {
172
201
  currentOwner = prev;
173
202
  }
174
203
  }
204
+ /** Bring `parent`'s children to exactly `nodes` in order, touching the DOM only
205
+ * where it differs. Relocates existing nodes with `moveBefore` (preserves focus,
206
+ * selection, and IME composition on items that didn't move); inserts fresh nodes
207
+ * and falls back to `replaceChildren` where `moveBefore` is unavailable. */
208
+ function reorder(parent, nodes) {
209
+ // Identical order — re-inserting would steal focus and reset clicks mid-interaction.
210
+ const cur = parent.childNodes;
211
+ let same = cur.length === nodes.length;
212
+ for (let i = 0; same && i < nodes.length; i++)
213
+ same = cur[i] === nodes[i];
214
+ if (same)
215
+ return;
216
+ const move = parent.moveBefore;
217
+ if (typeof move !== "function") {
218
+ parent.replaceChildren(...nodes);
219
+ return;
220
+ }
221
+ const wanted = new Set(nodes);
222
+ for (let i = cur.length - 1; i >= 0; i--) {
223
+ const n = cur[i];
224
+ if (!wanted.has(n))
225
+ n.remove();
226
+ }
227
+ let ref = parent.firstChild;
228
+ for (const node of nodes) {
229
+ if (node === ref) {
230
+ ref = ref.nextSibling;
231
+ continue;
232
+ }
233
+ // moveBefore relocates an already-connected node; brand-new nodes (and the
234
+ // disconnected/cross-document cases that make moveBefore throw) use insertBefore.
235
+ if (node.parentNode === parent) {
236
+ try {
237
+ move.call(parent, node, ref);
238
+ }
239
+ catch {
240
+ parent.insertBefore(node, ref);
241
+ }
242
+ }
243
+ else {
244
+ parent.insertBefore(node, ref);
245
+ }
246
+ }
247
+ }
175
248
  /** Keyed list rendering: keep `parent`'s children in sync with a reactive array,
176
249
  * reusing and reordering nodes by key, disposing those that leave. Each item is
177
250
  * rendered in its own `scope` (untracked from the list effect, so item-internal
@@ -201,14 +274,7 @@ export function each(parent, items, key, render) {
201
274
  cache.delete(k);
202
275
  }
203
276
  }
204
- // Only touch the DOM when the ordered node set actually changed — re-inserting
205
- // identical children mid-interaction would steal focus and reset clicks.
206
- const cur = parent.childNodes;
207
- let same = cur.length === nodes.length;
208
- for (let i = 0; same && i < nodes.length; i++)
209
- same = cur[i] === nodes[i];
210
- if (!same)
211
- parent.replaceChildren(...nodes);
277
+ reorder(parent, nodes);
212
278
  });
213
279
  track(() => {
214
280
  stop();
@@ -63,10 +63,8 @@ function denseBackward(p, inDim, outDim, act, x, dOut) {
63
63
  function denseLens(params, input, inDim, outDim, act, cfg) {
64
64
  const parents = [params, input];
65
65
  return lens(parents, {
66
- init: () => null,
67
- step: (_s, c) => c,
68
- fwd: (s) => denseForward(s[0], inDim, outDim, act, s[1]),
69
- bwd: (cot, s, c) => {
66
+ get: (s) => denseForward(s[0], inDim, outDim, act, s[1]),
67
+ put: (cot, s) => {
70
68
  const { dIn, gW, gb } = denseBackward(s[0], inDim, outDim, act, s[1], cot);
71
69
  let pUpd;
72
70
  if (cfg.frozen) {
@@ -82,7 +80,7 @@ function denseLens(params, input, inDim, outDim, act, cfg) {
82
80
  b[o] = b[o] - lr * gb[o];
83
81
  pUpd = { W, b };
84
82
  }
85
- return { updates: [pUpd, dIn], complement: c };
83
+ return [pUpd, dIn];
86
84
  },
87
85
  });
88
86
  }
@@ -91,7 +91,10 @@ export declare function each(elem: VLens<Obj, Obj, any>): VLens<Obj[], Obj[], un
91
91
  * for nested occurrences — e.g. rename a field at every level of a subtask
92
92
  * tree of arbitrary depth. Cambria's open "recursive schemas" case. */
93
93
  export declare function recurse(build: (self: OLens) => OLens): OLens;
94
- /** Lift a value-lens onto a reactive cell. */
94
+ /** Lift a value-lens onto a reactive cell. The value-level complement `C` is
95
+ * functional (heterogeneous products, possibly `null`), so a small engine-side
96
+ * holder `{ c }` carries it: `get`/`put` swap `holder.c` in place. `get` is the
97
+ * sole refresh (folds `step`; idempotent when `step` is). */
95
98
  export declare function toStep<C>(vl: VLens<Obj, Obj, C>): Step;
96
99
  /** A migration step: a writable lens from one POJO schema to the next. */
97
100
  export type Step = (src: Writable<Cell<Obj>>) => Writable<Cell<Obj>>;
@@ -369,15 +369,22 @@ export function recurse(build) {
369
369
  return self;
370
370
  }
371
371
  // ── reactive lifting (cells) ─────────────────────────────────────────
372
- /** Lift a value-lens onto a reactive cell. */
372
+ /** Lift a value-lens onto a reactive cell. The value-level complement `C` is
373
+ * functional (heterogeneous products, possibly `null`), so a small engine-side
374
+ * holder `{ c }` carries it: `get`/`put` swap `holder.c` in place. `get` is the
375
+ * sole refresh (folds `step`; idempotent when `step` is). */
373
376
  export function toStep(vl) {
374
377
  return src => lens(src, {
375
- init: v => vl.init(v),
376
- step: (v, c) => (vl.step ? vl.step(v, c) : c),
377
- fwd: (v, c) => vl.fwd(v, c),
378
- bwd: (t, v, c) => {
379
- const r = vl.bwd(t, v, c);
380
- return { update: r.s, complement: r.c };
378
+ complement: (v) => ({ c: vl.init(v) }),
379
+ get: (v, h) => {
380
+ if (vl.step)
381
+ h.c = vl.step(v, h.c);
382
+ return vl.fwd(v, h.c);
383
+ },
384
+ put: (t, v, h) => {
385
+ const r = vl.bwd(t, v, h.c);
386
+ h.c = r.c;
387
+ return r.s;
381
388
  },
382
389
  });
383
390
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bireactive",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "Bi-directional reactive programming.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",