@ssgoi/core 0.0.1 → 0.0.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.
@@ -1,2 +0,0 @@
1
- export * from './transitions/index'
2
- export {}
@@ -1,2 +0,0 @@
1
- export * from './view-transitions/index'
2
- export {}
@@ -1,172 +0,0 @@
1
- import { animate } from "popmotion";
2
-
3
- import type { SpringConfig } from "./types";
4
-
5
- export interface AnimationOptions {
6
- from: number;
7
- to: number;
8
- spring: SpringConfig;
9
- onUpdate: (value: number) => void;
10
- onComplete: () => void;
11
- onStart?: () => void;
12
- }
13
-
14
- /**
15
- * New Animator implementation using Popmotion
16
- * Provides spring-based animations with fine control
17
- */
18
- export class Animator {
19
- private options: AnimationOptions;
20
- private currentValue: number;
21
- private velocity: number = 0;
22
- private isAnimating = false;
23
- private controls: { stop: () => void } | null = null;
24
-
25
- constructor(options: Partial<AnimationOptions>) {
26
- this.options = {
27
- from: options.from ?? 0,
28
- to: options.to ?? 1,
29
- spring: options.spring ?? { stiffness: 100, damping: 10 },
30
- onUpdate: options.onUpdate ?? (() => {}),
31
- onComplete: options.onComplete ?? (() => {}),
32
- onStart: options.onStart,
33
- };
34
- this.currentValue = this.options.from;
35
- }
36
-
37
- private animate = (reverse: boolean = false) => {
38
- // Call onStart on first frame
39
- if (!this.isAnimating && this.options.onStart) {
40
- this.options.onStart();
41
- }
42
-
43
- this.isAnimating = true;
44
-
45
- const target = reverse ? this.options.from : this.options.to;
46
-
47
- // Track previous value for velocity calculation
48
- let previousValue = this.currentValue;
49
- let previousTime = performance.now();
50
-
51
- // Create animation
52
- this.controls = animate({
53
- from: this.currentValue,
54
- to: target,
55
- velocity: this.velocity * 1000, // Convert to px/s
56
- stiffness: this.options.spring.stiffness,
57
- damping: this.options.spring.damping,
58
- mass: 1,
59
-
60
- onUpdate: (value: number) => {
61
- const currentTime = performance.now();
62
- const timeDelta = (currentTime - previousTime) / 1000; // Convert to seconds
63
-
64
- if (timeDelta > 0) {
65
- // Calculate velocity in units per second, then normalize
66
- const rawVelocity = (value - previousValue) / timeDelta;
67
- this.velocity = rawVelocity / 1000; // Normalize to 0-1 range
68
-
69
- previousValue = value;
70
- previousTime = currentTime;
71
- }
72
-
73
- this.currentValue = value;
74
- this.options.onUpdate(value);
75
- },
76
- onComplete: () => {
77
- this.currentValue = target;
78
- this.isAnimating = false;
79
- this.controls = null;
80
- this.velocity = 0;
81
- this.options.onComplete();
82
- },
83
- });
84
- };
85
-
86
- // Animation control methods
87
- forward(): void {
88
- this.stop();
89
- this.animate(false);
90
- }
91
-
92
- backward(): void {
93
- this.stop();
94
- this.animate(true);
95
- }
96
-
97
- reverse(): void {
98
- // Swap from and to
99
- const temp = this.options.from;
100
- this.options.from = this.options.to;
101
- this.options.to = temp;
102
-
103
- // If currently animating, restart with new direction
104
- if (this.isAnimating) {
105
- const wasReversed =
106
- this.currentValue > (this.options.from + this.options.to) / 2;
107
- this.stop();
108
- this.animate(!wasReversed);
109
- }
110
- }
111
-
112
- stop(): void {
113
- this.isAnimating = false;
114
- if (this.controls) {
115
- this.controls.stop();
116
- this.controls = null;
117
- }
118
- // Preserve velocity when stopping
119
- }
120
-
121
- // State getters
122
- getVelocity(): number {
123
- return this.velocity;
124
- }
125
-
126
- getCurrentValue(): number {
127
- return this.currentValue;
128
- }
129
-
130
- getIsAnimating(): boolean {
131
- return this.isAnimating;
132
- }
133
-
134
- getCurrentState(): { position: number; velocity: number } {
135
- return {
136
- position: this.currentValue,
137
- velocity: this.velocity,
138
- };
139
- }
140
-
141
- // State setters
142
- setVelocity(velocity: number): void {
143
- this.velocity = velocity;
144
- }
145
-
146
- setValue(value: number): void {
147
- this.currentValue = value;
148
- }
149
-
150
- // Configuration
151
- updateOptions(newOptions: Partial<AnimationOptions>): void {
152
- this.options = { ...this.options, ...newOptions };
153
-
154
- // If animating, restart with new options
155
- if (this.isAnimating && this.controls) {
156
- const currentDirection = this.currentValue < this.options.to;
157
- this.stop();
158
- this.animate(!currentDirection);
159
- }
160
- }
161
-
162
- // Static factory method
163
- static fromState(
164
- state: { position: number; velocity: number },
165
- newOptions: Partial<AnimationOptions>
166
- ): Animator {
167
- const animation = new Animator(newOptions);
168
- animation.setValue(state.position);
169
- animation.setVelocity(state.velocity);
170
- return animation;
171
- }
172
- }
@@ -1,265 +0,0 @@
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
- }
@@ -1,246 +0,0 @@
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 DELETED
@@ -1,3 +0,0 @@
1
- export * from "./types";
2
- export * from "./transition";
3
- export * from "./create-ssgoi-transition-context";