@ssgoi/core 0.0.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 +34 -0
- package/dist/animator.d.ts +41 -0
- package/dist/animator.d.ts.map +1 -0
- package/dist/create-ssgoi-transition-context.d.ts +15 -0
- package/dist/create-ssgoi-transition-context.d.ts.map +1 -0
- package/dist/create-transition-callback.d.ts +36 -0
- package/dist/create-transition-callback.d.ts.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/ssgoi.cjs +1 -0
- package/dist/ssgoi.js +1 -0
- package/dist/ssgoi2.cjs +1 -0
- package/dist/ssgoi2.js +305 -0
- package/dist/ssgoi3.cjs +1 -0
- package/dist/ssgoi3.js +173 -0
- package/dist/ssgoi4.cjs +1 -0
- package/dist/ssgoi4.js +764 -0
- package/dist/transition.d.ts +16 -0
- package/dist/transition.d.ts.map +1 -0
- package/dist/transitions/blur.d.ts +27 -0
- package/dist/transitions/blur.d.ts.map +1 -0
- package/dist/transitions/bounce.d.ts +27 -0
- package/dist/transitions/bounce.d.ts.map +1 -0
- package/dist/transitions/fade.d.ts +26 -0
- package/dist/transitions/fade.d.ts.map +1 -0
- package/dist/transitions/index.d.ts +8 -0
- package/dist/transitions/index.d.ts.map +1 -0
- package/dist/transitions/none.d.ts +24 -0
- package/dist/transitions/none.d.ts.map +1 -0
- package/dist/transitions/rotate.d.ts +28 -0
- package/dist/transitions/rotate.d.ts.map +1 -0
- package/dist/transitions/scale.d.ts +27 -0
- package/dist/transitions/scale.d.ts.map +1 -0
- package/dist/transitions/slide.d.ts +27 -0
- package/dist/transitions/slide.d.ts.map +1 -0
- package/dist/transitions.d.ts +2 -0
- package/dist/types.d.ts +38 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/utils-BSgyev81.cjs +1 -0
- package/dist/utils-DR41YsAh.js +16 -0
- package/dist/utils.d.ts +11 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/view-transitions/fade.d.ts +7 -0
- package/dist/view-transitions/fade.d.ts.map +1 -0
- package/dist/view-transitions/hero.d.ts +8 -0
- package/dist/view-transitions/hero.d.ts.map +1 -0
- package/dist/view-transitions/index.d.ts +5 -0
- package/dist/view-transitions/index.d.ts.map +1 -0
- package/dist/view-transitions/pinterest.d.ts +8 -0
- package/dist/view-transitions/pinterest.d.ts.map +1 -0
- package/dist/view-transitions/ripple.d.ts +7 -0
- package/dist/view-transitions/ripple.d.ts.map +1 -0
- package/dist/view-transitions.d.ts +2 -0
- package/package.json +58 -0
- package/src/lib/animator.ts +172 -0
- package/src/lib/create-ssgoi-transition-context.ts +265 -0
- package/src/lib/create-transition-callback.ts +246 -0
- package/src/lib/index.ts +3 -0
- package/src/lib/transition.ts +76 -0
- package/src/lib/transitions/blur.ts +55 -0
- package/src/lib/transitions/bounce.ts +61 -0
- package/src/lib/transitions/fade.ts +33 -0
- package/src/lib/transitions/index.ts +7 -0
- package/src/lib/transitions/none.ts +27 -0
- package/src/lib/transitions/rotate.ts +57 -0
- package/src/lib/transitions/scale.ts +34 -0
- package/src/lib/transitions/slide.ts +53 -0
- package/src/lib/types.ts +60 -0
- package/src/lib/utils.ts +39 -0
- package/src/lib/view-transitions/fade.ts +29 -0
- package/src/lib/view-transitions/hero.ts +179 -0
- package/src/lib/view-transitions/index.ts +4 -0
- package/src/lib/view-transitions/pinterest.ts +403 -0
- package/src/lib/view-transitions/ripple.ts +45 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SsgoiConfig,
|
|
3
|
+
SsgoiContext,
|
|
4
|
+
GetTransitionConfig,
|
|
5
|
+
Transition,
|
|
6
|
+
} from "./types";
|
|
7
|
+
import { getScrollingElement } from "./utils";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* SSGOI Transition Context Operation Principles
|
|
11
|
+
*
|
|
12
|
+
* Page transition scenario: /home → /about
|
|
13
|
+
*
|
|
14
|
+
* 1. OUT animation starts (when /home page disappears)
|
|
15
|
+
* - getTransition('unique-id', 'out', '/home') is called
|
|
16
|
+
* - Stores { from: '/home' } in pendingTransitions
|
|
17
|
+
* - Creates Promise and stores outResolve (not resolved yet)
|
|
18
|
+
* - Calls checkAndResolve → waits because 'to' is missing
|
|
19
|
+
*
|
|
20
|
+
* 2. IN animation starts (when /about page appears)
|
|
21
|
+
* - getTransition('unique-id', 'in', '/about') is called
|
|
22
|
+
* - Adds { to: '/about' } to existing pending
|
|
23
|
+
* - Creates Promise and stores inResolve
|
|
24
|
+
* - Calls checkAndResolve → both 'from' and 'to' are present!
|
|
25
|
+
*
|
|
26
|
+
* 3. Transition matching and resolution
|
|
27
|
+
* - Finds appropriate transition with from: '/home', to: '/about'
|
|
28
|
+
* - Resolves both out and in with the found transition's settings
|
|
29
|
+
* - Removes the id from pendingTransitions
|
|
30
|
+
*
|
|
31
|
+
* Key point: OUT and IN wait for each other. When both are ready,
|
|
32
|
+
* they find the appropriate transition using from/to info
|
|
33
|
+
* and resolve simultaneously.
|
|
34
|
+
*
|
|
35
|
+
* Edge cases:
|
|
36
|
+
* - No OUT animation on page refresh or initial entry
|
|
37
|
+
* - When only IN is called, checkAndResolve doesn't work without 'from'
|
|
38
|
+
* - Promise isn't resolved, so animation doesn't start
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
type PendingTransition = {
|
|
42
|
+
from?: string;
|
|
43
|
+
to?: string;
|
|
44
|
+
outResolve?: (transition: GetTransitionConfig) => void;
|
|
45
|
+
inResolve?: (transition: GetTransitionConfig) => void;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Creates a transition configuration
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* const config = createSggoiTransitionConfig({
|
|
53
|
+
* transitions: [
|
|
54
|
+
* { from: '/home', to: '/about', transition: fade() },
|
|
55
|
+
* { from: '/products', to: '/products/*', transition: slide() }
|
|
56
|
+
* ],
|
|
57
|
+
* defaultTransition: fade()
|
|
58
|
+
* });
|
|
59
|
+
*/
|
|
60
|
+
export function createSggoiTransitionContext(
|
|
61
|
+
options: SsgoiConfig
|
|
62
|
+
): SsgoiContext {
|
|
63
|
+
let pendingTransition: PendingTransition | null = null;
|
|
64
|
+
|
|
65
|
+
// Process symmetric transitions - creates bidirectional transitions automatically
|
|
66
|
+
const processedTransitions = [...options.transitions];
|
|
67
|
+
const symmetricTransitions: typeof options.transitions = [];
|
|
68
|
+
|
|
69
|
+
for (const transitionDef of options.transitions) {
|
|
70
|
+
if (transitionDef.symmetric) {
|
|
71
|
+
// Check if reverse transition already exists to avoid duplicates
|
|
72
|
+
const reverseExists = processedTransitions.some(
|
|
73
|
+
t => t.from === transitionDef.to && t.to === transitionDef.from
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
if (!reverseExists) {
|
|
77
|
+
// Create reverse transition for symmetric navigation
|
|
78
|
+
symmetricTransitions.push({
|
|
79
|
+
from: transitionDef.to,
|
|
80
|
+
to: transitionDef.from,
|
|
81
|
+
transition: transitionDef.transition,
|
|
82
|
+
// Don't add symmetric flag to the reverse to avoid infinite loop
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Add symmetric transitions to the processed list for matching
|
|
89
|
+
processedTransitions.push(...symmetricTransitions);
|
|
90
|
+
|
|
91
|
+
// Scroll tracking - preserves scroll positions between page transitions
|
|
92
|
+
let scrollContainer: HTMLElement | null = null;
|
|
93
|
+
const scrollPositions: Map<string, { x: number; y: number }> = new Map();
|
|
94
|
+
let currentPath: string | null = null;
|
|
95
|
+
|
|
96
|
+
// Scroll listener - captures current scroll position
|
|
97
|
+
const scrollListener = () => {
|
|
98
|
+
if (scrollContainer && currentPath) {
|
|
99
|
+
scrollPositions.set(currentPath, {
|
|
100
|
+
x: scrollContainer.scrollLeft,
|
|
101
|
+
y: scrollContainer.scrollTop,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// Start tracking scroll for a path - initializes scroll container and updates current path
|
|
107
|
+
const startScrollTracking = (element: HTMLElement, path: string) => {
|
|
108
|
+
// Initialize scroll container once - finds the scrollable element
|
|
109
|
+
if (!scrollContainer) {
|
|
110
|
+
scrollContainer = getScrollingElement(element);
|
|
111
|
+
scrollContainer.addEventListener("scroll", scrollListener, {
|
|
112
|
+
passive: true,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Update current path for scroll position tracking
|
|
117
|
+
currentPath = path;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// Calculate scroll offset - computes difference between pages' scroll positions
|
|
121
|
+
const calculateScrollOffset = (): { x: number; y: number } => {
|
|
122
|
+
const from = pendingTransition?.from;
|
|
123
|
+
const to = pendingTransition?.to;
|
|
124
|
+
|
|
125
|
+
const fromScroll =
|
|
126
|
+
from && scrollPositions.has(from)
|
|
127
|
+
? scrollPositions.get(from)!
|
|
128
|
+
: { x: 0, y: 0 };
|
|
129
|
+
|
|
130
|
+
const toScroll =
|
|
131
|
+
to && scrollPositions.has(to) ? scrollPositions.get(to)! : { x: 0, y: 0 };
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
x: -toScroll.x + fromScroll.x,
|
|
135
|
+
y: -toScroll.y + fromScroll.y,
|
|
136
|
+
};
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
function checkAndResolve() {
|
|
140
|
+
if (pendingTransition?.from && pendingTransition?.to) {
|
|
141
|
+
const transition = findMatchingTransition(
|
|
142
|
+
pendingTransition.from,
|
|
143
|
+
pendingTransition.to,
|
|
144
|
+
processedTransitions
|
|
145
|
+
);
|
|
146
|
+
const result = transition || options.defaultTransition;
|
|
147
|
+
const scrollOffset = calculateScrollOffset();
|
|
148
|
+
const context = { scrollOffset };
|
|
149
|
+
|
|
150
|
+
if (result) {
|
|
151
|
+
if (result.out && pendingTransition.outResolve) {
|
|
152
|
+
pendingTransition.outResolve((element) =>
|
|
153
|
+
result.out!(element, context)
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
if (result.in && pendingTransition.inResolve) {
|
|
157
|
+
pendingTransition.inResolve((element) =>
|
|
158
|
+
result.in!(element, context)
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
pendingTransition = null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const getTransition = async (path: string, type: "out" | "in") => {
|
|
168
|
+
if (type === "in") {
|
|
169
|
+
// If IN is called but no OUT is pending, no transition occurs (e.g., page refresh)
|
|
170
|
+
if (!pendingTransition || !pendingTransition.from) {
|
|
171
|
+
return () => ({}); // Return empty transition
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (!pendingTransition) {
|
|
176
|
+
pendingTransition = {};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (type === "out") {
|
|
180
|
+
pendingTransition.from = path;
|
|
181
|
+
return new Promise<GetTransitionConfig>((resolve) => {
|
|
182
|
+
pendingTransition!.outResolve = resolve;
|
|
183
|
+
checkAndResolve();
|
|
184
|
+
});
|
|
185
|
+
} else {
|
|
186
|
+
pendingTransition.to = path;
|
|
187
|
+
return new Promise<GetTransitionConfig>((resolve) => {
|
|
188
|
+
pendingTransition!.inResolve = resolve;
|
|
189
|
+
checkAndResolve();
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
return (path: string) => {
|
|
195
|
+
return {
|
|
196
|
+
key: path,
|
|
197
|
+
in: async (element: HTMLElement) => {
|
|
198
|
+
// Start scroll tracking for this path when element enters
|
|
199
|
+
startScrollTracking(element, path);
|
|
200
|
+
|
|
201
|
+
const transitionConfig = await getTransition(path, "in");
|
|
202
|
+
return transitionConfig(element);
|
|
203
|
+
},
|
|
204
|
+
out: async (element: HTMLElement) => {
|
|
205
|
+
const transitionConfig = await getTransition(path, "out");
|
|
206
|
+
return transitionConfig(element);
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Matches a path against a pattern
|
|
214
|
+
* Supports exact matches and wildcard patterns
|
|
215
|
+
*
|
|
216
|
+
* @example
|
|
217
|
+
* matchPath('/products', '/products') // true
|
|
218
|
+
* matchPath('/products/123', '/products/*') // true
|
|
219
|
+
* matchPath('/products/123', '/products') // false
|
|
220
|
+
* matchPath('/anything', '*') // true
|
|
221
|
+
*/
|
|
222
|
+
function findMatchingTransition<TContext>(
|
|
223
|
+
from: string,
|
|
224
|
+
to: string,
|
|
225
|
+
transitions: Array<{
|
|
226
|
+
from: string;
|
|
227
|
+
to: string;
|
|
228
|
+
transition: Transition<TContext>;
|
|
229
|
+
}>
|
|
230
|
+
): Transition<TContext> | null {
|
|
231
|
+
// First try to find exact match for both from and to paths
|
|
232
|
+
for (const config of transitions) {
|
|
233
|
+
if (matchPath(from, config.from) && matchPath(to, config.to)) {
|
|
234
|
+
return config.transition;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Then try wildcard matches if no exact match found
|
|
239
|
+
for (const config of transitions) {
|
|
240
|
+
if (
|
|
241
|
+
(config.from === "*" || matchPath(from, config.from)) &&
|
|
242
|
+
(config.to === "*" || matchPath(to, config.to))
|
|
243
|
+
) {
|
|
244
|
+
return config.transition;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function matchPath(path: string, pattern: string): boolean {
|
|
252
|
+
// Universal match - asterisk matches any path
|
|
253
|
+
if (pattern === "*") {
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Wildcard match - pattern ending with /* matches path and subpaths
|
|
258
|
+
if (pattern.endsWith("/*")) {
|
|
259
|
+
const prefix = pattern.slice(0, -2);
|
|
260
|
+
return path === prefix || path.startsWith(prefix + "/");
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Exact match - paths must be identical
|
|
264
|
+
return path === pattern;
|
|
265
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import type { Transition, TransitionCallback } from "./types";
|
|
2
|
+
import { Animator } from "./animator";
|
|
3
|
+
/**
|
|
4
|
+
* Creates a transition callback that can be used with framework-specific implementations
|
|
5
|
+
* This is the core logic that frameworks can wrap with their own APIs
|
|
6
|
+
*
|
|
7
|
+
* UX Animation Behavior - 4 Main Scenarios:
|
|
8
|
+
*
|
|
9
|
+
* 1. No animation running + IN trigger:
|
|
10
|
+
* - Start entrance animation (0 → 1)
|
|
11
|
+
* - Return cleanup function for exit
|
|
12
|
+
*
|
|
13
|
+
* 2. No animation running + OUT trigger:
|
|
14
|
+
* - Clone element, start exit animation (1 → 0)
|
|
15
|
+
* - Remove clone when complete
|
|
16
|
+
*
|
|
17
|
+
* 3. IN animation running + OUT trigger:
|
|
18
|
+
* - Stop current IN animation (DOM is disappearing)
|
|
19
|
+
* - Clone element for exit animation
|
|
20
|
+
* - Create REVERSED IN animation (not OUT animation) with current state
|
|
21
|
+
* - This gives natural backward motion instead of jumping to OUT definition
|
|
22
|
+
*
|
|
23
|
+
* 4. OUT animation running + IN trigger:
|
|
24
|
+
* - Stop current OUT animation (cleanup any cloned elements)
|
|
25
|
+
* - Create REVERSED OUT animation (not IN animation) with current state
|
|
26
|
+
* - This gives natural backward motion instead of jumping to IN definition
|
|
27
|
+
* - Switch to entrance mode
|
|
28
|
+
*
|
|
29
|
+
* Closure Structure:
|
|
30
|
+
* - Outer function: Returns entrance callback
|
|
31
|
+
* - Inner function (entrance callback): Returns cleanup callback (exit)
|
|
32
|
+
* - Cleanup callback: Handles exit transitions
|
|
33
|
+
*/
|
|
34
|
+
export function createTransitionCallback(
|
|
35
|
+
getTransition: () => Transition,
|
|
36
|
+
options?: {
|
|
37
|
+
onCleanupEnd?: () => void;
|
|
38
|
+
}
|
|
39
|
+
): TransitionCallback {
|
|
40
|
+
let currentAnimation: Animator | null = null;
|
|
41
|
+
let currentClone: HTMLElement | null = null; // Track current clone element
|
|
42
|
+
let parentRef: Element | null = null;
|
|
43
|
+
let nextSiblingRef: Element | null = null;
|
|
44
|
+
let isEntering = false; // Track current transition direction
|
|
45
|
+
|
|
46
|
+
const runEntrance = async (element: HTMLElement) => {
|
|
47
|
+
// Scenario 4: OUT animation running + IN trigger
|
|
48
|
+
if (currentAnimation && currentAnimation.getIsAnimating() && !isEntering) {
|
|
49
|
+
// Stop current OUT animation
|
|
50
|
+
const currentState = currentAnimation.getCurrentState();
|
|
51
|
+
currentAnimation.stop();
|
|
52
|
+
|
|
53
|
+
// Remove clone immediately
|
|
54
|
+
if (currentClone) {
|
|
55
|
+
currentClone.remove();
|
|
56
|
+
currentClone = null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Start reversed OUT animation on original element (IN direction)
|
|
60
|
+
isEntering = true;
|
|
61
|
+
const transition = getTransition();
|
|
62
|
+
if (!transition.out) return;
|
|
63
|
+
|
|
64
|
+
const outConfig = await Promise.resolve(transition.out(element));
|
|
65
|
+
|
|
66
|
+
// Use OUT config but reverse direction (backward)
|
|
67
|
+
currentAnimation = Animator.fromState(currentState, {
|
|
68
|
+
from: 1,
|
|
69
|
+
to: 0,
|
|
70
|
+
spring: outConfig.spring,
|
|
71
|
+
onStart: outConfig.onStart,
|
|
72
|
+
onUpdate: (value) => {
|
|
73
|
+
outConfig.tick?.(value);
|
|
74
|
+
},
|
|
75
|
+
onComplete: () => {
|
|
76
|
+
currentAnimation = null;
|
|
77
|
+
isEntering = false;
|
|
78
|
+
outConfig.onEnd?.();
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
currentAnimation.backward();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Scenario 1: No animation running OR IN already running
|
|
87
|
+
if (!currentAnimation || !currentAnimation.getIsAnimating()) {
|
|
88
|
+
// Start new IN animation
|
|
89
|
+
isEntering = true;
|
|
90
|
+
const transition = getTransition();
|
|
91
|
+
if (!transition.in) return;
|
|
92
|
+
|
|
93
|
+
const inConfig = await Promise.resolve(transition.in(element));
|
|
94
|
+
|
|
95
|
+
// Apply prepare function if provided
|
|
96
|
+
inConfig.prepare?.(element);
|
|
97
|
+
|
|
98
|
+
currentAnimation = new Animator({
|
|
99
|
+
from: 0,
|
|
100
|
+
to: 1,
|
|
101
|
+
spring: inConfig.spring,
|
|
102
|
+
onStart: inConfig.onStart,
|
|
103
|
+
onUpdate: (value) => {
|
|
104
|
+
inConfig.tick?.(value);
|
|
105
|
+
},
|
|
106
|
+
onComplete: () => {
|
|
107
|
+
currentAnimation = null;
|
|
108
|
+
isEntering = false;
|
|
109
|
+
inConfig.onEnd?.();
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
currentAnimation.forward();
|
|
114
|
+
}
|
|
115
|
+
// If IN is already running, just continue
|
|
116
|
+
};
|
|
117
|
+
function runExitTransition(element: HTMLElement) {
|
|
118
|
+
// Helper function to insert clone into DOM
|
|
119
|
+
const insertClone = () => {
|
|
120
|
+
if (!parentRef || !currentClone) return;
|
|
121
|
+
|
|
122
|
+
if (nextSiblingRef && parentRef.contains(nextSiblingRef)) {
|
|
123
|
+
parentRef.insertBefore(currentClone, nextSiblingRef);
|
|
124
|
+
} else {
|
|
125
|
+
parentRef.appendChild(currentClone);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// Helper function to handle cleanup
|
|
130
|
+
const cleanup = () => {
|
|
131
|
+
if (currentClone) {
|
|
132
|
+
currentClone.remove();
|
|
133
|
+
currentClone = null;
|
|
134
|
+
}
|
|
135
|
+
currentAnimation = null;
|
|
136
|
+
isEntering = false;
|
|
137
|
+
options?.onCleanupEnd?.();
|
|
138
|
+
};
|
|
139
|
+
// Get transition
|
|
140
|
+
const transition = getTransition();
|
|
141
|
+
// Clone the element upfront
|
|
142
|
+
currentClone = element.cloneNode(true) as HTMLElement;
|
|
143
|
+
|
|
144
|
+
// Scenario 3: IN animation running + OUT trigger
|
|
145
|
+
if (currentAnimation && currentAnimation.getIsAnimating() && isEntering) {
|
|
146
|
+
// Stop current IN animation and create REVERSED IN animation (not OUT)
|
|
147
|
+
const currentState = currentAnimation.getCurrentState();
|
|
148
|
+
currentAnimation.stop();
|
|
149
|
+
isEntering = false;
|
|
150
|
+
|
|
151
|
+
// Get the IN config (not OUT) because we want to reverse the IN animation
|
|
152
|
+
if (!transition.in) {
|
|
153
|
+
currentClone = null;
|
|
154
|
+
cleanup();
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
Promise.resolve(transition.in(currentClone)).then(async (inConfig) => {
|
|
159
|
+
const outConfig =
|
|
160
|
+
currentClone && (await transition.out?.(currentClone));
|
|
161
|
+
|
|
162
|
+
if (outConfig?.prepare) {
|
|
163
|
+
currentClone && outConfig.prepare(currentClone);
|
|
164
|
+
}
|
|
165
|
+
// Insert clone only after we have the transition config
|
|
166
|
+
insertClone();
|
|
167
|
+
|
|
168
|
+
// Use IN config but reverse direction (backward)
|
|
169
|
+
currentAnimation = Animator.fromState(currentState, {
|
|
170
|
+
from: 0,
|
|
171
|
+
to: 1,
|
|
172
|
+
spring: inConfig.spring,
|
|
173
|
+
onStart: inConfig.onStart,
|
|
174
|
+
onUpdate: (value) => {
|
|
175
|
+
inConfig.tick?.(value);
|
|
176
|
+
},
|
|
177
|
+
onComplete: () => {
|
|
178
|
+
inConfig.onEnd?.();
|
|
179
|
+
cleanup();
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
currentAnimation.backward();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Scenario 2: No animation running OR OUT already running
|
|
190
|
+
if (
|
|
191
|
+
!currentAnimation ||
|
|
192
|
+
!currentAnimation.getIsAnimating() ||
|
|
193
|
+
!isEntering
|
|
194
|
+
) {
|
|
195
|
+
isEntering = false;
|
|
196
|
+
|
|
197
|
+
if (!transition.out) {
|
|
198
|
+
currentClone = null;
|
|
199
|
+
cleanup();
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
Promise.resolve(transition.out(currentClone)).then((outConfig) => {
|
|
204
|
+
// Apply prepare function if provided
|
|
205
|
+
|
|
206
|
+
currentClone && outConfig.prepare?.(currentClone);
|
|
207
|
+
|
|
208
|
+
// Insert clone after preparation
|
|
209
|
+
insertClone();
|
|
210
|
+
|
|
211
|
+
currentAnimation = new Animator({
|
|
212
|
+
from: 1,
|
|
213
|
+
to: 0,
|
|
214
|
+
spring: outConfig.spring,
|
|
215
|
+
onStart: outConfig.onStart,
|
|
216
|
+
onUpdate: (value) => {
|
|
217
|
+
outConfig.tick?.(value);
|
|
218
|
+
},
|
|
219
|
+
onComplete: () => {
|
|
220
|
+
outConfig.onEnd?.();
|
|
221
|
+
cleanup();
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
currentAnimation.forward();
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
// If OUT is already running, just continue
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return (element: HTMLElement | null) => {
|
|
232
|
+
if (!element) return;
|
|
233
|
+
parentRef = element.parentElement;
|
|
234
|
+
nextSiblingRef = element.nextElementSibling;
|
|
235
|
+
|
|
236
|
+
const transition = getTransition();
|
|
237
|
+
if (transition.in) {
|
|
238
|
+
runEntrance(element);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Return cleanup function for exit transition
|
|
242
|
+
return () => {
|
|
243
|
+
runExitTransition(element);
|
|
244
|
+
};
|
|
245
|
+
};
|
|
246
|
+
}
|
package/src/lib/index.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { createTransitionCallback } from "./create-transition-callback";
|
|
2
|
+
import type { Transition, TransitionCallback } from "./types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Key type for transitions - can be string or symbol
|
|
6
|
+
*/
|
|
7
|
+
type TransitionKey = string | symbol;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Centralized transition management
|
|
11
|
+
* Uses string/symbol keys for all storage
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// Map to store transition definitions by key
|
|
15
|
+
const transitionDefinitions = new Map<TransitionKey, Transition>();
|
|
16
|
+
|
|
17
|
+
// Map to store transition callbacks by key
|
|
18
|
+
const transitionCallbacks = new Map<TransitionKey, TransitionCallback>();
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Registers a transition with a key and returns the callback
|
|
22
|
+
* Usage: registerTransition('fade', { in: fadeIn, out: fadeOut })
|
|
23
|
+
*/
|
|
24
|
+
function registerTransition(
|
|
25
|
+
key: TransitionKey,
|
|
26
|
+
transition: Transition
|
|
27
|
+
): TransitionCallback {
|
|
28
|
+
transitionDefinitions.set(key, transition);
|
|
29
|
+
|
|
30
|
+
// Return existing callback if it exists
|
|
31
|
+
let callback = transitionCallbacks.get(key);
|
|
32
|
+
if (callback) {
|
|
33
|
+
return callback;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Create new callback
|
|
37
|
+
callback = createTransitionCallback(
|
|
38
|
+
() => {
|
|
39
|
+
const trans = transitionDefinitions.get(key);
|
|
40
|
+
if (!trans) {
|
|
41
|
+
console.warn(`Transition "${String(key)}" not found`);
|
|
42
|
+
return {};
|
|
43
|
+
}
|
|
44
|
+
return trans;
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
onCleanupEnd: () => unregisterTransition(key),
|
|
48
|
+
}
|
|
49
|
+
);
|
|
50
|
+
transitionCallbacks.set(key, callback);
|
|
51
|
+
return callback;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Unregisters a transition and cleans up associated resources
|
|
56
|
+
*/
|
|
57
|
+
function unregisterTransition(key: TransitionKey): void {
|
|
58
|
+
transitionDefinitions.delete(key);
|
|
59
|
+
transitionCallbacks.delete(key);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Framework-agnostic transition function that can be used as a ref
|
|
64
|
+
* Usage: <div ref={transition({ key: 'fade', in, out })} />
|
|
65
|
+
*/
|
|
66
|
+
export function transition(options: {
|
|
67
|
+
key: TransitionKey;
|
|
68
|
+
in?: Transition["in"];
|
|
69
|
+
out?: Transition["out"];
|
|
70
|
+
}): TransitionCallback {
|
|
71
|
+
// Register transition and get callback
|
|
72
|
+
return registerTransition(options.key, {
|
|
73
|
+
in: options.in,
|
|
74
|
+
out: options.out,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
interface BlurOptions {
|
|
2
|
+
amount?: number;
|
|
3
|
+
scale?: boolean;
|
|
4
|
+
fade?: boolean;
|
|
5
|
+
spring?: {
|
|
6
|
+
stiffness?: number;
|
|
7
|
+
damping?: number;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const blur = (options: BlurOptions = {}) => {
|
|
12
|
+
const {
|
|
13
|
+
amount = 10,
|
|
14
|
+
scale = false,
|
|
15
|
+
fade = true,
|
|
16
|
+
spring = { stiffness: 300, damping: 30 }
|
|
17
|
+
} = options;
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
in: (element: HTMLElement) => ({
|
|
21
|
+
spring,
|
|
22
|
+
tick: (progress: number) => {
|
|
23
|
+
const blurValue = (1 - progress) * amount;
|
|
24
|
+
const filters = [`blur(${blurValue}px)`];
|
|
25
|
+
|
|
26
|
+
element.style.filter = filters.join(' ');
|
|
27
|
+
|
|
28
|
+
if (scale) {
|
|
29
|
+
element.style.transform = `scale(${0.8 + progress * 0.2})`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (fade) {
|
|
33
|
+
element.style.opacity = progress.toString();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}),
|
|
37
|
+
out: (element: HTMLElement) => ({
|
|
38
|
+
spring,
|
|
39
|
+
tick: (progress: number) => {
|
|
40
|
+
const blurValue = (1 - progress) * amount;
|
|
41
|
+
const filters = [`blur(${blurValue}px)`];
|
|
42
|
+
|
|
43
|
+
element.style.filter = filters.join(' ');
|
|
44
|
+
|
|
45
|
+
if (scale) {
|
|
46
|
+
element.style.transform = `scale(${0.8 + progress * 0.2})`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (fade) {
|
|
50
|
+
element.style.opacity = progress.toString();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
};
|
|
55
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
interface BounceOptions {
|
|
2
|
+
height?: number;
|
|
3
|
+
scale?: boolean;
|
|
4
|
+
fade?: boolean;
|
|
5
|
+
spring?: {
|
|
6
|
+
stiffness?: number;
|
|
7
|
+
damping?: number;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const bounce = (options: BounceOptions = {}) => {
|
|
12
|
+
const {
|
|
13
|
+
height = 20,
|
|
14
|
+
scale = true,
|
|
15
|
+
fade = false,
|
|
16
|
+
spring = { stiffness: 800, damping: 15 }
|
|
17
|
+
} = options;
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
in: (element: HTMLElement) => ({
|
|
21
|
+
spring,
|
|
22
|
+
tick: (progress: number) => {
|
|
23
|
+
const transforms = [];
|
|
24
|
+
|
|
25
|
+
// Bounce effect using sine wave
|
|
26
|
+
const bounceOffset = Math.sin(progress * Math.PI) * height * (1 - progress);
|
|
27
|
+
transforms.push(`translateY(${-bounceOffset}px)`);
|
|
28
|
+
|
|
29
|
+
if (scale) {
|
|
30
|
+
transforms.push(`scale(${0.8 + progress * 0.2})`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
element.style.transform = transforms.join(' ');
|
|
34
|
+
|
|
35
|
+
if (fade) {
|
|
36
|
+
element.style.opacity = progress.toString();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}),
|
|
40
|
+
out: (element: HTMLElement) => ({
|
|
41
|
+
spring,
|
|
42
|
+
tick: (progress: number) => {
|
|
43
|
+
const transforms = [];
|
|
44
|
+
|
|
45
|
+
// Bounce effect using sine wave
|
|
46
|
+
const bounceOffset = Math.sin(progress * Math.PI) * height * (1 - progress);
|
|
47
|
+
transforms.push(`translateY(${-bounceOffset}px)`);
|
|
48
|
+
|
|
49
|
+
if (scale) {
|
|
50
|
+
transforms.push(`scale(${0.8 + progress * 0.2})`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
element.style.transform = transforms.join(' ');
|
|
54
|
+
|
|
55
|
+
if (fade) {
|
|
56
|
+
element.style.opacity = progress.toString();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
};
|
|
61
|
+
};
|