@mocanvas/state 1.0.0 → 3.1.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @mocanvas/state
2
2
 
3
- The signals library behind [mocanvas](https://github.com/SYMBIO/mocanvas):
3
+ The signals library behind mocanvas:
4
4
  atoms, computed values, transactions, reactors, and optional React bindings.
5
5
  No dependencies.
6
6
 
@@ -42,8 +42,9 @@ function OtherCount() {
42
42
  }
43
43
  ```
44
44
 
45
- ESM only. See the [repository](https://github.com/SYMBIO/mocanvas) for the
46
- rest of the packages.
45
+ ESM only. The rest of the family: `@mocanvas/mocanvas` (the batteries-included
46
+ canvas), `@mocanvas/editor`, `@mocanvas/store`, `@mocanvas/wasm`,
47
+ `@mocanvas/sync` and `@mocanvas/compat`.
47
48
 
48
49
  ## License
49
50
 
package/UI.md ADDED
@@ -0,0 +1,256 @@
1
+ # The default UI
2
+
3
+ `<Mocanvas />` ships a small, opinionated interface: a toolbar, a zoom bar, a
4
+ style panel, an optional statistics chip, and the chrome the editor draws on the
5
+ canvas itself. All of it is optional and all of it is themeable from CSS.
6
+
7
+ Source lives in `packages/mocanvas/src/ui/`:
8
+
9
+ | File | What it holds |
10
+ | --- | --- |
11
+ | `DefaultUi.tsx` | The toolbar, the zoom bar, the stats chip, and the `DefaultUi` wrapper |
12
+ | `StylePanel.tsx` | The style panel and `getStylePanelSections`, the rule for which rows appear |
13
+ | `icons.tsx` | The whole icon set, plus the geo icons generated from canvas geometry |
14
+ | `overlays.tsx` | The shared tooltip and popover layers, and `placeNear` |
15
+ | `ui.css` | Every design token and every rule |
16
+ | `useKeyboardShortcuts.ts` | The default key bindings |
17
+
18
+ ## Design tokens
19
+
20
+ Tokens are declared on `.mocanvas` (the editor container), on `.mocanvas-panel`,
21
+ and on `.mocanvas-layer` (the floating tooltip and popover, which sit outside the
22
+ panels in the DOM). Override any of them on `.mocanvas`, on a wrapper, or on a
23
+ single panel.
24
+
25
+ ### Surfaces and colour
26
+
27
+ | Token | Light | Dark | Used for |
28
+ | --- | --- | --- | --- |
29
+ | `--mocanvas-ui-panel` | `#ffffff` | `#1b1d22` | Panel, popover and picker background |
30
+ | `--mocanvas-ui-panel-border` | `#d6d9e0` | `#3c404a` | Panel hairline, dividers, group separators |
31
+ | `--mocanvas-ui-control` | `rgba(16,24,40,.06)` | `rgba(255,255,255,.08)` | Recessed background of a segmented button |
32
+ | `--mocanvas-ui-text` | `#16181d` | `#eceef3` | Icon and label colour |
33
+ | `--mocanvas-ui-muted` | `#5c6070` | `#a4a9b4` | Row labels, stats text |
34
+ | `--mocanvas-ui-accent` | `#2563eb` | `#6ea8fe` | Selected control, focus ring |
35
+ | `--mocanvas-ui-accent-fg` | `#ffffff` | `#0e1116` | Icon on an accent fill |
36
+ | `--mocanvas-ui-accent-soft` | 12% accent | 18% accent | Hover on a segmented button, open disclosure |
37
+ | `--mocanvas-ui-hover` | 6% ink | 9% white | Hover on a bare button |
38
+ | `--mocanvas-ui-active` | 13% ink | 18% white | Pointer-down on a bare button |
39
+ | `--mocanvas-ui-shadow` | — | — | Panel elevation |
40
+ | `--mocanvas-ui-tip-bg` / `-fg` / `-muted` | — | — | Tooltip pill, its label, its shortcut |
41
+
42
+ Every text-on-background pair is at or above 4.5:1 in both palettes. Measured on
43
+ the shipped values, the tightest are `accent` on a segmented control (4.58 light,
44
+ 5.54 dark) and `muted` on the panel (6.25 light, 7.16 dark). Disabled controls
45
+ render at 35% opacity, which WCAG exempts.
46
+
47
+ ### Metrics
48
+
49
+ | Token | Value | Meaning |
50
+ | --- | --- | --- |
51
+ | `--mocanvas-ui-btn` | `40px` | Every hit target — buttons, swatches, popover cells |
52
+ | `--mocanvas-ui-icon` | `20px` | Icon size inside a button |
53
+ | `--mocanvas-ui-gap` | `2px` | Gap between adjacent controls |
54
+ | `--mocanvas-ui-pad` | `4px` | Padding of a bar |
55
+ | `--mocanvas-ui-pad-lg` | `10px` | Padding of the style panel, gap between its groups |
56
+ | `--mocanvas-ui-inset` | `12px` | Distance from a panel to the viewport edge |
57
+ | `--mocanvas-ui-radius` / `-sm` | `12px` / `8px` | Panel radius / control radius |
58
+ | `--mocanvas-ui-dock` | `300px` | Width the bottom-left and bottom-right docks reserve beside the centred toolbar |
59
+ | `--mocanvas-ui-bottom-dock` | `62px` (`124px` under 1290px) | Height the bottom edge occupies; the style panel stops above it |
60
+ | `--mocanvas-ui-font` / `-mono` | system stacks | Panel type / stats chip |
61
+
62
+ `--mocanvas-ui-dock` is the mechanism that keeps the three bottom panels apart:
63
+ the toolbar is centred on the viewport but may not grow into the reservation, so
64
+ it wraps rather than collide. Shrink it if your zoom bar is narrower than the
65
+ default one.
66
+
67
+ ### Canvas chrome
68
+
69
+ These are read by the editor's indicator layer (`Canvas.tsx`), not by the panels,
70
+ so they apply even with `hideUi`. They are deliberately **not** redefined for the
71
+ dark UI: the canvas keeps its own background, which does not follow
72
+ `prefers-color-scheme`.
73
+
74
+ | Token | Value | Used for |
75
+ | --- | --- | --- |
76
+ | `--mocanvas-selection` | `#2f6fe4` | Selection and hover outlines, handle strokes, brush border |
77
+ | `--mocanvas-selection-fg` | `#ffffff` | Fill behind a solid handle |
78
+ | `--mocanvas-brush-fill` | 12% selection | Brush rectangle interior |
79
+ | `--mocanvas-snap` | `#cf3fe0` | Snap lines and their end markers |
80
+
81
+ The two stroke colours clear 3:1 against a light canvas (`#f9fafb`: 4.45 and
82
+ 3.69) and against a dark one (`#1b1d22`: 3.63 and 4.37). If you render a dark
83
+ canvas, swap `--mocanvas-selection-fg` for a dark value so handles stay filled
84
+ with the canvas colour rather than white.
85
+
86
+ Handles are drawn at 9px (corners), 6px (shape handles) and 5.5px (rotate) in
87
+ screen space, with a 1.5px stroke. Their hit radius is a separate editor
88
+ constant, `HANDLE_HIT_RADIUS` in `packages/editor/src/editor/selectionHandles.ts`.
89
+
90
+ ## Icon grid rules
91
+
92
+ Icons are original artwork on a 24×24 viewBox, painted with `currentColor`.
93
+
94
+ - **Grid.** 24×24. Ink, stroke included, stays inside it.
95
+ - **Weight.** Stroke 1.75, round caps and joins. Only texture marks deviate and
96
+ say so at the call site: the fill hatching (1.25), the dotted rule (2.6 with a
97
+ zero-length dash, so the caps draw the dots), the mono rails (1.4).
98
+ - **Centring.** Ink is optically centred on (12, 12), within half a grid unit.
99
+ The only exceptions are semantic: `valign-top` and `valign-bottom` sit high and
100
+ low on purpose.
101
+ - **Extent.** The longest ink dimension lands between 16 and 18.25 units — about
102
+ 17.75 for a full-bleed form — so no icon reads heavier than its neighbour. The
103
+ `size-*` ramp is exempt: its whole job is to differ in size.
104
+ - **Distinctness.** No two icons may draw the same artwork; `icons.test.tsx`
105
+ enforces this. It is why the handwriting font is a script `a` rather than a
106
+ fourth capital A, and why `oval` gets a wider box than `ellipse`.
107
+ - **Geo icons** are generated from `getGeoGeometry`, the same code the canvas
108
+ draws with, so a toolbar button always matches the shape it creates. Each kind
109
+ is fitted into the box `getGeoIconBox(kind)` returns: square at `GEO_BOX` (16)
110
+ by default, and flatter or narrower for kinds whose name implies a proportion
111
+ (`rectangle`, `oval`, the four arrows). The longest side is always `GEO_BOX`.
112
+
113
+ Render one with `<Icon name="select" size={20} />`. `size` sets the SVG's
114
+ attributes; buttons additionally take their icon size from `--mocanvas-ui-icon`,
115
+ so inline marks like the "mixed" badge keep the size they ask for.
116
+
117
+ ## Overriding the UI
118
+
119
+ ### Turn it off
120
+
121
+ ```tsx
122
+ <Mocanvas hideUi />
123
+ ```
124
+
125
+ This drops the toolbar, zoom bar, style panel and stats chip. `ui.css` still
126
+ loads, so the canvas chrome tokens above keep working. Default keyboard
127
+ shortcuts are wired by `<Mocanvas />` itself and are unaffected; call
128
+ `useKeyboardShortcuts(editor)` yourself if you build on `<Canvas />` directly.
129
+
130
+ ### Replace the canvas chrome
131
+
132
+ ```tsx
133
+ <Mocanvas components={{ Indicators: MyIndicators, Brush: MyBrush, Background: MyGrid }} />
134
+ ```
135
+
136
+ `components` is forwarded to `<Canvas />`. `Indicators` draws selection and hover
137
+ outlines and handles, `Brush` the marquee, `Background` a layer behind the canvas.
138
+ Each receives `{ editor }` and renders into the SVG overlay (`Background` into a
139
+ plain DOM layer). Omit one to keep the default.
140
+
141
+ ### Rebuild the panels
142
+
143
+ Compose your own from the exported parts:
144
+
145
+ ```tsx
146
+ import { Mocanvas, Toolbar, ZoomBar, StylePanel, UiTooltip, Icon } from "mocanvas"
147
+
148
+ <Mocanvas hideUi>
149
+ <Toolbar />
150
+ <MyOwnInspector />
151
+ <UiTooltip />
152
+ </Mocanvas>
153
+ ```
154
+
155
+ `Toolbar`, `ZoomBar`, `StylePanel`, `DebugStats`, `Popover`, `UiTooltip`, `Icon`
156
+ and `TOOLBAR_GROUPS` are all exported. Children of `<Mocanvas>` render above the
157
+ canvas inside the editor container, so the tokens apply to them too.
158
+
159
+ ### Restyle it
160
+
161
+ ```css
162
+ .mocanvas {
163
+ --mocanvas-ui-accent: #12b886;
164
+ --mocanvas-ui-radius: 6px;
165
+ --mocanvas-ui-btn: 44px;
166
+ --mocanvas-selection: #12b886;
167
+ }
168
+ ```
169
+
170
+ ## The panels
171
+
172
+ ### Toolbar — bottom centre
173
+
174
+ Tools in four groups separated by dividers: select and hand; draw and eraser; the
175
+ five common geo kinds plus a disclosure for the other fifteen; text, note, and
176
+ whichever of arrow, line and frame the app registered. Entries whose tool is not
177
+ registered disappear, so a cut-down `tools` prop yields a cut-down bar. The
178
+ active tool is `aria-pressed`; a geo button is pressed only when its own kind is
179
+ active. The disclosure opens a 5-column popover of the remaining kinds and is
180
+ itself marked pressed when one of them is active. The bar is centred on the
181
+ viewport, wraps to more rows rather than growing into the docks, and moves to a
182
+ row of its own below 1290px.
183
+
184
+ ### Zoom bar — bottom left
185
+
186
+ Zoom out, the current percentage (a button that resets to 100%), zoom in, zoom to
187
+ fit, then a divider and undo/redo. Undo and redo are `disabled` when there is
188
+ nothing to undo or redo, at 35% opacity and without a tooltip.
189
+
190
+ ### Style panel — top right
191
+
192
+ Appears when the selection carries at least one style, or when a drawing tool is
193
+ active with nothing selected. Rows are grouped, and separated by a hairline:
194
+
195
+ 1. **Shape** — the geo kind, as one button showing the current shape that opens a
196
+ 20-cell popover.
197
+ 2. **Colour** — the stroke colour, and the label colour when the selection can
198
+ carry one; twelve swatches each, six to a row.
199
+ 3. **Stroke and fill** — Fill, Dash and Size, four choices each.
200
+ 4. **Text** — Font, then horizontal and vertical alignment sharing one row.
201
+ 5. **Opacity** — a slider; only with a selection, since it edits shapes rather
202
+ than a style.
203
+
204
+ Which rows appear is decided by `getStylePanelSections(editor)`, which reads
205
+ `editor.getSharedStyles()` for a selection and
206
+ `editor.getStylePropsForType(toolId)` otherwise. A selection of two lines
207
+ therefore shows Colour, Dash and Size and nothing else. A row whose selected
208
+ shapes disagree shows a dashed "mixed" badge beside its label. The panel scrolls
209
+ when it is taller than the space above the bottom dock, with a fade and a shadow
210
+ at whichever edge has more content behind it. Below 560px it spans the width and
211
+ is capped at 42% of the height.
212
+
213
+ ### Statistics chip — bottom right
214
+
215
+ Shape counts, drawn versus culled, and milliseconds per frame. Toggled with
216
+ `⌥D`, or with the `showStats` prop. It moves above the zoom bar below 560px.
217
+
218
+ ### Tooltip and popover
219
+
220
+ Both are single, `position: fixed`, viewport-clamped layers (`overlays.tsx`).
221
+ A tooltip labels any element with `data-tooltip`, adding `data-shortcut` in a
222
+ muted weight; it appears after a 500ms rest, immediately on keyboard focus, sits
223
+ above its control so it never covers it, and flips below when there is no room.
224
+ `placeNear` does the arithmetic and is unit-tested. Popovers dismiss on outside
225
+ pointer-down and on Escape, which also returns focus to the button.
226
+
227
+ ## States
228
+
229
+ | State | Bare button | Segmented button | Swatch |
230
+ | --- | --- | --- | --- |
231
+ | Rest | transparent | `--mocanvas-ui-control` | transparent |
232
+ | Hover | `--mocanvas-ui-hover` | `--mocanvas-ui-accent-soft` | `--mocanvas-ui-hover` |
233
+ | Pointer down | `--mocanvas-ui-active`, scaled 0.94 | as hover, scaled 0.94 | scaled 0.92 |
234
+ | Selected | accent fill, accent-fg icon | accent fill, accent-fg icon | double ring in accent |
235
+ | Selected + hover | accent fill plus an inset ring | same | same |
236
+ | Focus (keyboard) | 2px accent outline, 2px offset | same | same |
237
+ | Disabled | 35% opacity, no tooltip, default cursor | — | — |
238
+
239
+ The focus ring is offset by 2px so a ring of panel colour separates it from an
240
+ accent-filled button; without that gap, focus would be invisible on the active
241
+ tool. `prefers-reduced-motion` removes every transition.
242
+
243
+ ## Pointer targets
244
+
245
+ Selection handles use a 24x24 px pointer target (`HANDLE_HIT_RADIUS = 12` in
246
+ `packages/editor/src/editor/selectionHandles.ts`), which is the WCAG 2.2
247
+ minimum. Two rules keep that from swallowing small shapes:
248
+
249
+ - Edge handles (top, right, bottom, left) only appear once that edge is at
250
+ least `4 * HANDLE_HIT_RADIUS` long on screen; below that the two corners
251
+ already cover the whole edge.
252
+ - On a selection smaller than six handles across, `getHandleHitRadius` scales
253
+ the target down (never below 4 px) so the shape's interior stays draggable.
254
+
255
+ Both measure the *screen-space* edge lengths of the transformed corners, so a
256
+ rotated selection behaves the same as an upright one.
@@ -1,5 +1,78 @@
1
+ // src/diff.ts
2
+ var RESET_VALUE = /* @__PURE__ */ Symbol("RESET_VALUE");
3
+ var WithDiff = class {
4
+ constructor(value, diff) {
5
+ this.value = value;
6
+ this.diff = diff;
7
+ }
8
+ value;
9
+ diff;
10
+ };
11
+ function withDiff(value, diff) {
12
+ return new WithDiff(value, diff);
13
+ }
14
+ function isWithDiff(value) {
15
+ return value instanceof WithDiff;
16
+ }
17
+ var HistoryBuffer = class {
18
+ constructor(capacity) {
19
+ this.capacity = capacity;
20
+ if (!Number.isInteger(capacity) || capacity < 1) {
21
+ throw new Error(`historyLength must be a positive integer, got ${String(capacity)}`);
22
+ }
23
+ this.entries = new Array(capacity);
24
+ }
25
+ capacity;
26
+ entries;
27
+ index = 0;
28
+ /**
29
+ * The earliest epoch this buffer can still answer for. Raised when an entry
30
+ * is evicted (its change is no longer described) and when a change arrives
31
+ * that has no diff at all.
32
+ */
33
+ oldestSafeEpoch = -1;
34
+ /**
35
+ * Record a diff. A `RESET_VALUE` diff means this change cannot be described,
36
+ * so everything up to and including it becomes unanswerable.
37
+ */
38
+ pushEntry(fromEpoch, toEpoch, diff) {
39
+ if (diff === RESET_VALUE) {
40
+ this.clear();
41
+ this.oldestSafeEpoch = toEpoch;
42
+ return;
43
+ }
44
+ const evicted = this.entries[this.index];
45
+ this.entries[this.index] = { fromEpoch, toEpoch, diff };
46
+ this.index = (this.index + 1) % this.capacity;
47
+ if (evicted && evicted.toEpoch > this.oldestSafeEpoch) this.oldestSafeEpoch = evicted.toEpoch;
48
+ }
49
+ clear() {
50
+ this.entries.fill(void 0);
51
+ this.index = 0;
52
+ this.oldestSafeEpoch = -1;
53
+ }
54
+ /**
55
+ * Every diff recorded after `epoch`, oldest first, or {@link RESET_VALUE}
56
+ * when the history does not reach back that far.
57
+ */
58
+ getChangesSince(epoch) {
59
+ if (epoch < this.oldestSafeEpoch) return RESET_VALUE;
60
+ const result = [];
61
+ for (let i = 0; i < this.capacity; i++) {
62
+ const entry = this.entries[(this.index + i) % this.capacity];
63
+ if (!entry) continue;
64
+ if (entry.toEpoch <= epoch) continue;
65
+ result.push(entry.diff);
66
+ }
67
+ return result;
68
+ }
69
+ };
70
+
1
71
  // src/core.ts
2
72
  var UNINITIALIZED = /* @__PURE__ */ Symbol("UNINITIALIZED");
73
+ function isUninitialized(value) {
74
+ return value === UNINITIALIZED;
75
+ }
3
76
  var EMPTY_ARRAY = Object.freeze([]);
4
77
  var globalEpoch = 0;
5
78
  var activeDependent = null;
@@ -57,10 +130,14 @@ var AtomImpl = class {
57
130
  children = /* @__PURE__ */ new Set();
58
131
  value;
59
132
  isEqual;
60
- constructor(name, value, isEqual) {
133
+ history;
134
+ computeDiff;
135
+ constructor(name, value, isEqual, historyLength, computeDiff) {
61
136
  this.name = name;
62
137
  this.value = value;
63
138
  this.isEqual = isEqual ?? defaultIsEqual;
139
+ this.history = historyLength === void 0 ? null : new HistoryBuffer(historyLength);
140
+ this.computeDiff = computeDiff ?? null;
64
141
  }
65
142
  get() {
66
143
  if (activeDependent !== null) activeDependent.parents.add(this);
@@ -69,22 +146,33 @@ var AtomImpl = class {
69
146
  set(value) {
70
147
  if (this.isEqual(this.value, value)) return this.value;
71
148
  if (currentTx !== null) currentTx.record(this, this.value);
72
- this.__write(value);
149
+ this.__write(value, this.diffFor(this.value, value));
73
150
  if (currentTx === null) flushPending();
74
151
  return value;
75
152
  }
76
153
  update(fn) {
77
154
  return this.set(fn(this.value));
78
155
  }
156
+ getDiffSince(epoch) {
157
+ this.get();
158
+ if (epoch >= this.lastChangedEpoch) return [];
159
+ return this.history === null ? RESET_VALUE : this.history.getChangesSince(epoch);
160
+ }
79
161
  /** Restores a value during rollback; skips the write when already equal. */
80
162
  __restore(value) {
81
163
  if (this.isEqual(this.value, value)) return;
82
- this.__write(value);
164
+ this.__write(value, RESET_VALUE);
165
+ }
166
+ diffFor(previous, next) {
167
+ if (this.history === null || this.computeDiff === null) return RESET_VALUE;
168
+ return this.computeDiff(previous, next, this.lastChangedEpoch, globalEpoch + 1);
83
169
  }
84
- __write(value) {
170
+ __write(value, diff) {
171
+ const fromEpoch = this.lastChangedEpoch;
85
172
  globalEpoch++;
86
173
  this.value = value;
87
174
  this.lastChangedEpoch = globalEpoch;
175
+ this.history?.pushEntry(fromEpoch, globalEpoch, diff);
88
176
  for (const child of this.children) child.__notify();
89
177
  }
90
178
  __addChild(child) {
@@ -97,7 +185,13 @@ var AtomImpl = class {
97
185
  }
98
186
  };
99
187
  function atom(name, initialValue, options) {
100
- return new AtomImpl(name, initialValue, options?.isEqual);
188
+ return new AtomImpl(
189
+ name,
190
+ initialValue,
191
+ options?.isEqual,
192
+ options?.historyLength,
193
+ options?.computeDiff
194
+ );
101
195
  }
102
196
  var ComputedImpl = class {
103
197
  name;
@@ -117,16 +211,28 @@ var ComputedImpl = class {
117
211
  isComputing = false;
118
212
  fn;
119
213
  isEqual;
120
- constructor(name, fn, isEqual) {
214
+ history;
215
+ computeDiff;
216
+ constructor(name, fn, isEqual, historyLength, computeDiff) {
121
217
  this.name = name;
122
218
  this.fn = fn;
123
219
  this.isEqual = isEqual ?? defaultIsEqual;
220
+ this.history = historyLength === void 0 ? null : new HistoryBuffer(historyLength);
221
+ this.computeDiff = computeDiff ?? null;
124
222
  }
125
223
  get() {
126
224
  if (activeDependent !== null) activeDependent.parents.add(this);
127
225
  this.__refresh();
128
226
  return this.value;
129
227
  }
228
+ getDiffSince(epoch) {
229
+ this.get();
230
+ if (epoch >= this.lastChangedEpoch) return [];
231
+ return this.history === null ? RESET_VALUE : this.history.getChangesSince(epoch);
232
+ }
233
+ __referenceEpoch() {
234
+ return this.lastCheckedEpoch;
235
+ }
130
236
  __refresh() {
131
237
  const epoch = globalEpoch;
132
238
  if (this.lastCheckedEpoch === epoch) return;
@@ -157,17 +263,31 @@ var ComputedImpl = class {
157
263
  this.isComputing = true;
158
264
  const epoch = globalEpoch;
159
265
  const prev = this.value;
266
+ const lastComputedEpoch = this.lastCheckedEpoch;
160
267
  const outer = beginCapture(this);
161
- let next;
268
+ let returned;
162
269
  try {
163
- next = this.fn(prev);
270
+ returned = this.fn(prev, lastComputedEpoch);
164
271
  } finally {
165
272
  endCapture(this, outer, this.children.size > 0);
166
273
  this.isComputing = false;
167
274
  }
275
+ let next;
276
+ let diff = RESET_VALUE;
277
+ if (isWithDiff(returned)) {
278
+ next = returned.value;
279
+ diff = returned.diff;
280
+ } else {
281
+ next = returned;
282
+ }
168
283
  if (prev === UNINITIALIZED || !this.isEqual(prev, next)) {
284
+ if (this.history !== null && diff === RESET_VALUE && this.computeDiff !== null && prev !== UNINITIALIZED) {
285
+ diff = this.computeDiff(prev, next, this.lastChangedEpoch, epoch);
286
+ }
287
+ const fromEpoch = this.lastChangedEpoch;
169
288
  this.value = next;
170
289
  this.lastChangedEpoch = epoch;
290
+ this.history?.pushEntry(fromEpoch, epoch, prev === UNINITIALIZED ? RESET_VALUE : diff);
171
291
  }
172
292
  this.lastCheckedEpoch = epoch;
173
293
  }
@@ -190,7 +310,13 @@ var ComputedImpl = class {
190
310
  }
191
311
  };
192
312
  function computed(name, fn, options) {
193
- return new ComputedImpl(name, fn, options?.isEqual);
313
+ return new ComputedImpl(
314
+ name,
315
+ fn,
316
+ options?.isEqual,
317
+ options?.historyLength,
318
+ options?.computeDiff
319
+ );
194
320
  }
195
321
  var EffectBase = class {
196
322
  name;
@@ -204,6 +330,9 @@ var EffectBase = class {
204
330
  __notify() {
205
331
  if (this.isActive) pendingEffects.add(this);
206
332
  }
333
+ __referenceEpoch() {
334
+ return this.lastRunEpoch;
335
+ }
207
336
  attachToParents() {
208
337
  for (const p of this.parents) p.__addChild(this);
209
338
  }
@@ -448,7 +577,80 @@ function isAtom(value) {
448
577
  function isComputed(value) {
449
578
  return value instanceof ComputedImpl;
450
579
  }
580
+ function whyAmIRunning() {
581
+ const dependent = activeDependent;
582
+ if (dependent === null) {
583
+ console.warn("[state] whyAmIRunning() was called outside of a computed or effect");
584
+ return;
585
+ }
586
+ const since = dependent.__referenceEpoch();
587
+ const changed = [];
588
+ const unchanged = [];
589
+ for (const parent of dependent.parents) {
590
+ (parent.lastChangedEpoch > since ? changed : unchanged).push(parent.name);
591
+ }
592
+ const lines = [`[state] ${dependent.name} is running because:`];
593
+ for (const name of changed) lines.push(` ${name} changed`);
594
+ for (const name of unchanged) lines.push(` ${name} (unchanged)`);
595
+ if (changed.length === 0 && unchanged.length === 0) lines.push(" (no dependencies captured yet)");
596
+ console.log(lines.join("\n"));
597
+ }
598
+ var computedInstances = /* @__PURE__ */ new WeakMap();
599
+ function getComputedInstance(object, propertyName) {
600
+ let byProperty = computedInstances.get(object);
601
+ if (!byProperty) {
602
+ byProperty = /* @__PURE__ */ new Map();
603
+ computedInstances.set(object, byProperty);
604
+ }
605
+ const existing = byProperty.get(propertyName);
606
+ if (existing) return existing;
607
+ const created = computed(
608
+ `${object.constructor?.name ?? "object"}.${String(propertyName)}`,
609
+ () => object[propertyName]
610
+ );
611
+ byProperty.set(propertyName, created);
612
+ return created;
613
+ }
614
+ function localStorageAtom(key, initialValue, options) {
615
+ const storage = () => {
616
+ try {
617
+ return typeof localStorage === "undefined" ? null : localStorage;
618
+ } catch {
619
+ return null;
620
+ }
621
+ };
622
+ let start = initialValue;
623
+ const store = storage();
624
+ if (store !== null) {
625
+ try {
626
+ const raw = store.getItem(key);
627
+ if (raw !== null) start = JSON.parse(raw);
628
+ } catch {
629
+ start = initialValue;
630
+ }
631
+ }
632
+ const result = atom(key, start, options);
633
+ const write = (value) => {
634
+ const s = storage();
635
+ if (s === null) return;
636
+ try {
637
+ s.setItem(key, JSON.stringify(value));
638
+ } catch {
639
+ }
640
+ };
641
+ react(`localStorageAtom(${key})`, () => write(result.get()));
642
+ if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
643
+ window.addEventListener("storage", (event) => {
644
+ if (event.key !== key || event.newValue === null) return;
645
+ try {
646
+ result.set(JSON.parse(event.newValue));
647
+ } catch {
648
+ }
649
+ });
650
+ }
651
+ return result;
652
+ }
451
653
 
452
- export { EMPTY_ARRAY, RenderTracker, UNINITIALIZED, atom, computed, getGlobalEpoch, getWithoutCapture, isAtom, isComputed, isSignal, react, reactor, subscribeToSignal, transact, transaction, unsafe__withoutCapture };
453
- //# sourceMappingURL=chunk-XCRMDUZF.js.map
454
- //# sourceMappingURL=chunk-XCRMDUZF.js.map
654
+ export { EMPTY_ARRAY, HistoryBuffer, RESET_VALUE, RenderTracker, UNINITIALIZED, WithDiff, atom, computed, getComputedInstance, getGlobalEpoch, getWithoutCapture, isAtom, isComputed, isSignal, isUninitialized, isWithDiff, localStorageAtom, react, reactor, subscribeToSignal, transact, transaction, unsafe__withoutCapture, whyAmIRunning, withDiff };
655
+ //# sourceMappingURL=chunk-KGMTQKZV.js.map
656
+ //# sourceMappingURL=chunk-KGMTQKZV.js.map