@humanspeak/svelte-motion 0.3.4 → 0.3.6
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/dist/components/PresenceChild.svelte +124 -0
- package/dist/components/PresenceChild.svelte.d.ts +8 -0
- package/dist/html/_MotionContainer.svelte +15 -7
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -1
- package/dist/utils/drag.js +155 -48
- package/dist/utils/dragMath.d.ts +18 -0
- package/dist/utils/dragMath.js +21 -0
- package/dist/utils/inertia.js +4 -3
- package/dist/utils/presence.d.ts +38 -11
- package/dist/utils/presence.js +80 -54
- package/dist/utils/usePresence.d.ts +71 -0
- package/dist/utils/usePresence.js +74 -0
- package/dist/vite.js +176 -7
- package/package.json +58 -45
package/dist/utils/presence.d.ts
CHANGED
|
@@ -35,6 +35,19 @@ export type AnimatePresenceContext = {
|
|
|
35
35
|
updateChildAnimatedStyle: (key: string, opacity: string, transform: string) => void;
|
|
36
36
|
/** Unregister a child. If it has an exit, clone and animate it out. */
|
|
37
37
|
unregisterChild: (key: string) => void;
|
|
38
|
+
/**
|
|
39
|
+
* @internal Used by `PresenceChild` to participate in the same exit
|
|
40
|
+
* accounting as the clone-based motion-element exit path. Increments the
|
|
41
|
+
* in-flight exit counter and applies mode='wait' enter blocking. Not
|
|
42
|
+
* intended for direct consumer use.
|
|
43
|
+
*/
|
|
44
|
+
notifyExitStart: () => void;
|
|
45
|
+
/**
|
|
46
|
+
* @internal Pairs with `notifyExitStart`. Decrements the in-flight exit
|
|
47
|
+
* counter, fires `onExitComplete` once it reaches zero, and unblocks
|
|
48
|
+
* pending enters in mode='wait'. Not intended for direct consumer use.
|
|
49
|
+
*/
|
|
50
|
+
notifyExitComplete: () => void;
|
|
38
51
|
};
|
|
39
52
|
/**
|
|
40
53
|
* Create a new `AnimatePresence` context instance.
|
|
@@ -113,20 +126,34 @@ export declare const getPresenceDepth: () => number | undefined;
|
|
|
113
126
|
*/
|
|
114
127
|
export declare const setPresenceDepth: (depth: number) => void;
|
|
115
128
|
/**
|
|
116
|
-
*
|
|
117
|
-
*
|
|
129
|
+
* Per-`PresenceChild` Svelte context payload. Read by the `useIsPresent` and
|
|
130
|
+
* `usePresence` hooks (and consulted by motion elements so they can opt out of
|
|
131
|
+
* the outer `AnimatePresence` clone path when a `PresenceChild` is driving
|
|
132
|
+
* the exit themselves).
|
|
118
133
|
*
|
|
119
|
-
*
|
|
134
|
+
* `isPresent` is exposed as a getter so consumers see live updates as the
|
|
135
|
+
* wrapper toggles between mounted, exiting, and re-entered states.
|
|
120
136
|
*/
|
|
137
|
+
export type PresenceChildContext = {
|
|
138
|
+
/** Reactive flag — `true` while present, `false` once the exit hold begins. */
|
|
139
|
+
readonly isPresent: boolean;
|
|
140
|
+
/**
|
|
141
|
+
* Signal that the consumer's exit work is complete. Triggers actual
|
|
142
|
+
* unmount and decrements the parent `AnimatePresenceContext` exit count.
|
|
143
|
+
* Idempotent and versioned (calls from a canceled exit cycle are no-ops).
|
|
144
|
+
*/
|
|
145
|
+
safeToRemove: () => void;
|
|
146
|
+
};
|
|
121
147
|
/**
|
|
122
|
-
*
|
|
148
|
+
* Get the nearest `PresenceChild` context from Svelte component context, or
|
|
149
|
+
* `undefined` if the caller is not wrapped in one.
|
|
123
150
|
*
|
|
124
|
-
*
|
|
125
|
-
|
|
151
|
+
* Note: Trivial wrapper - ignored for coverage.
|
|
152
|
+
*/
|
|
153
|
+
export declare const getPresenceChildContext: () => PresenceChildContext | undefined;
|
|
154
|
+
/**
|
|
155
|
+
* Install a `PresenceChild` context for descendants.
|
|
126
156
|
*
|
|
127
|
-
*
|
|
128
|
-
* @param element The DOM element to track.
|
|
129
|
-
* @param exit The exit keyframes definition.
|
|
130
|
-
* @param mergedTransition The element's merged transition for precedence.
|
|
157
|
+
* Note: Trivial wrapper - ignored for coverage.
|
|
131
158
|
*/
|
|
132
|
-
export declare const
|
|
159
|
+
export declare const setPresenceChildContext: (context: PresenceChildContext) => void;
|
package/dist/utils/presence.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { mergeTransitions } from './animation';
|
|
2
2
|
import { pwLog } from './log';
|
|
3
3
|
import { animate } from 'motion';
|
|
4
|
-
import { getContext,
|
|
4
|
+
import { getContext, setContext } from 'svelte';
|
|
5
5
|
/**
|
|
6
6
|
* Context key for `AnimatePresence`.
|
|
7
7
|
*
|
|
@@ -195,6 +195,66 @@ export const createAnimatePresenceContext = (context) => {
|
|
|
195
195
|
const children = new Map();
|
|
196
196
|
// Track number of in-flight exit animations to invoke onExitComplete once
|
|
197
197
|
let inFlightExits = 0;
|
|
198
|
+
/**
|
|
199
|
+
* Begin tracking an exit.
|
|
200
|
+
*
|
|
201
|
+
* Increments the `inFlightExits` counter and, in `mode='wait'`, raises the
|
|
202
|
+
* `enterBlocked` flag so sibling motion-element enters defer until every
|
|
203
|
+
* exit reports back via {@link finishExit}. Shared by the clone-based exit
|
|
204
|
+
* path in {@link unregisterChild} and the user-driven `PresenceChild` hold.
|
|
205
|
+
*
|
|
206
|
+
* Must be paired with exactly one {@link finishExit} call per invocation.
|
|
207
|
+
*
|
|
208
|
+
* @returns void
|
|
209
|
+
* @example
|
|
210
|
+
* ```ts
|
|
211
|
+
* // unregisterChild (clone path)
|
|
212
|
+
* startExit()
|
|
213
|
+
* requestAnimationFrame(() => {
|
|
214
|
+
* animate(clone, exitKeyframes, transition).finished.finally(finishExit)
|
|
215
|
+
* })
|
|
216
|
+
*
|
|
217
|
+
* // PresenceChild (user-driven path) — exposed as `notifyExitStart`
|
|
218
|
+
* presenceContext.notifyExitStart()
|
|
219
|
+
* // ... later, on transitionend or user signal ...
|
|
220
|
+
* presenceContext.notifyExitComplete()
|
|
221
|
+
* ```
|
|
222
|
+
*/
|
|
223
|
+
const startExit = () => {
|
|
224
|
+
if (mode === 'wait') {
|
|
225
|
+
enterBlocked = true;
|
|
226
|
+
}
|
|
227
|
+
inFlightExits += 1;
|
|
228
|
+
};
|
|
229
|
+
/**
|
|
230
|
+
* Mark an exit as finished.
|
|
231
|
+
*
|
|
232
|
+
* Decrements the `inFlightExits` counter. When the count reaches zero,
|
|
233
|
+
* fires the consumer's `onExitComplete` callback and, in `mode='wait'`,
|
|
234
|
+
* lowers `enterBlocked` plus notifies any deferred-enter callbacks
|
|
235
|
+
* registered via {@link onEnterUnblocked}.
|
|
236
|
+
*
|
|
237
|
+
* Must be called exactly once per matching {@link startExit}; double-fires
|
|
238
|
+
* underflow the counter and can permanently mis-route subsequent exits.
|
|
239
|
+
*
|
|
240
|
+
* @returns void
|
|
241
|
+
* @example
|
|
242
|
+
* ```ts
|
|
243
|
+
* startExit()
|
|
244
|
+
* // ... exit work ...
|
|
245
|
+
* finishExit() // fires onExitComplete if the last exit, unblocks waiters
|
|
246
|
+
* ```
|
|
247
|
+
*/
|
|
248
|
+
const finishExit = () => {
|
|
249
|
+
inFlightExits -= 1;
|
|
250
|
+
if (inFlightExits === 0) {
|
|
251
|
+
context.onExitComplete?.();
|
|
252
|
+
if (mode === 'wait' && enterBlocked) {
|
|
253
|
+
enterBlocked = false;
|
|
254
|
+
notifyEnterUnblocked();
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
};
|
|
198
258
|
/**
|
|
199
259
|
* Register a child element and snapshot its initial rect/styles.
|
|
200
260
|
*/
|
|
@@ -275,11 +335,6 @@ export const createAnimatePresenceContext = (context) => {
|
|
|
275
335
|
children.delete(key);
|
|
276
336
|
return;
|
|
277
337
|
}
|
|
278
|
-
// For mode='wait': block new enters while exit is in progress
|
|
279
|
-
if (mode === 'wait') {
|
|
280
|
-
enterBlocked = true;
|
|
281
|
-
pwLog('[presence] mode=wait: blocking enters during exit');
|
|
282
|
-
}
|
|
283
338
|
const rect = child.lastRect;
|
|
284
339
|
const computed = child.lastComputedStyle;
|
|
285
340
|
// For sync/wait, preserve layout by inserting a hidden placeholder.
|
|
@@ -415,8 +470,8 @@ export const createAnimatePresenceContext = (context) => {
|
|
|
415
470
|
// This prevents race conditions where re-entry registers a new element with the same key
|
|
416
471
|
// before this exit animation completes
|
|
417
472
|
const exitingElement = child.element;
|
|
418
|
-
// Start exit and track in-flight count
|
|
419
|
-
|
|
473
|
+
// Start exit and track in-flight count (handles wait-mode blocking)
|
|
474
|
+
startExit();
|
|
420
475
|
requestAnimationFrame(() => {
|
|
421
476
|
animate(clone, exitKeyframes, finalTransition)
|
|
422
477
|
.finished.catch(() => { })
|
|
@@ -459,17 +514,7 @@ export const createAnimatePresenceContext = (context) => {
|
|
|
459
514
|
inFlightExits: inFlightExits - 1,
|
|
460
515
|
clonesInDOM: document.querySelectorAll('[data-clone="true"]').length
|
|
461
516
|
});
|
|
462
|
-
|
|
463
|
-
if (inFlightExits === 0) {
|
|
464
|
-
pwLog('[presence] all exits complete, calling onExitComplete');
|
|
465
|
-
context.onExitComplete?.();
|
|
466
|
-
// For mode='wait': unblock enters now that all exits are complete
|
|
467
|
-
if (mode === 'wait' && enterBlocked) {
|
|
468
|
-
enterBlocked = false;
|
|
469
|
-
pwLog('[presence] mode=wait: unblocking enters, notifying callbacks');
|
|
470
|
-
notifyEnterUnblocked();
|
|
471
|
-
}
|
|
472
|
-
}
|
|
517
|
+
finishExit();
|
|
473
518
|
});
|
|
474
519
|
});
|
|
475
520
|
};
|
|
@@ -483,7 +528,9 @@ export const createAnimatePresenceContext = (context) => {
|
|
|
483
528
|
registerChild,
|
|
484
529
|
updateChildState,
|
|
485
530
|
updateChildAnimatedStyle,
|
|
486
|
-
unregisterChild
|
|
531
|
+
unregisterChild,
|
|
532
|
+
notifyExitStart: startExit,
|
|
533
|
+
notifyExitComplete: finishExit
|
|
487
534
|
};
|
|
488
535
|
};
|
|
489
536
|
/**
|
|
@@ -547,44 +594,23 @@ export const getPresenceDepth = () => getContext(PRESENCE_DEPTH_CONTEXT);
|
|
|
547
594
|
export const setPresenceDepth = (depth) => {
|
|
548
595
|
setContext(PRESENCE_DEPTH_CONTEXT, depth);
|
|
549
596
|
};
|
|
597
|
+
const PRESENCE_CHILD_CONTEXT = Symbol('presence-child-context');
|
|
550
598
|
/**
|
|
551
|
-
*
|
|
552
|
-
*
|
|
599
|
+
* Get the nearest `PresenceChild` context from Svelte component context, or
|
|
600
|
+
* `undefined` if the caller is not wrapped in one.
|
|
553
601
|
*
|
|
554
|
-
* Note:
|
|
602
|
+
* Note: Trivial wrapper - ignored for coverage.
|
|
555
603
|
*/
|
|
556
|
-
/* c8 ignore
|
|
604
|
+
/* c8 ignore next 3 */
|
|
605
|
+
export const getPresenceChildContext = () => {
|
|
606
|
+
return getContext(PRESENCE_CHILD_CONTEXT);
|
|
607
|
+
};
|
|
557
608
|
/**
|
|
558
|
-
*
|
|
559
|
-
*
|
|
560
|
-
* Registers the element with the presence context and guarantees that the
|
|
561
|
-
* exit animation is scheduled on teardown.
|
|
609
|
+
* Install a `PresenceChild` context for descendants.
|
|
562
610
|
*
|
|
563
|
-
*
|
|
564
|
-
* @param element The DOM element to track.
|
|
565
|
-
* @param exit The exit keyframes definition.
|
|
566
|
-
* @param mergedTransition The element's merged transition for precedence.
|
|
611
|
+
* Note: Trivial wrapper - ignored for coverage.
|
|
567
612
|
*/
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
key,
|
|
572
|
-
hasElement: !!element,
|
|
573
|
-
hasContext: !!context,
|
|
574
|
-
hasExit: !!exit,
|
|
575
|
-
exit
|
|
576
|
-
});
|
|
577
|
-
if (element && context && exit) {
|
|
578
|
-
context.registerChild(key, element, exit, mergedTransition);
|
|
579
|
-
onDestroy(() => {
|
|
580
|
-
pwLog('[presence] onDestroy triggered', { key });
|
|
581
|
-
context.unregisterChild(key);
|
|
582
|
-
});
|
|
583
|
-
}
|
|
584
|
-
else {
|
|
585
|
-
pwLog('[presence] usePresence - skipping registration', {
|
|
586
|
-
reason: !element ? 'no element' : !context ? 'no context' : 'no exit'
|
|
587
|
-
});
|
|
588
|
-
}
|
|
613
|
+
/* c8 ignore next 3 */
|
|
614
|
+
export const setPresenceChildContext = (context) => {
|
|
615
|
+
setContext(PRESENCE_CHILD_CONTEXT, context);
|
|
589
616
|
};
|
|
590
|
-
/* c8 ignore end */
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tuple returned by {@link usePresence}, matching framer-motion's shape:
|
|
3
|
+
* `[true, null]` while present (or when not inside a `PresenceChild`), and
|
|
4
|
+
* `[false, () => void]` after the wrapper enters its exit hold.
|
|
5
|
+
*/
|
|
6
|
+
export type UsePresenceState = [true, null] | [false, () => void];
|
|
7
|
+
/**
|
|
8
|
+
* Returns whether the calling component is currently present in its parent
|
|
9
|
+
* `<PresenceChild>`. While the wrapper holds the component for an exit, this
|
|
10
|
+
* flips to `false` so the consumer can branch (render different markup, run
|
|
11
|
+
* a custom exit animation, etc.).
|
|
12
|
+
*
|
|
13
|
+
* Outside of a `<PresenceChild>` always returns `true`.
|
|
14
|
+
*
|
|
15
|
+
* Reactivity note: the boolean tracks the wrapper's state and updates in
|
|
16
|
+
* Svelte 5 reactive contexts (`$derived`, `$effect`, template). For non-
|
|
17
|
+
* reactive snapshots, prefer `usePresence()` which exposes the same state
|
|
18
|
+
* alongside the `safeToRemove` callback.
|
|
19
|
+
*
|
|
20
|
+
* @returns `true` while present, `false` while exiting.
|
|
21
|
+
* @see https://motion.dev/docs/react-use-is-present
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```svelte
|
|
25
|
+
* <script lang="ts">
|
|
26
|
+
* import { useIsPresent } from '@humanspeak/svelte-motion'
|
|
27
|
+
* const isPresent = $derived(useIsPresent())
|
|
28
|
+
* </script>
|
|
29
|
+
* <div class:exiting={!isPresent}>{isPresent ? 'live' : 'goodbye'}</div>
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export declare const useIsPresent: () => boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Returns `[isPresent, safeToRemove]`. `isPresent` reflects the wrapper's
|
|
35
|
+
* presence state; `safeToRemove` is the callback to invoke once a custom exit
|
|
36
|
+
* animation finishes. Calling it triggers the actual unmount and decrements
|
|
37
|
+
* the parent `<AnimatePresence>` exit-completion count.
|
|
38
|
+
*
|
|
39
|
+
* Outside of a `<PresenceChild>` returns `[true, null]` — the consumer is
|
|
40
|
+
* effectively always present and there is nothing to safely remove.
|
|
41
|
+
*
|
|
42
|
+
* `safeToRemove` is idempotent and versioned: a stale callback from a
|
|
43
|
+
* canceled exit cycle (re-entry before the consumer signaled completion) is
|
|
44
|
+
* a no-op.
|
|
45
|
+
*
|
|
46
|
+
* @returns `[true, null]` while present (or outside any `PresenceChild`),
|
|
47
|
+
* `[false, () => void]` while the wrapper holds the component for exit.
|
|
48
|
+
* @see https://motion.dev/docs/react-use-presence
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* ```svelte
|
|
52
|
+
* <script lang="ts">
|
|
53
|
+
* import { usePresence } from '@humanspeak/svelte-motion'
|
|
54
|
+
*
|
|
55
|
+
* let node: HTMLElement | undefined = $state()
|
|
56
|
+
* const presence = $derived(usePresence())
|
|
57
|
+
*
|
|
58
|
+
* $effect(() => {
|
|
59
|
+
* const [isPresent, safeToRemove] = presence
|
|
60
|
+
* if (isPresent || !node) return
|
|
61
|
+
* const onEnd = () => safeToRemove()
|
|
62
|
+
* node.addEventListener('transitionend', onEnd, { once: true })
|
|
63
|
+
* node.classList.add('exiting')
|
|
64
|
+
* return () => node?.removeEventListener('transitionend', onEnd)
|
|
65
|
+
* })
|
|
66
|
+
* </script>
|
|
67
|
+
*
|
|
68
|
+
* <div bind:this={node}>…</div>
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
export declare const usePresence: () => UsePresenceState;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { getPresenceChildContext } from './presence';
|
|
2
|
+
/**
|
|
3
|
+
* Returns whether the calling component is currently present in its parent
|
|
4
|
+
* `<PresenceChild>`. While the wrapper holds the component for an exit, this
|
|
5
|
+
* flips to `false` so the consumer can branch (render different markup, run
|
|
6
|
+
* a custom exit animation, etc.).
|
|
7
|
+
*
|
|
8
|
+
* Outside of a `<PresenceChild>` always returns `true`.
|
|
9
|
+
*
|
|
10
|
+
* Reactivity note: the boolean tracks the wrapper's state and updates in
|
|
11
|
+
* Svelte 5 reactive contexts (`$derived`, `$effect`, template). For non-
|
|
12
|
+
* reactive snapshots, prefer `usePresence()` which exposes the same state
|
|
13
|
+
* alongside the `safeToRemove` callback.
|
|
14
|
+
*
|
|
15
|
+
* @returns `true` while present, `false` while exiting.
|
|
16
|
+
* @see https://motion.dev/docs/react-use-is-present
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```svelte
|
|
20
|
+
* <script lang="ts">
|
|
21
|
+
* import { useIsPresent } from '@humanspeak/svelte-motion'
|
|
22
|
+
* const isPresent = $derived(useIsPresent())
|
|
23
|
+
* </script>
|
|
24
|
+
* <div class:exiting={!isPresent}>{isPresent ? 'live' : 'goodbye'}</div>
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export const useIsPresent = () => {
|
|
28
|
+
const context = getPresenceChildContext();
|
|
29
|
+
return context ? context.isPresent : true;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Returns `[isPresent, safeToRemove]`. `isPresent` reflects the wrapper's
|
|
33
|
+
* presence state; `safeToRemove` is the callback to invoke once a custom exit
|
|
34
|
+
* animation finishes. Calling it triggers the actual unmount and decrements
|
|
35
|
+
* the parent `<AnimatePresence>` exit-completion count.
|
|
36
|
+
*
|
|
37
|
+
* Outside of a `<PresenceChild>` returns `[true, null]` — the consumer is
|
|
38
|
+
* effectively always present and there is nothing to safely remove.
|
|
39
|
+
*
|
|
40
|
+
* `safeToRemove` is idempotent and versioned: a stale callback from a
|
|
41
|
+
* canceled exit cycle (re-entry before the consumer signaled completion) is
|
|
42
|
+
* a no-op.
|
|
43
|
+
*
|
|
44
|
+
* @returns `[true, null]` while present (or outside any `PresenceChild`),
|
|
45
|
+
* `[false, () => void]` while the wrapper holds the component for exit.
|
|
46
|
+
* @see https://motion.dev/docs/react-use-presence
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```svelte
|
|
50
|
+
* <script lang="ts">
|
|
51
|
+
* import { usePresence } from '@humanspeak/svelte-motion'
|
|
52
|
+
*
|
|
53
|
+
* let node: HTMLElement | undefined = $state()
|
|
54
|
+
* const presence = $derived(usePresence())
|
|
55
|
+
*
|
|
56
|
+
* $effect(() => {
|
|
57
|
+
* const [isPresent, safeToRemove] = presence
|
|
58
|
+
* if (isPresent || !node) return
|
|
59
|
+
* const onEnd = () => safeToRemove()
|
|
60
|
+
* node.addEventListener('transitionend', onEnd, { once: true })
|
|
61
|
+
* node.classList.add('exiting')
|
|
62
|
+
* return () => node?.removeEventListener('transitionend', onEnd)
|
|
63
|
+
* })
|
|
64
|
+
* </script>
|
|
65
|
+
*
|
|
66
|
+
* <div bind:this={node}>…</div>
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
export const usePresence = () => {
|
|
70
|
+
const context = getPresenceChildContext();
|
|
71
|
+
if (!context)
|
|
72
|
+
return [true, null];
|
|
73
|
+
return context.isPresent ? [true, null] : [false, context.safeToRemove];
|
|
74
|
+
};
|
package/dist/vite.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Parser } from 'acorn';
|
|
1
2
|
/**
|
|
2
3
|
* Tag-to-component name mapping. Each key is the lowercase HTML/SVG tag,
|
|
3
4
|
* and the value is the PascalCase component filename (without .svelte).
|
|
@@ -318,16 +319,18 @@ export const svelteMotionOptimize = () => ({
|
|
|
318
319
|
for (const [tag, localName] of tagToLocal) {
|
|
319
320
|
const openRe = new RegExp(`<motion\\.${escapeRegExp(tag)}(?=[\\s/>])`, 'g');
|
|
320
321
|
const closeRe = new RegExp(`</motion\\.${escapeRegExp(tag)}\\s*>`, 'g');
|
|
321
|
-
const scriptRe = new RegExp(`\\bmotion\\.${escapeRegExp(tag)}\\b`, 'g');
|
|
322
322
|
transformed = transformed.replace(openRe, `<${localName}`);
|
|
323
323
|
transformed = transformed.replace(closeRe, `</${localName}>`);
|
|
324
|
-
// Also replace script-block references (e.g., const Component = motion.div)
|
|
325
|
-
// But only outside of the import statement we already handled
|
|
326
|
-
const importEndIdx = transformed.indexOf(localName) + localName.length;
|
|
327
|
-
const beforeImport = transformed.slice(0, importEndIdx);
|
|
328
|
-
const afterImport = transformed.slice(importEndIdx);
|
|
329
|
-
transformed = beforeImport + afterImport.replace(scriptRe, localName);
|
|
330
324
|
}
|
|
325
|
+
// Rewrite `motion.TAG` JS references (e.g. `const Component = motion.div`)
|
|
326
|
+
// inside <script> blocks only. A naive regex over the script body would
|
|
327
|
+
// also clobber the same substring in string literals (`"motion.div"`)
|
|
328
|
+
// and comments (`// motion.div`). Parse the script as JS instead and
|
|
329
|
+
// only rewrite real `motion.<tag>` MemberExpressions. For scripts that
|
|
330
|
+
// fail to parse as plain JS (e.g. `<script lang="ts">`), fall back to a
|
|
331
|
+
// string/comment-aware lexer that achieves the same correctness without
|
|
332
|
+
// needing a TS parser.
|
|
333
|
+
transformed = transformed.replace(/(<script\b[^>]*>)([\s\S]*?)(<\/script>)/g, (_full, open, content, close) => open + rewriteMotionRefsInScript(content, tagToLocal) + close);
|
|
331
334
|
return {
|
|
332
335
|
code: transformed,
|
|
333
336
|
map: null
|
|
@@ -341,3 +344,169 @@ export const svelteMotionOptimize = () => ({
|
|
|
341
344
|
* @returns The escaped string safe for use in a RegExp.
|
|
342
345
|
*/
|
|
343
346
|
const escapeRegExp = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
347
|
+
/**
|
|
348
|
+
* Rewrite `motion.<tag>` member-expression references inside a `<script>`
|
|
349
|
+
* body to the matching `SvelteMotionTag` local. Preserves string literals
|
|
350
|
+
* and comments — they look like `motion.div` to a regex but must not be
|
|
351
|
+
* rewritten.
|
|
352
|
+
*
|
|
353
|
+
* Strategy: parse the body as JS with acorn and splice only real
|
|
354
|
+
* MemberExpression matches. If parsing fails (TypeScript, JSX, etc.) fall
|
|
355
|
+
* back to a string/comment-aware lexer that skips literals and comments.
|
|
356
|
+
*
|
|
357
|
+
* @param content - Raw script body (between `<script ...>` and `</script>`).
|
|
358
|
+
* @param tagToLocal - Map of lowercase tag → local component identifier.
|
|
359
|
+
* @returns The rewritten body, ready to splice back into the source.
|
|
360
|
+
*/
|
|
361
|
+
const rewriteMotionRefsInScript = (content, tagToLocal) => {
|
|
362
|
+
try {
|
|
363
|
+
return rewriteViaAst(content, tagToLocal);
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
return rewriteViaLexer(content, tagToLocal);
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
const isIdentifier = (n) => !!n && n.type === 'Identifier';
|
|
370
|
+
const isMemberExpression = (n) => !!n && n.type === 'MemberExpression';
|
|
371
|
+
/**
|
|
372
|
+
* Walk an acorn AST and collect every `motion.<tag>` MemberExpression range
|
|
373
|
+
* we should rewrite. Splice from end to start so earlier indices stay valid.
|
|
374
|
+
*/
|
|
375
|
+
const rewriteViaAst = (content, tagToLocal) => {
|
|
376
|
+
const ast = Parser.parse(content, {
|
|
377
|
+
ecmaVersion: 'latest',
|
|
378
|
+
sourceType: 'module',
|
|
379
|
+
allowImportExportEverywhere: true,
|
|
380
|
+
allowReturnOutsideFunction: true,
|
|
381
|
+
allowAwaitOutsideFunction: true,
|
|
382
|
+
allowHashBang: true
|
|
383
|
+
});
|
|
384
|
+
const edits = [];
|
|
385
|
+
const visit = (node) => {
|
|
386
|
+
if (!node || typeof node !== 'object' || typeof node.type !== 'string')
|
|
387
|
+
return;
|
|
388
|
+
if (isMemberExpression(node) &&
|
|
389
|
+
!node.computed &&
|
|
390
|
+
isIdentifier(node.object) &&
|
|
391
|
+
node.object.name === 'motion' &&
|
|
392
|
+
isIdentifier(node.property)) {
|
|
393
|
+
const localName = tagToLocal.get(node.property.name);
|
|
394
|
+
if (localName) {
|
|
395
|
+
edits.push({ start: node.start, end: node.end, replacement: localName });
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
for (const key of Object.keys(node)) {
|
|
400
|
+
if (key === 'type' || key === 'start' || key === 'end' || key === 'loc')
|
|
401
|
+
continue;
|
|
402
|
+
const value = node[key];
|
|
403
|
+
if (Array.isArray(value))
|
|
404
|
+
value.forEach((v) => visit(v));
|
|
405
|
+
else if (value && typeof value === 'object')
|
|
406
|
+
visit(value);
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
visit(ast);
|
|
410
|
+
if (edits.length === 0)
|
|
411
|
+
return content;
|
|
412
|
+
edits.sort((a, b) => b.start - a.start);
|
|
413
|
+
let out = content;
|
|
414
|
+
for (const edit of edits) {
|
|
415
|
+
out = out.slice(0, edit.start) + edit.replacement + out.slice(edit.end);
|
|
416
|
+
}
|
|
417
|
+
return out;
|
|
418
|
+
};
|
|
419
|
+
/**
|
|
420
|
+
* Fallback for scripts acorn can't parse (TS, JSX). Walks the source
|
|
421
|
+
* character-by-character, skipping string literals (`'`, `"`, backtick incl.
|
|
422
|
+
* `${…}` substitutions) and line/block comments, then applies a `motion.<tag>`
|
|
423
|
+
* regex to the remaining "code" regions. Less precise than AST but covers
|
|
424
|
+
* the same correctness contract for literal/comment preservation.
|
|
425
|
+
*/
|
|
426
|
+
const rewriteViaLexer = (content, tagToLocal) => {
|
|
427
|
+
const len = content.length;
|
|
428
|
+
const out = [];
|
|
429
|
+
let i = 0;
|
|
430
|
+
const isIdStart = (ch) => /[A-Za-z_$]/.test(ch);
|
|
431
|
+
const isIdPart = (ch) => /[A-Za-z0-9_$-]/.test(ch);
|
|
432
|
+
while (i < len) {
|
|
433
|
+
const ch = content[i];
|
|
434
|
+
const next = content[i + 1];
|
|
435
|
+
// Line comment
|
|
436
|
+
if (ch === '/' && next === '/') {
|
|
437
|
+
const end = content.indexOf('\n', i);
|
|
438
|
+
const stop = end === -1 ? len : end;
|
|
439
|
+
out.push(content.slice(i, stop));
|
|
440
|
+
i = stop;
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
// Block comment
|
|
444
|
+
if (ch === '/' && next === '*') {
|
|
445
|
+
const end = content.indexOf('*/', i + 2);
|
|
446
|
+
const stop = end === -1 ? len : end + 2;
|
|
447
|
+
out.push(content.slice(i, stop));
|
|
448
|
+
i = stop;
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
// String literals (single/double)
|
|
452
|
+
if (ch === '"' || ch === "'") {
|
|
453
|
+
const quote = ch;
|
|
454
|
+
let j = i + 1;
|
|
455
|
+
while (j < len) {
|
|
456
|
+
if (content[j] === '\\') {
|
|
457
|
+
j += 2;
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
if (content[j] === quote) {
|
|
461
|
+
j++;
|
|
462
|
+
break;
|
|
463
|
+
}
|
|
464
|
+
j++;
|
|
465
|
+
}
|
|
466
|
+
out.push(content.slice(i, j));
|
|
467
|
+
i = j;
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
// Template literal — naive: skip to matching backtick, no `${…}` parsing
|
|
471
|
+
// is needed for our use case (we only need to NOT rewrite the literal
|
|
472
|
+
// text; substitutions still look like code but `motion.<tag>` inside
|
|
473
|
+
// a template substitution is vanishingly rare and acorn would normally
|
|
474
|
+
// handle it).
|
|
475
|
+
if (ch === '`') {
|
|
476
|
+
let j = i + 1;
|
|
477
|
+
while (j < len) {
|
|
478
|
+
if (content[j] === '\\') {
|
|
479
|
+
j += 2;
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
if (content[j] === '`') {
|
|
483
|
+
j++;
|
|
484
|
+
break;
|
|
485
|
+
}
|
|
486
|
+
j++;
|
|
487
|
+
}
|
|
488
|
+
out.push(content.slice(i, j));
|
|
489
|
+
i = j;
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
// Possible `motion.<tag>` identifier — require word boundary on left
|
|
493
|
+
if ((i === 0 || !isIdPart(content[i - 1])) &&
|
|
494
|
+
isIdStart(ch) &&
|
|
495
|
+
content.slice(i, i + 7) === 'motion.') {
|
|
496
|
+
let j = i + 7;
|
|
497
|
+
const tagStart = j;
|
|
498
|
+
while (j < len && isIdPart(content[j]))
|
|
499
|
+
j++;
|
|
500
|
+
const tag = content.slice(tagStart, j);
|
|
501
|
+
const localName = tagToLocal.get(tag);
|
|
502
|
+
if (localName && (j === len || !isIdPart(content[j]))) {
|
|
503
|
+
out.push(localName);
|
|
504
|
+
i = j;
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
out.push(ch);
|
|
509
|
+
i++;
|
|
510
|
+
}
|
|
511
|
+
return out.join('');
|
|
512
|
+
};
|