@humanspeak/svelte-motion 0.5.1 → 0.5.3

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.
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Local mirrors of `motion-utils`'s `Axis` / `Box` / `AxisDelta` /
3
+ * `Delta` types. Inlined because `motion-utils` is a runtime-only
4
+ * transitive dep through `motion-dom` — the runtime helpers
5
+ * (`calcBoxDelta`, `createDelta`, `isDeltaZero`) are re-exported, but
6
+ * the type aliases are not. Same approach we use in
7
+ * `src/lib/components/Reorder/context.ts`.
8
+ *
9
+ * Values match upstream byte-for-byte so handoff between our types and
10
+ * the runtime helpers from motion-dom is implicit.
11
+ */
12
+ export interface Axis {
13
+ min: number;
14
+ max: number;
15
+ }
16
+ export interface Box {
17
+ x: Axis;
18
+ y: Axis;
19
+ }
20
+ export interface AxisDelta {
21
+ translate: number;
22
+ scale: number;
23
+ origin: number;
24
+ originPoint: number;
25
+ }
26
+ export interface Delta {
27
+ x: AxisDelta;
28
+ y: AxisDelta;
29
+ }
30
+ /**
31
+ * Event names the projection node fans out. Mirrors framer-motion's
32
+ * `LayoutEvents` subset that's actually consumed externally.
33
+ *
34
+ * - `willUpdate` — fires inside `willUpdate()`, AFTER the pre-mutation
35
+ * snapshot has been captured. Receives the snapshot Box.
36
+ * - `didUpdate` — fires inside `didUpdate()`, AFTER the post-mutation
37
+ * re-measure. Receives the full `LayoutUpdateData` payload (layout,
38
+ * snapshot, delta, hasLayoutChanged).
39
+ * - `measure` — fires every time `measure()` returns a non-null Box.
40
+ * Useful for debug overlays and follow-up event consumers.
41
+ */
42
+ export type ProjectionEventName = 'willUpdate' | 'didUpdate' | 'measure';
43
+ /**
44
+ * Payload delivered to `didUpdate` listeners.
45
+ *
46
+ * `layout` is the post-mutation measurement; `snapshot` is the
47
+ * pre-mutation one. `delta` is `calcBoxDelta(snapshot, layout)` and is
48
+ * the value drag-listeners apply via `originPoint += delta.translate`
49
+ * + `motionValue.set(motionValue.get() + delta.translate)` to keep a
50
+ * dragged element under the cursor while its slot moves.
51
+ *
52
+ * `hasLayoutChanged` is `!isDeltaZero(delta)`. `isDeltaZero` (from
53
+ * motion-dom) treats an axis as unchanged only when its translate is
54
+ * within ±0.01px and its scale within ±0.0001 of identity — a tight
55
+ * floating-point epsilon, NOT a 1px rounding threshold. A genuine
56
+ * sub-pixel layout shift (say 0.4px) is therefore reported as a change.
57
+ */
58
+ export interface ProjectionDidUpdateData {
59
+ layout: Box;
60
+ snapshot: Box;
61
+ delta: Delta;
62
+ hasLayoutChanged: boolean;
63
+ }
64
+ type Listener<E extends ProjectionEventName> = E extends 'didUpdate' ? (data: ProjectionDidUpdateData) => void : E extends 'willUpdate' ? (snapshot: Box) => void : (layout: Box) => void;
65
+ /**
66
+ * Options passed at `ProjectionNode` construction time. All optional —
67
+ * a node with no options still works as a leaf measurement target.
68
+ */
69
+ export interface ProjectionNodeOptions {
70
+ /**
71
+ * Parent node in the projection tree. Wire-up is callsite-driven
72
+ * (the consumer reads `getProjectionParent()` from the Svelte
73
+ * context system and passes the result here); the node stores it
74
+ * as `this.parent` and registers self in `parent.children` on
75
+ * `mount()`.
76
+ */
77
+ parent?: ProjectionNode | null;
78
+ /**
79
+ * Thunk returning the `layoutScroll` ancestor chain at measure
80
+ * time. Used as the second argument to `measureRect`, which
81
+ * shifts the returned rect by the sum of ancestor
82
+ * `scrollLeft`/`scrollTop` so FLIP deltas stay correct when
83
+ * scrollable ancestors scroll between two measurements.
84
+ *
85
+ * Returning `[]` (or omitting the option entirely) gives
86
+ * viewport-relative measurements — fine for the common case.
87
+ */
88
+ getScrollContainers?: () => HTMLElement[];
89
+ /**
90
+ * Thunk returning the element's USER-authored `transform` — the
91
+ * value `measure()` resets to while reading, so the motion-applied
92
+ * portion (`initial`/`animate`/FLIP/drag) is stripped but the user's
93
+ * own transform is preserved.
94
+ *
95
+ * Preferred over the mount-time `baseTransform` capture, because at
96
+ * mount the inline `style.transform` already carries any
97
+ * transform-type `initial` keyframe (it is serialized inline before
98
+ * effects run). The consumer therefore sources this from the `style`
99
+ * prop instead (see `extractTransform`). When omitted, the node
100
+ * falls back to the mount-captured `baseTransform`.
101
+ */
102
+ getBaseTransform?: () => string;
103
+ }
104
+ /**
105
+ * Per-element node in the projection tree. Created at component setup
106
+ * time in `_MotionContainer.svelte`, mounted when the element ref
107
+ * binds, unmounted on cleanup.
108
+ *
109
+ * Lifecycle:
110
+ * 1. `new ProjectionNode({ parent, getScrollContainers })` at setup.
111
+ * 2. `node.mount(element)` once the element ref binds.
112
+ * 3. `node.willUpdate()` before any layout-mutating state change (e.g.
113
+ * a `values` reassign that reorders DOM children).
114
+ * 4. State mutates → Svelte commits the DOM update.
115
+ * 5. `node.didUpdate()` after the DOM update is flushed — fires
116
+ * `didUpdate` listeners with the snapshot→current delta.
117
+ * 6. `node.unmount()` on cleanup.
118
+ */
119
+ export declare class ProjectionNode {
120
+ /** The mounted element. `null` until `mount()` runs. */
121
+ element: HTMLElement | null;
122
+ /**
123
+ * Parent node in the projection tree. Captured at construction
124
+ * from the Svelte context. Set to `null` for root-level motion
125
+ * elements that have no motion ancestor.
126
+ */
127
+ parent: ProjectionNode | null;
128
+ /**
129
+ * Descendant nodes registered via `mount()`. Iterated when we
130
+ * need to broadcast to the subtree (none in this PR; reserved
131
+ * for follow-up work).
132
+ */
133
+ readonly children: Set<ProjectionNode>;
134
+ /** Most-recent post-mutation measurement, or `null` before first measure. */
135
+ latestLayout: Box | null;
136
+ /**
137
+ * Pre-mutation snapshot captured by `willUpdate`. Cleared by
138
+ * `didUpdate` after the delta has been computed. Idempotent for
139
+ * repeat `willUpdate` calls in the same frame — only the first
140
+ * snapshots; subsequent calls no-op so a parent broadcasting
141
+ * `willUpdate` to its children doesn't clobber a child's own
142
+ * earlier snapshot.
143
+ */
144
+ snapshot: Box | null;
145
+ /** Whether `mount()` has been called and `unmount()` has not. */
146
+ isMounted: boolean;
147
+ /**
148
+ * Fallback user-authored base transform, captured from
149
+ * `element.style.transform` at `mount()` time. Used only when no
150
+ * `getBaseTransform` thunk was provided (e.g. unit tests that
151
+ * construct a bare node and set the transform before mounting).
152
+ *
153
+ * For real `motion.*` elements the `getBaseTransform` thunk is
154
+ * preferred: capturing at mount is unsafe because a transform-type
155
+ * `initial` keyframe is serialized into the inline `style.transform`
156
+ * BEFORE effects run, so the mount-time value can be a motion
157
+ * transform rather than the user's. `resolveBaseTransform()` picks
158
+ * the thunk first.
159
+ *
160
+ * `measure()` resets ancestors (and self, via `measureRect`) to this
161
+ * base rather than to `'none'`, removing the motion-applied portion
162
+ * while leaving the user-authored part intact — the same distinction
163
+ * framer-motion draws by only subtracting motion-tracked
164
+ * `latestValues` in `removeBoxTransforms`.
165
+ */
166
+ baseTransform: string;
167
+ private readonly listeners;
168
+ private readonly getScrollContainers;
169
+ private readonly getBaseTransform;
170
+ constructor(options?: ProjectionNodeOptions);
171
+ /**
172
+ * The transform `measure()` resets this node's element to while
173
+ * reading. Prefers the `getBaseTransform` thunk (the user's authored
174
+ * `style` transform, motion-independent); falls back to the
175
+ * mount-captured `baseTransform`. See `getBaseTransform` /
176
+ * `baseTransform` for why the thunk is the safe source.
177
+ */
178
+ private resolveBaseTransform;
179
+ /**
180
+ * Register this node with its parent + bind to a DOM element.
181
+ * Idempotent — calling `mount()` twice on the same element is a
182
+ * no-op for the registration steps.
183
+ *
184
+ * Re-mounting onto a DIFFERENT element swaps the element in place
185
+ * WITHOUT a full `unmount()`. A full unmount would `children.clear()`,
186
+ * orphaning still-mounted descendants that registered themselves in
187
+ * this node's `children` (they keep their `parent` pointer but the
188
+ * set would never be repopulated). Listeners and children are
189
+ * therefore preserved across an element swap.
190
+ *
191
+ * @param element The DOM element this node represents.
192
+ */
193
+ mount(element: HTMLElement): void;
194
+ /**
195
+ * Tear down. Detaches from parent, clears children references,
196
+ * drops all listeners. Safe to call on a never-mounted node and
197
+ * safe to call twice.
198
+ */
199
+ unmount(): void;
200
+ /**
201
+ * Read the element's layout box with every ancestor's
202
+ * motion-applied transform temporarily removed, while preserving
203
+ * each ancestor's user-authored base transform.
204
+ *
205
+ * Mechanism:
206
+ * 1. Walk `this.parent` chain bottom-up, collecting every
207
+ * mounted ancestor node (excludes self — `measureRect`
208
+ * handles self's transform internally).
209
+ * 2. Snapshot each ancestor's current `el.style.transform`.
210
+ * 3. Set each to its node's resolved base transform (the
211
+ * user-authored value, via `resolveBaseTransform`). This strips
212
+ * any FLIP/drag/initial transform while keeping the user's static
213
+ * one — see `getBaseTransform` / `baseTransform`.
214
+ * 4. Delegate to `measureRect(self.element, scrollContainers,
215
+ * self base)`, which applies self's base transform inside its own
216
+ * try/finally and returns the scroll-compensated DOMRect.
217
+ * 5. Restore ancestor transforms in reverse order inside a
218
+ * `finally` block — guarantees restoration even if measure
219
+ * throws.
220
+ * 6. Convert DOMRect → Box and cache as `latestLayout`.
221
+ *
222
+ * Returns `null` when `element` is not mounted.
223
+ */
224
+ measure(): Box | null;
225
+ /**
226
+ * Snapshot the current layout box for use by the next
227
+ * `didUpdate()`. Caller's contract: invoke this BEFORE the
228
+ * layout-mutating state change so the snapshot reflects the
229
+ * pre-mutation position.
230
+ *
231
+ * Idempotent within a frame — once a snapshot exists, subsequent
232
+ * `willUpdate()` calls are no-ops until `didUpdate()` consumes it.
233
+ * This means a parent that broadcasts `willUpdate` to its
234
+ * children before its own snapshot is fine: children snapshot
235
+ * themselves first via their own willUpdate, parent's broadcast
236
+ * is a no-op.
237
+ */
238
+ willUpdate(): void;
239
+ /**
240
+ * Re-measure post-mutation, compute the delta against the
241
+ * snapshot, fire `didUpdate` listeners. No-op when there's no
242
+ * snapshot (matches upstream — `willUpdate` MUST precede
243
+ * `didUpdate` for the cycle to fire).
244
+ *
245
+ * Always clears the snapshot at the end so the next gesture's
246
+ * `willUpdate`/`didUpdate` cycle starts fresh.
247
+ */
248
+ didUpdate(): void;
249
+ /**
250
+ * Observer-driven layout-change commit. Unlike the explicit
251
+ * `willUpdate()` → mutate → `didUpdate()` cycle (used when a
252
+ * consumer controls the exact mutation moment, e.g. Reorder.Group
253
+ * before a `values` reassign), this is for the reactive path where
254
+ * a layout change has ALREADY happened and we only learn about it
255
+ * after the fact (the existing `observeLayoutChanges` FLIP loop in
256
+ * `_MotionContainer`).
257
+ *
258
+ * Uses the cached `latestLayout` (the pre-change position from
259
+ * mount or the previous commit) as the snapshot, re-measures the
260
+ * post-change position, and fires `didUpdate` with the delta.
261
+ *
262
+ * First call after mount just seeds `latestLayout` (no prior
263
+ * position to diff against) and fires nothing.
264
+ *
265
+ * Returns the freshly-measured `Box` (or `null` when unmounted) so
266
+ * the caller can reuse it — the FLIP loop in `_MotionContainer` uses
267
+ * this as its `next` rect instead of measuring a second time per
268
+ * frame.
269
+ *
270
+ * The `didUpdate` fan-out is gated on a non-zero delta. The FLIP
271
+ * animation this commit runs alongside writes its own inverse
272
+ * `transform` to the element every frame, and those writes re-trigger
273
+ * the same `observeLayoutChanges` signal that drives this method.
274
+ * Those re-fires carry no real layout change (the ancestor-stripped
275
+ * measure is identical to the previous one), so without the
276
+ * `isDeltaZero` gate every animation frame would fan out a `delta: 0`
277
+ * event and clobber the genuine delta from the originating change.
278
+ */
279
+ commitLayoutChange(): Box | null;
280
+ /**
281
+ * Subscribe to a projection event. Returns an unsubscribe
282
+ * function. Safe to call after `unmount()` (becomes a no-op).
283
+ */
284
+ addEventListener<E extends ProjectionEventName>(name: E, cb: Listener<E>): () => void;
285
+ private notify;
286
+ }
287
+ export {};
@@ -0,0 +1,392 @@
1
+ /**
2
+ * Projection layout system — minimal foundation for cross-element layout
3
+ * coordination during gestures (drag, layoutId, future shared-element).
4
+ *
5
+ * Direct port of the surface area framer-motion's projection system
6
+ * exposes to consumers, slimmed down to what we actually need for the
7
+ * acceptance criteria of #379:
8
+ *
9
+ * - Per-element `ProjectionNode`s with parent/child wiring through the
10
+ * Svelte component tree (via `projection.context.ts`).
11
+ * - `willUpdate()` / `didUpdate()` lifecycle around layout-mutating
12
+ * operations: caller snapshots before the mutation, re-measures
13
+ * after, fan-outs a `didUpdate` event with the computed delta.
14
+ * - Transform-stripping `measure()` — the load-bearing read primitive.
15
+ * A child's `getBoundingClientRect()` is contaminated by every
16
+ * ancestor's transform, so while measuring we temporarily reset the
17
+ * whole ancestor chain to each node's mount-time `baseTransform`,
18
+ * then restore in reverse order. Resetting to the captured base
19
+ * (rather than `'none'`) strips the motion-applied portion of each
20
+ * transform while preserving the user-authored one — mirroring
21
+ * framer-motion's `removeBoxTransforms`, which only subtracts
22
+ * motion-tracked `latestValues`. Generalises what
23
+ * `layout.ts:measureRect` does for the single-element case across
24
+ * the projection chain.
25
+ *
26
+ * Math helpers (`createBox`, `createDelta`, `calcBoxDelta`,
27
+ * `isDeltaZero`) are imported directly from `motion-dom` — no need to
28
+ * re-port what upstream already re-exports.
29
+ *
30
+ * KNOWN_LIMITATIONS (deferred to follow-up PRs, see #379):
31
+ * - No depth-sorted FlatTree / `path` array. We walk via `parent`
32
+ * pointers from leaves; siblings under one parent is sufficient for
33
+ * the Reorder use case and the rest of the projection tree workflows
34
+ * this PR enables.
35
+ * - No 4-phase tree walk (propagateDirty → resolveTarget →
36
+ * calcProjection → cleanDirty). Projection-transform *inheritance*
37
+ * (parent target deltas affecting child positioning) is not implemented
38
+ * yet. The ancestor-zeroing measure is independent of this — it's a
39
+ * read-time concern, not a projection-compose concern.
40
+ * - No scale-correction utilities (border-radius / box-shadow). Visual
41
+ * polish; defer.
42
+ * - No `relativeTarget` / `projectionDelta` with transform inheritance.
43
+ * Only matters for full shared-element morphing through nested
44
+ * transforms; current `layoutId.ts` registry handles the simple case.
45
+ * - No `layoutId` registry migration onto projection nodes. The
46
+ * one-shot pattern in `layoutId.ts` keeps working; future PR can
47
+ * route through projection nodes for richer coordination.
48
+ */
49
+ import { measureRect } from './layout';
50
+ import { calcBoxDelta, createDelta, isDeltaZero } from 'motion-dom';
51
+ /**
52
+ * Convert a `DOMRect` to our `Box` shape. Inline because `motion-dom`'s
53
+ * `convertBoundingBoxToBox` works on a BoundingBox (already-derived
54
+ * `top`/`bottom`/`left`/`right`) rather than the DOMRect we get from
55
+ * `getBoundingClientRect`. Same math, just one less indirection.
56
+ */
57
+ const rectToBox = (rect) => ({
58
+ x: { min: rect.left, max: rect.right },
59
+ y: { min: rect.top, max: rect.bottom }
60
+ });
61
+ /** Deep-copy a Box so subsequent measurements don't mutate snapshots. */
62
+ const cloneBox = (box) => ({
63
+ x: { min: box.x.min, max: box.x.max },
64
+ y: { min: box.y.min, max: box.y.max }
65
+ });
66
+ /**
67
+ * Per-element node in the projection tree. Created at component setup
68
+ * time in `_MotionContainer.svelte`, mounted when the element ref
69
+ * binds, unmounted on cleanup.
70
+ *
71
+ * Lifecycle:
72
+ * 1. `new ProjectionNode({ parent, getScrollContainers })` at setup.
73
+ * 2. `node.mount(element)` once the element ref binds.
74
+ * 3. `node.willUpdate()` before any layout-mutating state change (e.g.
75
+ * a `values` reassign that reorders DOM children).
76
+ * 4. State mutates → Svelte commits the DOM update.
77
+ * 5. `node.didUpdate()` after the DOM update is flushed — fires
78
+ * `didUpdate` listeners with the snapshot→current delta.
79
+ * 6. `node.unmount()` on cleanup.
80
+ */
81
+ export class ProjectionNode {
82
+ /** The mounted element. `null` until `mount()` runs. */
83
+ element = null;
84
+ /**
85
+ * Parent node in the projection tree. Captured at construction
86
+ * from the Svelte context. Set to `null` for root-level motion
87
+ * elements that have no motion ancestor.
88
+ */
89
+ parent = null;
90
+ /**
91
+ * Descendant nodes registered via `mount()`. Iterated when we
92
+ * need to broadcast to the subtree (none in this PR; reserved
93
+ * for follow-up work).
94
+ */
95
+ children = new Set();
96
+ /** Most-recent post-mutation measurement, or `null` before first measure. */
97
+ latestLayout = null;
98
+ /**
99
+ * Pre-mutation snapshot captured by `willUpdate`. Cleared by
100
+ * `didUpdate` after the delta has been computed. Idempotent for
101
+ * repeat `willUpdate` calls in the same frame — only the first
102
+ * snapshots; subsequent calls no-op so a parent broadcasting
103
+ * `willUpdate` to its children doesn't clobber a child's own
104
+ * earlier snapshot.
105
+ */
106
+ snapshot = null;
107
+ /** Whether `mount()` has been called and `unmount()` has not. */
108
+ isMounted = false;
109
+ /**
110
+ * Fallback user-authored base transform, captured from
111
+ * `element.style.transform` at `mount()` time. Used only when no
112
+ * `getBaseTransform` thunk was provided (e.g. unit tests that
113
+ * construct a bare node and set the transform before mounting).
114
+ *
115
+ * For real `motion.*` elements the `getBaseTransform` thunk is
116
+ * preferred: capturing at mount is unsafe because a transform-type
117
+ * `initial` keyframe is serialized into the inline `style.transform`
118
+ * BEFORE effects run, so the mount-time value can be a motion
119
+ * transform rather than the user's. `resolveBaseTransform()` picks
120
+ * the thunk first.
121
+ *
122
+ * `measure()` resets ancestors (and self, via `measureRect`) to this
123
+ * base rather than to `'none'`, removing the motion-applied portion
124
+ * while leaving the user-authored part intact — the same distinction
125
+ * framer-motion draws by only subtracting motion-tracked
126
+ * `latestValues` in `removeBoxTransforms`.
127
+ */
128
+ baseTransform = '';
129
+ listeners = new Map();
130
+ getScrollContainers;
131
+ getBaseTransform;
132
+ constructor(options = {}) {
133
+ this.parent = options.parent ?? null;
134
+ this.getScrollContainers = options.getScrollContainers;
135
+ this.getBaseTransform = options.getBaseTransform;
136
+ }
137
+ /**
138
+ * The transform `measure()` resets this node's element to while
139
+ * reading. Prefers the `getBaseTransform` thunk (the user's authored
140
+ * `style` transform, motion-independent); falls back to the
141
+ * mount-captured `baseTransform`. See `getBaseTransform` /
142
+ * `baseTransform` for why the thunk is the safe source.
143
+ */
144
+ resolveBaseTransform() {
145
+ return this.getBaseTransform?.() ?? this.baseTransform;
146
+ }
147
+ /**
148
+ * Register this node with its parent + bind to a DOM element.
149
+ * Idempotent — calling `mount()` twice on the same element is a
150
+ * no-op for the registration steps.
151
+ *
152
+ * Re-mounting onto a DIFFERENT element swaps the element in place
153
+ * WITHOUT a full `unmount()`. A full unmount would `children.clear()`,
154
+ * orphaning still-mounted descendants that registered themselves in
155
+ * this node's `children` (they keep their `parent` pointer but the
156
+ * set would never be repopulated). Listeners and children are
157
+ * therefore preserved across an element swap.
158
+ *
159
+ * @param element The DOM element this node represents.
160
+ */
161
+ mount(element) {
162
+ if (this.isMounted && this.element === element)
163
+ return;
164
+ this.element = element;
165
+ // Fallback base capture (the consumer's getBaseTransform thunk is
166
+ // preferred — see `baseTransform`).
167
+ this.baseTransform = element.style.transform;
168
+ // Stale measurement from a previous element; refreshed on next measure.
169
+ this.latestLayout = null;
170
+ if (!this.isMounted) {
171
+ this.isMounted = true;
172
+ this.parent?.children.add(this);
173
+ }
174
+ }
175
+ /**
176
+ * Tear down. Detaches from parent, clears children references,
177
+ * drops all listeners. Safe to call on a never-mounted node and
178
+ * safe to call twice.
179
+ */
180
+ unmount() {
181
+ if (!this.isMounted)
182
+ return;
183
+ this.parent?.children.delete(this);
184
+ this.children.clear();
185
+ this.listeners.clear();
186
+ this.element = null;
187
+ this.latestLayout = null;
188
+ this.snapshot = null;
189
+ this.baseTransform = '';
190
+ this.isMounted = false;
191
+ }
192
+ /**
193
+ * Read the element's layout box with every ancestor's
194
+ * motion-applied transform temporarily removed, while preserving
195
+ * each ancestor's user-authored base transform.
196
+ *
197
+ * Mechanism:
198
+ * 1. Walk `this.parent` chain bottom-up, collecting every
199
+ * mounted ancestor node (excludes self — `measureRect`
200
+ * handles self's transform internally).
201
+ * 2. Snapshot each ancestor's current `el.style.transform`.
202
+ * 3. Set each to its node's resolved base transform (the
203
+ * user-authored value, via `resolveBaseTransform`). This strips
204
+ * any FLIP/drag/initial transform while keeping the user's static
205
+ * one — see `getBaseTransform` / `baseTransform`.
206
+ * 4. Delegate to `measureRect(self.element, scrollContainers,
207
+ * self base)`, which applies self's base transform inside its own
208
+ * try/finally and returns the scroll-compensated DOMRect.
209
+ * 5. Restore ancestor transforms in reverse order inside a
210
+ * `finally` block — guarantees restoration even if measure
211
+ * throws.
212
+ * 6. Convert DOMRect → Box and cache as `latestLayout`.
213
+ *
214
+ * Returns `null` when `element` is not mounted.
215
+ */
216
+ measure() {
217
+ if (!this.element)
218
+ return null;
219
+ // Collect ancestor nodes bottom-up. Skips ancestors that
220
+ // haven't bound yet (`element === null`).
221
+ const ancestors = [];
222
+ let cursor = this.parent;
223
+ while (cursor) {
224
+ if (cursor.element)
225
+ ancestors.push(cursor);
226
+ cursor = cursor.parent;
227
+ }
228
+ // Snapshot current transform; reset each ancestor to its
229
+ // user-authored base for the duration of the measure.
230
+ const restoreList = ancestors.map((node) => ({
231
+ el: node.element,
232
+ prev: node.element.style.transform,
233
+ base: node.resolveBaseTransform()
234
+ }));
235
+ try {
236
+ for (const { el, base } of restoreList)
237
+ el.style.transform = base;
238
+ // measureRect applies self's base transform + scroll-container offset.
239
+ const rect = measureRect(this.element, this.getScrollContainers?.() ?? [], this.resolveBaseTransform());
240
+ const box = rectToBox(rect);
241
+ this.latestLayout = box;
242
+ // Clone for the event: `box` aliases `this.latestLayout`, so a
243
+ // listener mutating it would corrupt the cached layout.
244
+ this.notify('measure', cloneBox(box));
245
+ return box;
246
+ }
247
+ finally {
248
+ // Reverse-order restore — important because ancestor
249
+ // composition cascades from outer-most down; restoring
250
+ // bottom-up matches the snapshot order.
251
+ for (let i = restoreList.length - 1; i >= 0; i--) {
252
+ restoreList[i].el.style.transform = restoreList[i].prev;
253
+ }
254
+ }
255
+ }
256
+ /**
257
+ * Snapshot the current layout box for use by the next
258
+ * `didUpdate()`. Caller's contract: invoke this BEFORE the
259
+ * layout-mutating state change so the snapshot reflects the
260
+ * pre-mutation position.
261
+ *
262
+ * Idempotent within a frame — once a snapshot exists, subsequent
263
+ * `willUpdate()` calls are no-ops until `didUpdate()` consumes it.
264
+ * This means a parent that broadcasts `willUpdate` to its
265
+ * children before its own snapshot is fine: children snapshot
266
+ * themselves first via their own willUpdate, parent's broadcast
267
+ * is a no-op.
268
+ */
269
+ willUpdate() {
270
+ if (!this.element || this.snapshot)
271
+ return;
272
+ const measured = this.measure();
273
+ if (!measured)
274
+ return;
275
+ this.snapshot = cloneBox(measured);
276
+ // Clone for the event: emitting `this.snapshot` by reference would
277
+ // let a listener mutate the stored snapshot.
278
+ this.notify('willUpdate', cloneBox(this.snapshot));
279
+ }
280
+ /**
281
+ * Re-measure post-mutation, compute the delta against the
282
+ * snapshot, fire `didUpdate` listeners. No-op when there's no
283
+ * snapshot (matches upstream — `willUpdate` MUST precede
284
+ * `didUpdate` for the cycle to fire).
285
+ *
286
+ * Always clears the snapshot at the end so the next gesture's
287
+ * `willUpdate`/`didUpdate` cycle starts fresh.
288
+ */
289
+ didUpdate() {
290
+ if (!this.element || !this.snapshot) {
291
+ this.snapshot = null;
292
+ return;
293
+ }
294
+ const layout = this.measure();
295
+ if (!layout) {
296
+ this.snapshot = null;
297
+ return;
298
+ }
299
+ const delta = createDelta();
300
+ calcBoxDelta(delta, this.snapshot, layout);
301
+ const hasLayoutChanged = !isDeltaZero(delta);
302
+ const payload = {
303
+ // Clone the layout box: it aliases `this.latestLayout`, so
304
+ // handing the live reference out would let a listener mutate
305
+ // the node's cached layout and poison the next diff.
306
+ layout: cloneBox(layout),
307
+ snapshot: this.snapshot,
308
+ delta,
309
+ hasLayoutChanged
310
+ };
311
+ this.snapshot = null;
312
+ this.notify('didUpdate', payload);
313
+ }
314
+ /**
315
+ * Observer-driven layout-change commit. Unlike the explicit
316
+ * `willUpdate()` → mutate → `didUpdate()` cycle (used when a
317
+ * consumer controls the exact mutation moment, e.g. Reorder.Group
318
+ * before a `values` reassign), this is for the reactive path where
319
+ * a layout change has ALREADY happened and we only learn about it
320
+ * after the fact (the existing `observeLayoutChanges` FLIP loop in
321
+ * `_MotionContainer`).
322
+ *
323
+ * Uses the cached `latestLayout` (the pre-change position from
324
+ * mount or the previous commit) as the snapshot, re-measures the
325
+ * post-change position, and fires `didUpdate` with the delta.
326
+ *
327
+ * First call after mount just seeds `latestLayout` (no prior
328
+ * position to diff against) and fires nothing.
329
+ *
330
+ * Returns the freshly-measured `Box` (or `null` when unmounted) so
331
+ * the caller can reuse it — the FLIP loop in `_MotionContainer` uses
332
+ * this as its `next` rect instead of measuring a second time per
333
+ * frame.
334
+ *
335
+ * The `didUpdate` fan-out is gated on a non-zero delta. The FLIP
336
+ * animation this commit runs alongside writes its own inverse
337
+ * `transform` to the element every frame, and those writes re-trigger
338
+ * the same `observeLayoutChanges` signal that drives this method.
339
+ * Those re-fires carry no real layout change (the ancestor-stripped
340
+ * measure is identical to the previous one), so without the
341
+ * `isDeltaZero` gate every animation frame would fan out a `delta: 0`
342
+ * event and clobber the genuine delta from the originating change.
343
+ */
344
+ commitLayoutChange() {
345
+ if (!this.element)
346
+ return null;
347
+ const previous = this.latestLayout;
348
+ const layout = this.measure();
349
+ if (!layout)
350
+ return null;
351
+ if (previous) {
352
+ const delta = createDelta();
353
+ calcBoxDelta(delta, previous, layout);
354
+ // Skip the no-op re-fires from our own FLIP transform writes.
355
+ if (!isDeltaZero(delta)) {
356
+ this.notify('didUpdate', {
357
+ // Clone: `layout` aliases `this.latestLayout`.
358
+ layout: cloneBox(layout),
359
+ snapshot: previous,
360
+ delta,
361
+ hasLayoutChanged: true
362
+ });
363
+ }
364
+ }
365
+ return layout;
366
+ }
367
+ /**
368
+ * Subscribe to a projection event. Returns an unsubscribe
369
+ * function. Safe to call after `unmount()` (becomes a no-op).
370
+ */
371
+ addEventListener(name, cb) {
372
+ let bucket = this.listeners.get(name);
373
+ if (!bucket) {
374
+ bucket = new Set();
375
+ this.listeners.set(name, bucket);
376
+ }
377
+ const wrapped = cb;
378
+ bucket.add(wrapped);
379
+ return () => {
380
+ this.listeners.get(name)?.delete(wrapped);
381
+ };
382
+ }
383
+ notify(name, payload) {
384
+ const bucket = this.listeners.get(name);
385
+ if (!bucket || bucket.size === 0)
386
+ return;
387
+ // Snapshot the bucket so unsubscribes inside a listener don't
388
+ // skip the next listener in the iteration.
389
+ for (const cb of [...bucket])
390
+ cb(payload);
391
+ }
392
+ }