final changes

This commit is contained in:
2025-09-22 11:14:32 +02:00
parent cf157643d7
commit bf9ae5f01f
509 changed files with 920 additions and 64152 deletions
+3
View File
@@ -7,3 +7,6 @@ Archive_*/**
#ignore dist folder
**/dist/**
#ignore log files
**/*.log
-29
View File
@@ -1,29 +0,0 @@
# Development Environment Variables for Local Build
# These are used when running build scripts outside of Docker containers
NODE_ENV=development
PORT=3000
# Database Configuration (Docker containers)
DB_HOST=localhost
DB_PORT=5432
DB_NAME=serpentrace
DB_USERNAME=postgres
DB_PASSWORD=postgres
# Redis Configuration (Docker containers)
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_URL=redis://localhost:6379
# JWT Configuration
JWT_SECRET=dev_jwt_secret_change_in_production
JWT_EXPIRATION=24h
JWT_REFRESH_EXPIRATION=7d
# MinIO Configuration (Docker containers)
MINIO_ENDPOINT=localhost
MINIO_PORT=9000
MINIO_ACCESS_KEY=serpentrace
MINIO_SECRET_KEY=serpentrace123!
MINIO_USE_SSL=false
+22 -4
View File
@@ -20,10 +20,28 @@ REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_URL=redis://localhost:6379
# JWT CONFIGURATION
JWT_SECRET=your_super_secret_jwt_key_change_in_production
JWT_EXPIRY=86400
JWT_EXPIRATION=24h
# JWT AUTHENTICATION CONFIGURATION
JWT_SECRET=your-super-secure-secret-key-here
JWT_REFRESH_SECRET=your-super-secure-refresh-secret-key-here
# Access Token Expiry (choose ONE option, priority order listed):
JWT_ACCESS_TOKEN_EXPIRY=1800 # Seconds (recommended for production)
# JWT_ACCESS_TOKEN_EXPIRATION=30m # Duration string (user-friendly)
# JWT_EXPIRY=1800 # Legacy: seconds
# JWT_EXPIRATION=30m # Legacy: duration string
# Refresh Token Expiry (choose ONE option, priority order listed):
JWT_REFRESH_TOKEN_EXPIRY=604800 # Seconds (7 days)
# JWT_REFRESH_TOKEN_EXPIRATION=7d # Duration string (recommended)
# JWT_REFRESH_EXPIRATION=7d # Legacy: duration string
# Cookie Names (optional)
JWT_COOKIE_NAME=auth_token
JWT_REFRESH_COOKIE_NAME=refresh_token
# Legacy JWT Configuration (deprecated - use above options)
# JWT_EXPIRY=86400
# JWT_EXPIRATION=24h
GAME_TOKEN_EXPIRY=86400
# EMAIL SERVICE CONFIGURATION
@@ -0,0 +1,338 @@
# JWT Refresh Token Implementation Guide
## Overview
The JWT authentication system supports both **cookie-based** and **header-based** (Bearer token) authentication with comprehensive refresh token functionality and proper logout logic. **All authentication methods now use refresh tokens** - there is no legacy single-token mode.
## Features
- **Dual Authentication Methods**: Support for both cookie-based and Bearer token authentication
- **Universal Refresh Tokens**: All logins receive both access and refresh tokens
- **Automatic Token Refresh**: Tokens are refreshed when 75% of their lifetime has passed
- **Logout Functionality**: Proper token blacklisting and cleanup
- **Security**: Short-lived access tokens (30 minutes) and longer-lived refresh tokens (7 days)
## Authentication Methods
### 1. Cookie-Based Authentication
- Access token stored in `auth_token` cookie
- Refresh token stored in `refresh_token` cookie
- Suitable for web applications with same-origin requests
- Tokens also returned in response body
### 2. Bearer Token Authentication
- Access token sent in `Authorization: Bearer <token>` header
- Refresh token sent in `X-Refresh-Token` header
- Suitable for mobile apps, SPAs, and API integrations
- Tokens returned in response body
## API Endpoints
### Login
```http
POST /api/user/login
Content-Type: application/json
{
"username": "user@example.com",
"password": "password123"
}
```
**Response (all logins):**
```json
{
"user": { ... },
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
```
For cookie-based auth, tokens are also set as httpOnly cookies.
### Refresh Token
```http
POST /api/user/refresh-token
```
**For Cookie-based auth:**
- Refresh token is read from `refresh_token` cookie
- New tokens are set as cookies AND returned in response body
**For Bearer token auth:**
```http
POST /api/user/refresh-token
X-Refresh-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
**Response:**
```json
{
"success": true,
"message": "Tokens refreshed successfully",
"accessToken": "new_access_token",
"refreshToken": "new_refresh_token"
}
```
### Logout
```http
POST /api/user/logout
Authorization: Bearer <access_token>
```
Response:
```json
{
"success": true
}
```
## Environment Variables
```env
# JWT Configuration
JWT_SECRET=your-secret-key-for-access-tokens
JWT_REFRESH_SECRET=your-secret-key-for-refresh-tokens
# Access Token Expiry (use one of these)
JWT_ACCESS_TOKEN_EXPIRY=1800 # Access token expiry in seconds (30 minutes)
JWT_ACCESS_TOKEN_EXPIRATION=30m # Access token expiry (supports s, m, h, d)
JWT_EXPIRY=1800 # Legacy: Access token expiry in seconds
JWT_EXPIRATION=30m # Legacy: Access token expiry with duration
# Refresh Token Expiry (use one of these)
JWT_REFRESH_TOKEN_EXPIRY=604800 # Refresh token expiry in seconds (7 days)
JWT_REFRESH_TOKEN_EXPIRATION=7d # Refresh token expiry (supports s, m, h, d)
JWT_REFRESH_EXPIRATION=7d # Legacy: Refresh token expiry with duration
# Cookie Names (optional)
JWT_COOKIE_NAME=auth_token # Access token cookie name (default: auth_token)
JWT_REFRESH_COOKIE_NAME=refresh_token # Refresh token cookie name (default: refresh_token)
```
### Environment Variable Priority
**Access Token Expiry** (checked in order):
1. `JWT_ACCESS_TOKEN_EXPIRY` (seconds)
2. `JWT_ACCESS_TOKEN_EXPIRATION` (duration string)
3. `JWT_EXPIRY` (seconds) - legacy
4. `JWT_EXPIRATION` (duration string) - legacy
5. Default: 1800 seconds (30 minutes)
**Refresh Token Expiry** (checked in order):
1. `JWT_REFRESH_TOKEN_EXPIRY` (seconds)
2. `JWT_REFRESH_TOKEN_EXPIRATION` (duration string)
3. `JWT_REFRESH_EXPIRATION` (duration string) - legacy
4. Default: 604800 seconds (7 days)
### Duration String Format
Supports: `s` (seconds), `m` (minutes), `h` (hours), `d` (days)
Examples: `30s`, `15m`, `2h`, `7d`
## Token Structure
### Access Token Payload
```json
{
"userId": "user-uuid",
"authLevel": 0,
"userStatus": 1,
"orgId": "org-uuid",
"type": "access",
"iat": 1640995200,
"exp": 1640997000
}
```
### Refresh Token Payload
```json
{
"userId": "user-uuid",
"orgId": "org-uuid",
"type": "refresh",
"iat": 1640995200,
"exp": 1641600000
}
```
## Automatic Token Refresh
The system automatically refreshes tokens when:
- Token is within 25% of its expiration time (75% of lifetime has passed)
- Valid refresh token is available
- User makes an authenticated request
**✅ Automatic refresh happens on every authenticated API call** - no manual intervention needed!
### Response Headers
For Bearer token authentication, refresh responses include:
- `X-New-Access-Token`: New access token
- `X-New-Refresh-Token`: New refresh token
- `X-Token-Refreshed`: "true" indicator
### Manual Refresh (Optional)
While automatic refresh handles most scenarios, manual refresh is available for:
- **Proactive refresh**: Before critical operations
- **Background apps**: Long-running applications that need fresh tokens
- **Offline recovery**: When app reconnects after being offline
```http
POST /api/user/refresh-token
X-Refresh-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
## Client Implementation Examples
### JavaScript/TypeScript (Fetch API)
```typescript
class ApiClient {
private accessToken: string = '';
private refreshToken: string = '';
async login(username: string, password: string) {
const response = await fetch('/api/user/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await response.json();
this.accessToken = data.token;
this.refreshToken = data.refreshToken; // Always present now
return data;
}
async makeAuthenticatedRequest(url: string, options: RequestInit = {}) {
const headers = {
'Authorization': `Bearer ${this.accessToken}`,
...options.headers
};
let response = await fetch(url, { ...options, headers });
// Automatically handle token refresh (tokens updated in response headers)
if (response.headers.get('X-Token-Refreshed') === 'true') {
const newAccessToken = response.headers.get('X-New-Access-Token');
const newRefreshToken = response.headers.get('X-New-Refresh-Token');
if (newAccessToken) this.accessToken = newAccessToken;
if (newRefreshToken) this.refreshToken = newRefreshToken;
}
return response;
}
// Optional: Manual refresh (usually not needed due to automatic refresh)
async refreshTokens() {
const response = await fetch('/api/user/refresh-token', {
method: 'POST',
headers: {
'X-Refresh-Token': this.refreshToken
}
});
if (response.ok) {
const data = await response.json();
this.accessToken = data.accessToken;
this.refreshToken = data.refreshToken;
return true;
}
return false;
}
async logout() {
await fetch('/api/user/logout', {
method: 'POST',
headers: { 'Authorization': `Bearer ${this.accessToken}` }
});
this.accessToken = '';
this.refreshToken = '';
}
}
```
### React Hook Example
```typescript
import { useState, useCallback } from 'react';
export const useAuth = () => {
const [accessToken, setAccessToken] = useState<string>('');
const [refreshToken, setRefreshToken] = useState<string>('');
const login = useCallback(async (username: string, password: string) => {
const response = await fetch('/api/user/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await response.json();
setAccessToken(data.token);
setRefreshToken(data.refreshToken); // Always present
return data;
}, []);
const logout = useCallback(async () => {
if (accessToken) {
await fetch('/api/user/logout', {
method: 'POST',
headers: { 'Authorization': `Bearer ${accessToken}` }
});
}
setAccessToken('');
setRefreshToken('');
}, [accessToken]);
return { accessToken, refreshToken, login, logout };
};
```
## Security Considerations
1. **Token Blacklisting**: Logout tokens are blacklisted in Redis with TTL matching token expiration
2. **Short-lived Access Tokens**: 30-minute expiry reduces exposure window
3. **Secure Cookies**: httpOnly, secure, sameSite attributes for cookie-based auth
4. **Token Rotation**: Refresh tokens are rotated on each refresh
5. **Environment-specific Secrets**: Different secrets for access and refresh tokens
## Migration Guide
### From Single Token to Refresh Token System
Since this is a new implementation, all clients should expect:
1. **Login Response**: Always includes both `token` (access) and `refreshToken`
2. **Token Storage**: Store both tokens securely
3. **API Requests**: Use access token in Authorization header
4. **Automatic Refresh**: Tokens refresh automatically - just watch for response headers
5. **Logout**: Call logout endpoint to invalidate tokens
**Key Point**: Manual refresh is optional since automatic refresh handles token renewal seamlessly.
**No backward compatibility needed** - this is the only authentication method.
### Testing
```bash
# Login and get tokens
curl -X POST http://localhost:3000/api/user/login \
-H "Content-Type: application/json" \
-d '{"username": "test@example.com", "password": "password"}'
# Use access token
curl -X GET http://localhost:3000/api/user/profile \
-H "Authorization: Bearer <access_token>"
# Refresh tokens
curl -X POST http://localhost:3000/api/user/refresh-token \
-H "X-Refresh-Token: <refresh_token>"
# Logout
curl -X POST http://localhost:3000/api/user/logout \
-H "Authorization: Bearer <access_token>"
```
+20 -8
View File
@@ -10,12 +10,13 @@ import chatRouter from './routers/chatRouter';
import contactRouter from './routers/contactRouter';
import adminRouter from './routers/adminRouter';
import deckImportExportRouter from './routers/deckImportExportRouter';
<<<<<<< HEAD
import gameRouter from './routers/gameRouter';
=======
>>>>>>> origin/main
import { LoggingService, logStartup, logConnection, logError, logRequest } from '../Application/Services/Logger';
import { WebSocketService } from '../Application/Services/WebSocketService';
import { GameWebSocketService } from '../Application/Services/GameWebSocketService';
import { GameRepository } from '../Infrastructure/Repository/GameRepository';
import { UserRepository } from '../Infrastructure/Repository/UserRepository';
import { RedisService } from '../Application/Services/RedisService';
import { setupSwagger } from './swagger/swaggerUiSetup';
const app = express();
@@ -135,10 +136,7 @@ app.use('/api/chats', chatRouter);
app.use('/api/contacts', contactRouter);
app.use('/api/admin', adminRouter);
app.use('/api/deck-import-export', deckImportExportRouter);
<<<<<<< HEAD
app.use('/api/games', gameRouter);
=======
>>>>>>> origin/main
// Global error handler (must be after routes)
app.use(loggingService.errorLoggingMiddleware());
@@ -167,6 +165,7 @@ app.use((req: express.Request, res: express.Response) => {
// Initialize WebSocket service after database connection
let webSocketService: WebSocketService;
let gameWebSocketService: GameWebSocketService;
// Initialize database connection
AppDataSource.initialize()
@@ -183,6 +182,19 @@ AppDataSource.initialize()
logStartup('WebSocket service initialized', {
chatInactivityTimeout: process.env.CHAT_INACTIVITY_TIMEOUT_MINUTES || '30'
});
// Initialize Game WebSocket service for /game namespace
const gameRepository = new GameRepository();
const userRepository = new UserRepository();
const redisService = RedisService.getInstance();
gameWebSocketService = new GameWebSocketService(
webSocketService['io'], // Access the io property directly
gameRepository,
userRepository,
redisService
);
logStartup('Game WebSocket service initialized for /game namespace');
})
.catch((error) => {
const dbOptions = AppDataSource.options as any;
@@ -254,5 +266,5 @@ process.on('unhandledRejection', (reason, promise) => {
process.exit(1);
});
// Export WebSocket service for game integration
export { webSocketService };
// Export WebSocket services for game integration
export { webSocketService, gameWebSocketService };
@@ -107,41 +107,6 @@ router.get('/users/page/:from/:to', adminRequired, async (req: Request, res: Res
}
});
<<<<<<< HEAD
=======
// Get users by page (admin only) - RECOMMENDED
router.get('/users/page/:from/:to', adminRequired, async (req: Request, res: Response) => {
try {
const from = parseInt(req.params.from);
const to = parseInt(req.params.to);
const includeDeleted = req.query.includeDeleted === 'true';
if (isNaN(from) || isNaN(to) || from < 0 || to < from) {
return res.status(400).json({ error: 'Invalid page parameters. "from" and "to" must be valid numbers with to >= from >= 0' });
}
logRequest('Admin get users by page endpoint accessed', req, res, { from, to, includeDeleted });
const result = includeDeleted
? await container.userRepository.findByPageIncludingDeleted(from, to)
: await container.userRepository.findByPage(from, to);
logRequest('Admin users page retrieved successfully', req, res, {
from,
to,
count: result.users.length,
total: result.totalCount,
includeDeleted
});
res.json(result);
} catch (error) {
logError('Admin get users by page endpoint error', error as Error, req, res);
res.status(500).json({ error: 'Internal server error' });
}
});
>>>>>>> origin/main
// Get user by ID including soft-deleted ones
router.get('/users/:userId',
adminRequired,
@@ -176,7 +141,6 @@ router.get('/users/:userId',
});
// Search users including soft-deleted ones
<<<<<<< HEAD
// router.get('/users/search/:searchTerm',
// adminRequired,
// ValidationMiddleware.validateStringLength({ searchTerm: { min: 2, max: 100 } }),
@@ -203,34 +167,6 @@ router.get('/users/:userId',
// res.status(500).json({ error: 'Internal server error' });
// }
// });
=======
router.get('/users/search/:searchTerm',
adminRequired,
ValidationMiddleware.validateStringLength({ searchTerm: { min: 2, max: 100 } }),
async (req: Request, res: Response) => {
try {
const { searchTerm } = req.params;
const includeDeleted = req.query.includeDeleted === 'true';
logRequest('Admin search users endpoint accessed', req, res, { searchTerm, includeDeleted });
const users = includeDeleted
? await container.userRepository.searchIncludingDeleted(searchTerm)
: await container.userRepository.search(searchTerm);
logRequest('Admin user search completed', req, res, {
searchTerm,
resultCount: Array.isArray(users) ? users.length : (users.totalCount || 0),
includeDeleted
});
res.json(users);
} catch (error) {
logError('Admin search users endpoint error', error as Error, req, res);
res.status(500).json({ error: 'Internal server error' });
}
});
>>>>>>> origin/main
// Update any user (admin only)
router.patch('/users/:userId',
@@ -422,7 +358,6 @@ router.get('/decks/search/:searchTerm', adminRequired, async (req: Request, res:
}
});
<<<<<<< HEAD
//modify deck (admin only)
router.patch('/decks/:id', adminRequired, async (req: Request, res: Response) => {
try {
@@ -447,8 +382,6 @@ router.patch('/decks/:id', adminRequired, async (req: Request, res: Response) =>
}
});
=======
>>>>>>> origin/main
// Hard delete deck (admin only)
router.delete('/decks/:id/hard', adminRequired, async (req: Request, res: Response) => {
try {
@@ -5,9 +5,53 @@ import { ErrorResponseService } from '../../Application/Services/ErrorResponseSe
import { ValidationMiddleware } from '../../Application/Services/ValidationMiddleware';
import { GeneralSearchService } from '../../Application/Search/Generalsearch';
import { logRequest, logError, logWarning } from '../../Application/Services/Logger';
import { Type, CType } from '../../Domain/Deck/DeckAggregate';
const deckRouter = Router();
/**
* Helper function to convert string enum values to integer enum values
*/
function convertEnumValues(data: any): any {
const converted = { ...data };
// Convert Type enum
if (converted.type && typeof converted.type === 'string') {
switch (converted.type.toUpperCase()) {
case 'LUCK':
converted.type = Type.LUCK;
break;
case 'JOKER':
converted.type = Type.JOKER;
break;
case 'QUESTION':
converted.type = Type.QUESTION;
break;
default:
throw new Error('Invalid deck type. Must be LUCK, JOKER, or QUESTION');
}
}
// Convert CType enum
if (converted.ctype && typeof converted.ctype === 'string') {
switch (converted.ctype.toUpperCase()) {
case 'PUBLIC':
converted.ctype = CType.PUBLIC;
break;
case 'PRIVATE':
converted.ctype = CType.PRIVATE;
break;
case 'ORGANIZATION':
converted.ctype = CType.ORGANIZATION;
break;
default:
throw new Error('Invalid deck ctype. Must be PUBLIC, PRIVATE, or ORGANIZATION');
}
}
return converted;
}
// Create search service that isn't in the container yet
const searchService = new GeneralSearchService(container.userRepository, container.organizationRepository, container.deckRepository);
@@ -60,18 +104,25 @@ deckRouter.post('/', authRequired, async (req, res) => {
try {
const userId = (req as any).user.userId;
logRequest('Create deck endpoint accessed', req, res, { name: req.body.name, userId });
<<<<<<< HEAD
req.body.userid = userId; // Set userId in request body
=======
>>>>>>> origin/main
const result = await container.createDeckCommandHandler.execute(req.body);
// Convert string enum values to integers
const command = convertEnumValues({
...req.body,
userid: userId
});
const result = await container.createDeckCommandHandler.execute(command);
logRequest('Deck created successfully', req, res, { deckId: result.id, name: req.body.name, userId });
res.json(result);
} catch (error) {
logError('Create deck endpoint error', error as Error, req, res);
// Handle enum validation errors
if (error instanceof Error && error.message.includes('Invalid deck')) {
return res.status(400).json({ error: error.message });
}
if (error instanceof Error && (error.message.includes('duplicate') || error.message.includes('unique constraint'))) {
return res.status(409).json({ error: 'Deck with this name already exists' });
}
@@ -144,23 +195,27 @@ deckRouter.get('/:id', authRequired, async (req, res) => {
}
});
<<<<<<< HEAD
deckRouter.patch('/:id', authRequired, async (req, res) => {
=======
deckRouter.put('/:id', authRequired, async (req, res) => {
>>>>>>> origin/main
try {
const deckId = req.params.id;
const userId = (req as any).user.userId;
logRequest('Update deck endpoint accessed', req, res, { deckId, userId, updateFields: Object.keys(req.body) });
const result = await container.updateDeckCommandHandler.execute({ id: deckId, ...req.body });
// Convert string enum values to integers
const updateData = convertEnumValues(req.body);
const result = await container.updateDeckCommandHandler.execute({ id: deckId, ...updateData });
logRequest('Deck updated successfully', req, res, { deckId, userId });
res.json(result);
} catch (error) {
logError('Update deck endpoint error', error as Error, req, res);
// Handle enum validation errors
if (error instanceof Error && error.message.includes('Invalid deck')) {
return res.status(400).json({ error: error.message });
}
if (error instanceof Error && error.message.includes('not found')) {
return res.status(404).json({ error: 'Deck not found' });
}
@@ -172,13 +227,10 @@ deckRouter.put('/:id', authRequired, async (req, res) => {
if (error instanceof Error && error.message.includes('validation')) {
return res.status(400).json({ error: 'Invalid input data', details: error.message });
}
<<<<<<< HEAD
if (error instanceof Error && error.message.includes('admin')) {
return res.status(403).json({ error: 'Forbidden: ' + error.message });
}
=======
>>>>>>> origin/main
res.status(500).json({ error: 'Internal server error' });
}
@@ -206,7 +206,26 @@ gameRouter.post('/join', optionalAuth, async (req, res) => {
playerName: actualPlayerName
});
res.json(game);
// Create game token for WebSocket authentication
const gameTokenService = container.gameTokenService;
const gameToken = gameTokenService.createGameToken(
game.id,
game.gamecode,
actualPlayerName || 'Anonymous',
actualPlayerId
);
// Return clean response with essential data + game token
res.json({
id: game.id,
gamecode: game.gamecode,
playerName: actualPlayerName,
playerCount: game.players.length,
maxPlayers: game.maxplayers,
gameType: LoginType[gameToJoin.logintype],
isAuthenticated: !!actualPlayerId,
gameToken: gameToken
});
} catch (error) {
logError('Join game endpoint error', error as Error, req, res);
@@ -32,11 +32,7 @@ userRouter.post('/login',
logAuth('User login successful', result.user.id, { username: result.user.username }, req, res);
res.json(result);
} else {
<<<<<<< HEAD
throw new Error(`Login failed: ${result}`);
=======
return ErrorResponseService.sendUnauthorized(res, 'Invalid username or password');
>>>>>>> origin/main
}
} catch (error) {
@@ -52,12 +48,9 @@ userRouter.post('/login',
if (error.message.includes('not verified')) {
return ErrorResponseService.sendUnauthorized(res, 'Please verify your email address');
}
<<<<<<< HEAD
if (error.message.includes('restriction')) {
return ErrorResponseService.sendUnauthorized(res, 'Please verify your email address');
}
=======
>>>>>>> origin/main
if (error.message.includes('deactivated')) {
return ErrorResponseService.sendUnauthorized(res, 'Account has been deactivated');
}
@@ -94,12 +87,8 @@ userRouter.post('/create',
res.status(201).json(result);
} catch (error) {
<<<<<<< HEAD
// Don't log here since CreateUserCommandHandler already logs system errors
// Only log validation/user input errors at router level
=======
logError('Create user endpoint error', error as Error, req, res);
>>>>>>> origin/main
if (error instanceof Error) {
if (error.message.includes('already exists')) {
@@ -108,13 +97,10 @@ userRouter.post('/create',
if (error.message.includes('validation')) {
return ErrorResponseService.sendBadRequest(res, error.message);
}
<<<<<<< HEAD
// Log unexpected errors that weren't handled by the command handler
if (!error.message.includes('Failed to create user')) {
logError('Unexpected create user endpoint error', error as Error, req, res);
}
=======
>>>>>>> origin/main
}
return ErrorResponseService.sendInternalServerError(res);
@@ -187,7 +173,6 @@ userRouter.patch('/profile', authRequired, async (req, res) => {
}
});
<<<<<<< HEAD
//Soft delete user (current user)
userRouter.delete('/profile', authRequired, async (req, res) => {
try {
@@ -214,6 +199,32 @@ userRouter.post('/logout', authRequired, async (req, res) => {
}
});
// Refresh token endpoint
userRouter.post('/refresh-token', async (req, res) => {
try {
logRequest('Token refresh endpoint accessed', req, res);
const jwtService = container.jwtService;
const newTokenPair = jwtService.attemptTokenRefresh(req, res);
if (newTokenPair) {
logRequest('Token refresh successful', req, res);
res.json({
success: true,
message: 'Tokens refreshed successfully',
accessToken: newTokenPair.accessToken,
refreshToken: newTokenPair.refreshToken
});
} else {
logWarning('Token refresh failed - invalid or missing refresh token', undefined, req, res);
return ErrorResponseService.sendUnauthorized(res, 'Invalid or expired refresh token');
}
} catch (error) {
logError('Refresh token endpoint error', error as Error, req, res);
return ErrorResponseService.sendInternalServerError(res);
}
});
// Email verification endpoint
userRouter.get('/verify-email/:token', async (req, res) => {
try {
@@ -325,6 +336,4 @@ userRouter.post('/reset-password',
}
});
=======
>>>>>>> origin/main
export default userRouter;
@@ -1,8 +1,5 @@
import swaggerJSDoc from 'swagger-jsdoc';
<<<<<<< HEAD
import path from 'path';
=======
>>>>>>> origin/main
export const swaggerOptions = {
definition: {
@@ -22,17 +19,12 @@ export const swaggerOptions = {
},
servers: [
{
<<<<<<< HEAD
url: 'http://localhost:3001',
description: 'Local development server'
},
{
url: 'http://localhost:3000',
description: 'Local development server (alt)'
=======
url: 'http://localhost:3000',
description: 'Local development server'
>>>>>>> origin/main
},
{
url: 'https://api.serpentrace.com',
@@ -74,7 +66,6 @@ export const swaggerOptions = {
{
name: 'Deck Import/Export',
description: 'Import and export deck functionality'
<<<<<<< HEAD
},
{
name: 'Games',
@@ -99,17 +90,11 @@ export const swaggerOptions = {
{
name: 'Admin - Contacts',
description: 'Admin contact management operations'
=======
>>>>>>> origin/main
}
]
},
apis: [
<<<<<<< HEAD
'./src/Api/swagger/swaggerDefinitionsFixed.ts'
=======
'./src/Api/swagger/swaggerDefinitions.ts'
>>>>>>> origin/main
],
};
@@ -21,10 +21,6 @@ export class UserMapper {
fname: user.fname,
lname: user.lname,
code: user.token,
<<<<<<< HEAD
=======
type: user.type,
>>>>>>> origin/main
phone: user.phone,
state: user.state,
};
@@ -24,10 +24,6 @@ export interface DetailUserDto {
fname: string;
lname: string;
code: string | null;
<<<<<<< HEAD
=======
type: string;
>>>>>>> origin/main
phone: string | null;
state: number;
}
@@ -1,9 +1,6 @@
export interface UpdateDeckCommand {
id: string;
<<<<<<< HEAD
userstate?: number;
=======
>>>>>>> origin/main
name?: string;
type?: number;
userid?: string;
@@ -2,17 +2,13 @@ import { IDeckRepository } from '../../../Domain/IRepository/IDeckRepository';
import { UpdateDeckCommand } from './UpdateDeckCommand';
import { ShortDeckDto } from '../../DTOs/DeckDto';
import { DeckMapper } from '../../DTOs/Mappers/DeckMapper';
<<<<<<< HEAD
import { DeckAggregate } from '../../../Domain/Deck/DeckAggregate';
import { logError } from '../../Services/Logger';
=======
>>>>>>> origin/main
export class UpdateDeckCommandHandler {
constructor(private readonly deckRepo: IDeckRepository) {}
async execute(cmd: UpdateDeckCommand): Promise<ShortDeckDto | null> {
<<<<<<< HEAD
if(cmd.state !== undefined && cmd.userstate!==1) {
throw new Error('Only admin users can change deck state');
}
@@ -50,10 +46,5 @@ export class UpdateDeckCommandHandler {
logError(`Error updating deck: ${cmd.id}`, error);
throw error;
}
=======
const updated = await this.deckRepo.update(cmd.id, { ...cmd });
if (!updated) return null;
return DeckMapper.toShortDto(updated);
>>>>>>> origin/main
}
}
@@ -1,25 +1,14 @@
import { IDeckRepository } from '../../../Domain/IRepository/IDeckRepository';
import { GetDeckByIdQuery } from './GetDeckByIdQuery';
<<<<<<< HEAD
import { DetailDeckDto } from '../../DTOs/DeckDto';
=======
import { ShortDeckDto } from '../../DTOs/DeckDto';
>>>>>>> origin/main
import { DeckMapper } from '../../DTOs/Mappers/DeckMapper';
export class GetDeckByIdQueryHandler {
constructor(private readonly deckRepo: IDeckRepository) {}
<<<<<<< HEAD
async execute(query: GetDeckByIdQuery): Promise<DetailDeckDto | null> {
const deck = await this.deckRepo.findById(query.id);
if (!deck) return null;
return DeckMapper.toDetailDto(deck);
=======
async execute(query: GetDeckByIdQuery): Promise<ShortDeckDto | null> {
const deck = await this.deckRepo.findById(query.id);
if (!deck) return null;
return DeckMapper.toShortDto(deck);
>>>>>>> origin/main
}
}
@@ -151,6 +151,15 @@ export class JoinGameCommandHandler {
isOnline: true
};
// Check if player name is already in use by a different player
const existingPlayerWithName = gameData.currentPlayers.find(
p => p.playerName === command.playerName && p.playerId !== command.playerId
);
if (existingPlayerWithName) {
throw new Error(`Player name "${command.playerName}" is already in use in this game`);
}
// Update players list (remove if exists, then add)
gameData.currentPlayers = gameData.currentPlayers.filter(p => p.playerId !== command.playerId);
gameData.currentPlayers.push(newPlayer);
@@ -161,9 +170,6 @@ export class JoinGameCommandHandler {
// Store updated data in Redis with TTL (24 hours)
await this.redisService.setWithExpiry(redisKey, JSON.stringify(gameData), 24 * 60 * 60);
// Add player to active players set
await this.redisService.setAdd(`active_players:${game.id}`, command.playerId);
logOther('Game data updated in Redis', {
gameId: game.id,
gameCode: game.gamecode,
@@ -204,7 +210,6 @@ export class JoinGameCommandHandler {
gameData.currentPlayers = gameData.currentPlayers.filter(p => p.playerId !== playerId);
await this.redisService.setWithExpiry(redisKey, JSON.stringify(gameData), 24 * 60 * 60);
await this.redisService.setRemove(`active_players:${gameId}`, playerId);
}
} catch (error) {
logError('Failed to remove player from Redis', error instanceof Error ? error : new Error(String(error)));
@@ -64,7 +64,7 @@ export class StartGameCommandHandler {
gamecode,
maxplayers: command.maxplayers,
logintype: command.logintype,
createdby: command.userid || null,
createdby: command.userid!,
orgid: command.orgid || null,
gamedecks,
players: [],
@@ -49,7 +49,6 @@ export class GeneralSearchService implements IGeneralSearchService {
};
}
<<<<<<< HEAD
// Ensure limit is at least 1 to prevent database issues
const effectiveLimit = Math.max(limit || 20, 1);
const effectiveOffset = Math.max(offset || 0, 0);
@@ -58,12 +57,6 @@ export class GeneralSearchService implements IGeneralSearchService {
const { users, totalCount } = await this.userRepo.search(query.trim(), effectiveLimit, effectiveOffset);
const results = users.map(user => UserMapper.toShortDto(user));
const hasMore = (effectiveOffset + effectiveLimit) < totalCount;
=======
try {
const { users, totalCount } = await this.userRepo.search(query.trim(), limit, offset);
const results = users.map(user => UserMapper.toShortDto(user));
const hasMore = (offset + limit) < totalCount;
>>>>>>> origin/main
return {
results,
@@ -116,7 +109,6 @@ export class GeneralSearchService implements IGeneralSearchService {
};
}
<<<<<<< HEAD
// Ensure limit is at least 1 to prevent database issues
const effectiveLimit = Math.max(limit || 20, 1);
const effectiveOffset = Math.max(offset || 0, 0);
@@ -136,19 +128,6 @@ export class GeneralSearchService implements IGeneralSearchService {
} catch (error) {
throw new Error('Failed to search decks');
}
=======
const { decks, totalCount } = await this.deckRepo.search(query.trim(), limit, offset);
const results = decks.map(deck => DeckMapper.toShortDto(deck));
const hasMore = (offset + limit) < totalCount;
return {
results,
totalCount,
hasMore,
searchQuery: query,
searchType: 'decks'
};
>>>>>>> origin/main
}
async searchByType(
@@ -1,6 +1,5 @@
import { Request, Response, NextFunction } from 'express';
import { JWTService } from './JWTService';
<<<<<<< HEAD
import { RedisService } from './RedisService';
import { logAuth, logWarning } from './Logger';
@@ -80,7 +79,7 @@ export async function authRequired(req: Request, res: Response, next: NextFuncti
orgId: payload.orgId
}, req);
const refreshed = jwtService.refreshIfNeeded(payload, res);
const refreshed = jwtService.refreshIfNeeded(payload, res, req);
if (refreshed) {
logAuth('Token refreshed', payload.userId, undefined, req);
}
@@ -133,7 +132,7 @@ export async function adminRequired(req: Request, res: Response, next: NextFunct
orgId: payload.orgId
}, req);
const refreshed = jwtService.refreshIfNeeded(payload, res);
const refreshed = jwtService.refreshIfNeeded(payload, res, req);
if (refreshed) {
logAuth('Admin token refreshed', payload.userId, undefined, req);
}
@@ -144,60 +143,4 @@ export async function adminRequired(req: Request, res: Response, next: NextFunct
logWarning('Admin authentication middleware error', { error: (error as Error).message }, req);
return res.status(500).json({ error: 'Internal server error' });
}
=======
import { logAuth, logWarning } from './Logger';
export const jwtService = new JWTService();
export function authRequired(req: Request, res: Response, next: NextFunction) {
const payload = jwtService.verify(req);
if (!payload) {
logAuth('Authentication failed - No valid token', undefined, {
ip: req.ip,
userAgent: req.get ? req.get('User-Agent') : 'unknown',
path: req.path
}, req);
return res.status(401).json({ error: 'Unauthorized' });
}
logAuth('Authentication successful', payload.userId, {
authLevel: payload.authLevel,
orgId: payload.orgId
}, req);
const refreshed = jwtService.refreshIfNeeded(payload, res);
if (refreshed) {
logAuth('Token refreshed', payload.userId, undefined, req);
}
(req as any).user = payload;
next();
}
export function adminRequired(req: Request, res: Response, next: NextFunction) {
const payload = jwtService.verify(req);
if (!payload || payload.authLevel !== 1) {
logWarning('Admin access denied', {
hasPayload: !!payload,
authLevel: payload?.authLevel,
userId: payload?.userId,
ip: req.ip,
path: req.path
}, req);
return res.status(403).json({ error: 'Forbidden' });
}
logAuth('Admin authentication successful', payload.userId, {
authLevel: payload.authLevel,
orgId: payload.orgId
}, req);
const refreshed = jwtService.refreshIfNeeded(payload, res);
if (refreshed) {
logAuth('Admin token refreshed', payload.userId, undefined, req);
}
(req as any).user = payload;
next();
>>>>>>> origin/main
}
@@ -25,7 +25,7 @@ export class EmailTemplateHelper {
}
public static replaceTemplatePlaceholders(template: string, data: TemplateData): string {
return template.replace(/\{\{(\w+)\}\}/g, (match, key) => {
return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, key) => {
return data[key] !== undefined ? String(data[key]) : match;
});
}
@@ -13,8 +13,11 @@ import {
GameActionData,
PlayerPosition,
GameStateUpdateData,
FieldEffectRequest
FieldEffectRequest,
JoinGameData,
LeaveGameData
} from './Interfaces/GameInterfaces';
import { json } from 'stream/consumers';
interface AuthenticatedSocket extends Socket {
userId?: string;
@@ -23,14 +26,6 @@ interface AuthenticatedSocket extends Socket {
isAuthenticated?: boolean;
}
interface JoinGameData {
gameToken: string; // Required game session token
}
interface LeaveGameData {
gameCode: string;
}
interface DiceRollData {
gameCode: string;
diceValue: number; // Value from frontend (1-6)
@@ -91,7 +86,7 @@ export class GameWebSocketService {
private setupGameEventHandlers(socket: AuthenticatedSocket): void {
// Join game room
socket.on('game:join', async (data: JoinGameData) => {
socket.on('game:join', async (data: any) => {
await this.handleJoinGame(socket, data);
});
@@ -141,11 +136,14 @@ export class GameWebSocketService {
});
}
private async handleJoinGame(socket: AuthenticatedSocket, data: JoinGameData): Promise<void> {
private async handleJoinGame(socket: AuthenticatedSocket, data: any): Promise<void> {
try {
const { gameToken } = data;
// Simple data extraction - let Socket.IO handle the parsing
const jsdata = JSON.parse(data);
const gameToken = jsdata?.gameToken;
if (!gameToken) {
logError('Game join failed: No game token provided');
socket.emit('game:error', { message: 'Game token is required' });
return;
}
@@ -153,6 +151,7 @@ export class GameWebSocketService {
// Verify the game token
const gameTokenPayload = this.gameTokenService.verifyGameToken(gameToken);
if (!gameTokenPayload) {
logError('Game join failed: Invalid game token');
socket.emit('game:error', { message: 'Invalid or expired game token' });
return;
}
@@ -162,10 +161,19 @@ export class GameWebSocketService {
// Validate game still exists
const game = await this.gameRepository.findByGameCode(gameCode);
if (!game || game.id !== gameId) {
logError(`Game join failed: Game not found - Code: ${gameCode}`);
socket.emit('game:error', { message: 'Game not found or token invalid' });
return;
}
// Check if player name is already in use by checking connected players
const connectedPlayers = await this.getConnectedPlayers(gameCode);
if (connectedPlayers.includes(playerName)) {
logOther(`Game join failed: Player name "${playerName}" already in use in game ${gameCode}`);
socket.emit('game:error', { message: `Player name "${playerName}" is already in use in this game` });
return;
}
// Set socket properties from game token
socket.gameCode = gameCode;
socket.playerName = playerName;
@@ -185,8 +193,6 @@ export class GameWebSocketService {
// Add to pending players list and notify gamemaster
await this.addToPendingPlayers(gameCode, playerName);
logOther(`Player ${playerName} requesting approval to join private game: ${gameRoomName}`);
// Send pending status to the requesting player
socket.emit('game:pending-approval', {
gameCode,
@@ -210,7 +216,6 @@ export class GameWebSocketService {
await socket.join(gameRoomName);
await socket.join(playerRoomName);
logOther(`Player ${playerName} joined game room: ${gameRoomName} (${isAuthenticated ? 'authenticated' : 'public'}) ${isGamemaster ? '[GAMEMASTER]' : ''}`);
// Send success response to the joining player
socket.emit('game:joined', {
@@ -222,6 +227,7 @@ export class GameWebSocketService {
timestamp: new Date().toISOString()
});
// Notify other players in the game (broadcast)
socket.to(gameRoomName).emit('game:player-joined', {
playerName: playerName,
@@ -230,40 +236,54 @@ export class GameWebSocketService {
timestamp: new Date().toISOString()
});
// Send current game state to the joining player
const gameState = await this.getGameState(gameCode);
socket.emit('game:state', gameState);
// Update Redis with active player connection
await this.updatePlayerConnection(gameCode, playerName, true);
} catch (error) {
logError('Error joining game', error as Error);
socket.emit('game:error', { message: 'Failed to join game' });
socket.emit('game:error', {
message: 'Failed to join game',
error: error instanceof Error ? error.message : 'Unknown error'
});
}
}
private async handleLeaveGame(socket: AuthenticatedSocket, data: LeaveGameData): Promise<void> {
try {
const { gameCode } = data;
const { gameCode } = JSON.parse(data as any);
const playerName = socket.playerName;
// Validate we have the required data
if (!playerName) {
logError('Cannot leave game: socket has no playerName');
socket.emit('game:error', { message: 'Player has no name' });
return;
}
const gameRoomName = `game_${gameCode}`;
const playerRoomName = `game_${gameCode}:${socket.playerName}`;
const playerRoomName = `game_${gameCode}:${playerName}`;
// Leave both rooms
await socket.leave(gameRoomName);
await socket.leave(playerRoomName);
logOther(`Player ${socket.playerName} left game room: ${gameRoomName}`);
logOther(`Player ${playerName} left game room: ${gameRoomName}`);
// Notify other players
socket.to(gameRoomName).emit('game:player-left', {
playerName: socket.playerName,
playerName: playerName,
timestamp: new Date().toISOString()
});
// Update Redis
await this.updatePlayerConnection(gameCode, socket.playerName!, false);
// Update Redis before clearing socket properties
await this.updatePlayerConnection(gameCode, playerName, false);
// Clear socket properties
socket.gameCode = undefined;
socket.playerName = undefined;
@@ -275,7 +295,7 @@ export class GameWebSocketService {
private async handleGameAction(socket: AuthenticatedSocket, data: GameActionData): Promise<void> {
try {
const { gameCode, action, data: actionData } = data;
const { gameCode, action, data: actionData } = JSON.parse(data as any);
if (!socket.gameCode || socket.gameCode !== gameCode) {
socket.emit('game:error', { message: 'You must be in the game to perform actions' });
@@ -317,7 +337,7 @@ export class GameWebSocketService {
private async handleGameChat(socket: AuthenticatedSocket, data: GameChatData): Promise<void> {
try {
const { gameCode, message } = data;
const { gameCode, message } = JSON.parse(data as any);
if (!socket.gameCode || socket.gameCode !== gameCode) {
socket.emit('game:error', { message: 'You must be in the game to chat' });
@@ -343,7 +363,7 @@ export class GameWebSocketService {
private async handlePlayerReady(socket: AuthenticatedSocket, data: { gameCode: string; ready: boolean }): Promise<void> {
try {
const { gameCode, ready } = data;
const { gameCode, ready } = JSON.parse(data as any);
const gameRoomName = `game_${gameCode}`;
// Update player ready status in Redis
@@ -373,7 +393,7 @@ export class GameWebSocketService {
private async handleApprovePlayer(socket: AuthenticatedSocket, data: { gameCode: string; playerName: string }): Promise<void> {
try {
const { gameCode, playerName } = data;
const { gameCode, playerName } = JSON.parse(data as any);
// Verify that the requesting socket is the gamemaster
const game = await this.gameRepository.findByGameCode(gameCode);
@@ -434,7 +454,7 @@ export class GameWebSocketService {
private async handleRejectPlayer(socket: AuthenticatedSocket, data: { gameCode: string; playerName: string; reason?: string }): Promise<void> {
try {
const { gameCode, playerName, reason } = data;
const { gameCode, playerName, reason } = JSON.parse(data as any);
// Verify that the requesting socket is the gamemaster
const game = await this.gameRepository.findByGameCode(gameCode);
@@ -482,7 +502,7 @@ export class GameWebSocketService {
private async handleJoinApproved(socket: AuthenticatedSocket, data: JoinGameData): Promise<void> {
try {
const { gameToken } = data;
const { gameToken } = JSON.parse(data as any);
if (!gameToken) {
socket.emit('game:error', { message: 'Game token is required' });
@@ -560,7 +580,7 @@ export class GameWebSocketService {
private async handleDiceRoll(socket: AuthenticatedSocket, data: DiceRollData): Promise<void> {
try {
const { gameCode, diceValue } = data;
const { gameCode, diceValue } = JSON.parse(data as any);
// Validate input
if (!gameCode || !socket.gameCode || socket.gameCode !== gameCode) {
@@ -772,14 +792,6 @@ export class GameWebSocketService {
// Remove from pending players if they were pending
await this.redisService.setRemove(`game_pending:${gameCode}`, playerName);
// If we have player ID, also clean up ID-based tracking
if (playerId) {
const game = await this.gameRepository.findByGameCode(gameCode);
if (game?.id) {
await this.redisService.setRemove(`active_players:${game.id}`, playerId);
}
}
logOther(`Cleaned up player data for ${playerName} in game ${gameCode}`);
} catch (error) {
@@ -1276,7 +1288,6 @@ export class GameWebSocketService {
if (gameId) {
const gameIdKeys = [
`game:${gameId}`, // Main game data
`active_players:${gameId}`, // Active players set
`game_turns:${gameId}` // Turn data by ID
];
@@ -7,60 +7,204 @@ export interface TokenPayload {
authLevel: 0 | 1;
userStatus: UserState;
orgId: string;
type?: 'access';
iat?: number;
exp?: number;
}
export interface RefreshTokenPayload {
userId: string;
type: 'refresh';
orgId?: string;
tokenId?: string; // For token rotation/revocation
iat?: number;
exp?: number;
}
export interface TokenPair {
accessToken: string;
refreshToken: string;
}
export class JWTService {
private readonly secretKey: string;
private readonly refreshSecretKey: string;
private readonly tokenExpiry: number;
private readonly refreshTokenExpiry: number;
private readonly cookieName: string;
private readonly refreshCookieName: string;
constructor() {
this.secretKey = process.env.JWT_SECRET || 'your-secret-key';
this.refreshSecretKey = process.env.JWT_REFRESH_SECRET || this.secretKey + '_refresh';
let expiry = 86400;
// Access token expiry (short-lived)
let expiry = 1800; // Default 30 minutes for better security
if (process.env.JWT_EXPIRY) {
expiry = parseInt(process.env.JWT_EXPIRY);
} else if (process.env.JWT_EXPIRATION) {
expiry = this.parseDuration(process.env.JWT_EXPIRATION);
}
// Refresh token expiry (long-lived)
let refreshExpiry = 604800; // Default 7 days
if (process.env.JWT_REFRESH_EXPIRATION) {
refreshExpiry = this.parseDuration(process.env.JWT_REFRESH_EXPIRATION);
}
this.tokenExpiry = expiry;
this.refreshTokenExpiry = refreshExpiry;
this.cookieName = 'auth_token';
this.refreshCookieName = 'refresh_token';
if (process.env.NODE_ENV === 'production' && (!process.env.JWT_SECRET || process.env.JWT_SECRET === 'your-secret-key')) {
throw new Error('JWT_SECRET environment variable must be set in production');
}
}
create(payload: TokenPayload, res: Response): string {
/**
* Create a pair of access and refresh tokens
*/
public createTokenPair(payload: Omit<TokenPayload, 'type' | 'iat' | 'exp'>): TokenPair {
const now = Math.floor(Date.now() / 1000);
const payloadWithTimestamps: TokenPayload = {
// Create access token
const accessTokenPayload: TokenPayload = {
...payload,
type: 'access',
iat: now,
exp: now + this.tokenExpiry
};
const accessToken = jwt.sign(accessTokenPayload, this.secretKey);
// Don't use expiresIn option since we're manually setting exp in payload
const options: SignOptions = {};
const token = jwt.sign(payloadWithTimestamps, this.secretKey, options);
// Create refresh token
const refreshTokenPayload: RefreshTokenPayload = {
userId: payload.userId,
type: 'refresh',
orgId: payload.orgId,
iat: now,
exp: now + this.refreshTokenExpiry
};
const refreshToken = jwt.sign(refreshTokenPayload, this.refreshSecretKey);
res.cookie(this.cookieName, token, {
return { accessToken, refreshToken };
}
/**
* Create access and refresh tokens and set cookies (for cookie-based auth)
*/
create(payload: Omit<TokenPayload, 'type' | 'iat' | 'exp'>, res: Response): TokenPair {
const tokenPair = this.createTokenPair(payload);
this.setTokenCookies(res, tokenPair);
return tokenPair;
}
/**
* Check if the request is using Bearer token authentication
*/
private isUsingBearerAuth(req: Request): boolean {
// No cookie but has Authorization header
return !req.cookies?.[this.cookieName] &&
!!req.headers.authorization &&
req.headers.authorization.startsWith('Bearer ');
}
/**
* Verify a refresh token
*/
public verifyRefreshToken(token: string): RefreshTokenPayload | null {
try {
const decoded = jwt.verify(token, this.refreshSecretKey) as RefreshTokenPayload;
if (decoded.type !== 'refresh') {
return null;
}
return decoded;
} catch (error) {
return null;
}
}
/**
* Attempt to refresh tokens using refresh token from cookies or headers
*/
public attemptTokenRefresh(req: Request, res: Response): TokenPair | null {
try {
// Try to get refresh token from cookie first
let refreshToken = req.cookies[this.refreshCookieName];
// If no cookie, try X-Refresh-Token header
if (!refreshToken) {
refreshToken = req.headers['x-refresh-token'] as string;
}
if (!refreshToken) {
return null;
}
const refreshPayload = this.verifyRefreshToken(refreshToken);
if (!refreshPayload) {
return null;
}
// Create new token pair
const newTokenPair = this.createTokenPair({
userId: refreshPayload.userId,
authLevel: 0, // Default auth level, should be fetched from user data
userStatus: UserState.VERIFIED_REGULAR, // Default status, should be fetched from user data
orgId: refreshPayload.orgId || ''
});
// Set new tokens based on authentication method
if (req.cookies[this.cookieName] || req.cookies[this.refreshCookieName]) {
// Cookie-based auth: set new cookies
this.setTokenCookies(res, newTokenPair);
} else {
// Header-based auth: send tokens in response headers
res.setHeader('X-New-Access-Token', newTokenPair.accessToken);
res.setHeader('X-New-Refresh-Token', newTokenPair.refreshToken);
res.setHeader('X-Token-Refreshed', 'true');
}
return newTokenPair;
} catch (error) {
return null;
}
}
/**
* Set token cookies for cookie-based authentication
*/
private setTokenCookies(res: Response, tokenPair: TokenPair): void {
// Set access token cookie
res.cookie(this.cookieName, tokenPair.accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: this.tokenExpiry * 1000, // Convert to milliseconds
maxAge: this.tokenExpiry * 1000,
});
return token;
// Set refresh token cookie
res.cookie(this.refreshCookieName, tokenPair.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: this.refreshTokenExpiry * 1000,
});
}
verify(req: Request): TokenPayload | null {
try {
const token = req.cookies[this.cookieName];
// First try to get token from cookie
let token = req.cookies[this.cookieName];
// If no cookie token, try Authorization header
if (!token) {
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
token = authHeader.substring(7);
}
}
if (!token) return null;
const decoded = jwt.verify(token, this.secretKey) as TokenPayload;
@@ -70,6 +214,32 @@ export class JWTService {
}
}
/**
* Logout user by clearing tokens
*/
public logout(req: Request, res: Response): void {
// Clear cookies if they exist
if (req.cookies[this.cookieName]) {
res.clearCookie(this.cookieName, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict'
});
}
if (req.cookies[this.refreshCookieName]) {
res.clearCookie(this.refreshCookieName, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict'
});
}
// For bearer token auth, set headers to indicate logout
res.setHeader('X-Auth-Logout', 'true');
res.setHeader('X-Clear-Tokens', 'true');
}
// Check if token needs refresh (within 25% of expiry time)
shouldRefreshToken(payload: TokenPayload): boolean {
if (!payload.exp || !payload.iat) return false;
@@ -83,16 +253,39 @@ export class JWTService {
}
// Conditionally refresh token only if needed
refreshIfNeeded(payload: TokenPayload, res: Response): boolean {
refreshIfNeeded(payload: TokenPayload, res: Response, req?: Request): boolean {
if (this.shouldRefreshToken(payload)) {
// Create new token with fresh timestamps, but same user data
const freshPayload: Omit<TokenPayload, 'iat' | 'exp'> = {
if (req) {
// Try to use the new refresh token system
const newTokenPair = this.attemptTokenRefresh(req, res);
if (newTokenPair) {
return true;
}
}
// Fallback: create new token pair
const freshPayload: Omit<TokenPayload, 'iat' | 'exp' | 'type'> = {
userId: payload.userId,
authLevel: payload.authLevel,
userStatus: payload.userStatus,
orgId: payload.orgId
};
this.create(freshPayload, res);
// Check if using Bearer authentication
if (req && this.isUsingBearerAuth(req)) {
// For Bearer auth, create token pair and add to headers
const newTokenPair = this.createTokenPair(freshPayload);
res.setHeader('X-New-Access-Token', newTokenPair.accessToken);
res.setHeader('X-New-Refresh-Token', newTokenPair.refreshToken);
res.setHeader('X-Token-Refreshed', 'true');
} else {
// For cookie auth, create token pair and set cookies
const newTokenPair = this.create(freshPayload, res);
res.setHeader('X-New-Access-Token', newTokenPair.accessToken);
res.setHeader('X-New-Refresh-Token', newTokenPair.refreshToken);
res.setHeader('X-Token-Refreshed', 'true');
}
return true;
}
return false;
@@ -12,6 +12,7 @@ import { Response } from 'express';
export interface LoginResponse {
user: ShortUserDto;
token: string;
refreshToken: string;
requiresOrgReauth?: boolean;
orgLoginUrl?: string;
organizationName?: string;
@@ -111,7 +112,23 @@ export class LoginCommandHandler {
try {
// Use the real response object if provided, otherwise use mock
const responseObj = res || mockRes;
const token = this.jwtService.create(tokenPayload, responseObj);
// Check if client prefers Bearer token authentication
const prefersBearerAuth = res && (
res.req?.headers['authorization'] !== undefined ||
res.req?.headers['x-auth-method'] === 'bearer' ||
res.req?.headers['accept']?.includes('application/json')
);
let tokenPair: any;
if (prefersBearerAuth && res) {
// Create token pair for Bearer authentication (no cookies)
tokenPair = this.jwtService.createTokenPair(tokenPayload);
} else {
// Cookie-based authentication (sets cookies automatically)
tokenPair = this.jwtService.create(tokenPayload, responseObj);
}
// Check if user belongs to an organization and needs reauthentication
let requiresOrgReauth = false;
@@ -154,7 +171,8 @@ export class LoginCommandHandler {
const response: LoginResponse = {
user: UserMapper.toShortDto(user),
token
token: tokenPair.accessToken,
refreshToken: tokenPair.refreshToken
};
if (requiresOrgReauth) {
@@ -17,45 +17,63 @@ export class LogoutCommandHandler {
try {
logAuth('Logout process started', userId);
// 1. Get token from request to blacklist it
let tokenToBlacklist: string | null = null;
// 1. Get tokens from request to blacklist them
let accessTokenToBlacklist: string | null = null;
let refreshTokenToBlacklist: string | null = null;
if (req) {
// Extract token from cookie
tokenToBlacklist = req.cookies['auth_token'];
// Also check Authorization header as fallback
if (!tokenToBlacklist && req.headers.authorization) {
// Extract access token from cookie or Authorization header
accessTokenToBlacklist = req.cookies['auth_token'];
if (!accessTokenToBlacklist && req.headers.authorization) {
const authHeader = req.headers.authorization;
if (authHeader.startsWith('Bearer ')) {
tokenToBlacklist = authHeader.substring(7);
accessTokenToBlacklist = authHeader.substring(7);
}
}
// Extract refresh token from cookie or header
refreshTokenToBlacklist = req.cookies['refresh_token'];
if (!refreshTokenToBlacklist) {
refreshTokenToBlacklist = req.headers['x-refresh-token'] as string;
}
}
// 2. Blacklist the current JWT token in Redis (if available)
if (tokenToBlacklist && req) {
// 2. Blacklist both access and refresh tokens in Redis
if (accessTokenToBlacklist && req) {
try {
// Store token in blacklist with expiration matching token expiry
const decoded = this.jwtService.verify(req);
if (decoded && decoded.exp) {
const ttl = decoded.exp - Math.floor(Date.now() / 1000);
if (ttl > 0) {
await this.redisService.setWithExpiry(`blacklist:${tokenToBlacklist}`, 'true', ttl);
logAuth('JWT token blacklisted', userId, { tokenExpiry: ttl });
await this.redisService.setWithExpiry(`blacklist:${accessTokenToBlacklist}`, 'true', ttl);
logAuth('Access token blacklisted', userId, { tokenExpiry: ttl });
}
}
} catch (error) {
logWarning('Failed to blacklist token', { userId, error: (error as Error).message });
logWarning('Failed to blacklist access token', { userId, error: (error as Error).message });
}
}
// 3. Clear authentication cookie
res.clearCookie('auth_token', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/'
});
// Blacklist refresh token if present
if (refreshTokenToBlacklist) {
try {
const refreshDecoded = this.jwtService.verifyRefreshToken(refreshTokenToBlacklist);
if (refreshDecoded && refreshDecoded.exp) {
const ttl = refreshDecoded.exp - Math.floor(Date.now() / 1000);
if (ttl > 0) {
await this.redisService.setWithExpiry(`blacklist:${refreshTokenToBlacklist}`, 'true', ttl);
logAuth('Refresh token blacklisted', userId, { tokenExpiry: ttl });
}
}
} catch (error) {
logWarning('Failed to blacklist refresh token', { userId, error: (error as Error).message });
}
}
// 3. Use JWT service to clear cookies and set logout headers
if (req) {
this.jwtService.logout(req, res);
}
// 4. Remove user from active sessions in Redis
try {
@@ -50,16 +50,19 @@ export class GameAggregate {
@Column({ type: 'int', default: LoginType.PUBLIC })
logintype!: LoginType;
@Column({ type: 'varchar', length: 255, nullable: true })
createdby!: string | null;
@Column({ type: 'int', default: 50 })
boardsize!: number;
@Column({ type: 'varchar', length: 255, nullable: true })
@Column({ type: 'uuid', nullable: false, name: 'createdBy' })
createdby!: string;
@Column({ type: 'uuid', nullable: true, name: 'organizationid' })
orgid!: string | null;
@Column({ type: 'json' })
@Column({ type: 'jsonb', default: () => "'[]'", name: 'decks' })
gamedecks!: GameDeck[];
@Column({ type: 'json', default: () => "'[]'" })
@Column({ type: 'uuid', array: true, default: () => "'{}'", name: 'playerids' })
players!: string[];
@Column({ type: 'boolean', default: false })
@@ -68,22 +71,22 @@ export class GameAggregate {
@Column({ type: 'boolean', default: false })
finished!: boolean;
@Column({ type: 'varchar', length: 255, nullable: true })
@Column({ type: 'uuid', nullable: true, name: 'winnerid' })
winner!: string | null;
@Column({ type: 'int', default: GameState.WAITING })
state!: GameState;
@CreateDateColumn({ name: 'create_date' })
@CreateDateColumn({ name: 'createDate' })
createdate!: Date;
@Column({ type: 'timestamp', nullable: true, name: 'start_date' })
startdate!: Date | null;
@Column({ type: 'timestamp', nullable: true, name: 'end_date' })
@Column({ type: 'timestamp', nullable: true, name: 'finishDate' })
enddate!: Date | null;
@UpdateDateColumn({ name: 'update_date' })
@UpdateDateColumn({ name: 'updateDate' })
updatedate!: Date;
}
@@ -1,22 +0,0 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class Test1755691733404 implements MigrationInterface {
name = 'Test1755691733404'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "Users" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "orgid" uuid, "username" character varying(100) NOT NULL, "password" character varying(255) NOT NULL, "email" character varying(255) NOT NULL, "fname" character varying(100) NOT NULL, "lname" character varying(100) NOT NULL, "code" character varying(50), "type" character varying(50) NOT NULL, "phone" character varying(20), "state" integer NOT NULL DEFAULT '0', "regdate" TIMESTAMP NOT NULL DEFAULT now(), "updatedate" TIMESTAMP NOT NULL DEFAULT now(), "Orglogindate" TIMESTAMP, CONSTRAINT "UQ_ffc81a3b97dcbf8e320d5106c0d" UNIQUE ("username"), CONSTRAINT "UQ_3c3ab3f49a87e6ddb607f3c4945" UNIQUE ("email"), CONSTRAINT "PK_16d4f7d636df336db11d87413e3" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE TABLE "Organizations" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying(255) NOT NULL, "contactfname" character varying(100) NOT NULL, "contactlname" character varying(100) NOT NULL, "contactphone" character varying(20) NOT NULL, "contactemail" character varying(255) NOT NULL, "state" integer NOT NULL DEFAULT '0', "regdate" TIMESTAMP NOT NULL DEFAULT now(), "updatedate" TIMESTAMP NOT NULL DEFAULT now(), "url" character varying(500), "userinorg" integer NOT NULL DEFAULT '0', CONSTRAINT "PK_e0690a31419f6666194423526f2" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE TABLE "Decks" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying(255) NOT NULL, "type" integer NOT NULL, "user_id" uuid NOT NULL, "creation_date" TIMESTAMP NOT NULL DEFAULT now(), "cards" json NOT NULL, "played_number" integer NOT NULL DEFAULT '0', "ctype" integer NOT NULL DEFAULT '0', "update_date" TIMESTAMP NOT NULL DEFAULT now(), "state" integer NOT NULL DEFAULT '0', "organization_id" uuid, CONSTRAINT "PK_001f26cb3ec39c1f25269943473" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE TABLE "Chats" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "users" uuid array NOT NULL, "messages" json NOT NULL, "updateDate" TIMESTAMP NOT NULL DEFAULT now(), "state" integer NOT NULL DEFAULT '0', CONSTRAINT "PK_64c36c2b8d86a0d5de4cf64de8d" PRIMARY KEY ("id"))`);
await queryRunner.query(`ALTER TABLE "Decks" ADD CONSTRAINT "FK_06ee28f90d68543a03b14aebe13" FOREIGN KEY ("organization_id") REFERENCES "Organizations"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "Decks" DROP CONSTRAINT "FK_06ee28f90d68543a03b14aebe13"`);
await queryRunner.query(`DROP TABLE "Chats"`);
await queryRunner.query(`DROP TABLE "Decks"`);
await queryRunner.query(`DROP TABLE "Organizations"`);
await queryRunner.query(`DROP TABLE "Users"`);
}
}
@@ -1,18 +0,0 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddEmailVerificationFields1755706019351 implements MigrationInterface {
name = 'AddEmailVerificationFields1755706019351'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "Users" DROP COLUMN "code"`);
await queryRunner.query(`ALTER TABLE "Users" ADD "token" character varying(255)`);
await queryRunner.query(`ALTER TABLE "Users" ADD "TokenExpires" TIMESTAMP`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "Users" DROP COLUMN "TokenExpires"`);
await queryRunner.query(`ALTER TABLE "Users" DROP COLUMN "token"`);
await queryRunner.query(`ALTER TABLE "Users" ADD "code" character varying(50)`);
}
}
@@ -1,30 +0,0 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddChatMessagingSystem1755817306222 implements MigrationInterface {
name = 'AddChatMessagingSystem1755817306222'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "ChatArchives" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "chatId" uuid NOT NULL, "archivedMessages" json NOT NULL, "archivedAt" TIMESTAMP NOT NULL, "createDate" TIMESTAMP NOT NULL DEFAULT now(), "chatType" character varying(50) NOT NULL, "chatName" character varying(255), "gameId" uuid, "participants" uuid array NOT NULL, CONSTRAINT "PK_fe62979fc2061d7afe278d3f14e" PRIMARY KEY ("id"))`);
await queryRunner.query(`ALTER TABLE "Chats" ADD "type" character varying(50) NOT NULL DEFAULT 'direct'`);
await queryRunner.query(`ALTER TABLE "Chats" ADD "name" character varying(255)`);
await queryRunner.query(`ALTER TABLE "Chats" ADD "gameId" uuid`);
await queryRunner.query(`ALTER TABLE "Chats" ADD "createdBy" uuid`);
await queryRunner.query(`ALTER TABLE "Chats" ADD "lastActivity" TIMESTAMP`);
await queryRunner.query(`ALTER TABLE "Chats" ADD "createDate" TIMESTAMP NOT NULL DEFAULT now()`);
await queryRunner.query(`ALTER TABLE "Chats" ADD "archiveDate" TIMESTAMP`);
await queryRunner.query(`ALTER TABLE "Chats" ALTER COLUMN "messages" SET DEFAULT '[]'`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "Chats" ALTER COLUMN "messages" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE "Chats" DROP COLUMN "archiveDate"`);
await queryRunner.query(`ALTER TABLE "Chats" DROP COLUMN "createDate"`);
await queryRunner.query(`ALTER TABLE "Chats" DROP COLUMN "lastActivity"`);
await queryRunner.query(`ALTER TABLE "Chats" DROP COLUMN "createdBy"`);
await queryRunner.query(`ALTER TABLE "Chats" DROP COLUMN "gameId"`);
await queryRunner.query(`ALTER TABLE "Chats" DROP COLUMN "name"`);
await queryRunner.query(`ALTER TABLE "Chats" DROP COLUMN "type"`);
await queryRunner.query(`DROP TABLE "ChatArchives"`);
}
}
@@ -1,14 +0,0 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class CreateContactTable1755855028839 implements MigrationInterface {
name = 'CreateContactTable1755855028839'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "Contacts" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying(255) NOT NULL, "email" character varying(255) NOT NULL, "userid" uuid, "type" integer NOT NULL, "txt" text NOT NULL, "state" integer NOT NULL DEFAULT '0', "createDate" TIMESTAMP NOT NULL DEFAULT now(), "updateDate" TIMESTAMP NOT NULL DEFAULT now(), "adminResponse" text, "responseDate" TIMESTAMP, "respondedBy" uuid, CONSTRAINT "PK_68782cec65c8eef577c62958273" PRIMARY KEY ("id"))`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "Contacts"`);
}
}
@@ -1,28 +0,0 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class Full1757939815984 implements MigrationInterface {
name = 'Full1757939815984'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "Chats" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "type" character varying(50) NOT NULL DEFAULT 'direct', "name" character varying(255), "gameId" uuid, "createdBy" uuid, "users" uuid array NOT NULL, "messages" json NOT NULL DEFAULT '[]', "lastActivity" TIMESTAMP, "createDate" TIMESTAMP NOT NULL DEFAULT now(), "updateDate" TIMESTAMP NOT NULL DEFAULT now(), "state" integer NOT NULL DEFAULT '0', "archiveDate" TIMESTAMP, CONSTRAINT "PK_64c36c2b8d86a0d5de4cf64de8d" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE TABLE "Users" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "orgid" uuid, "username" character varying(100) NOT NULL, "password" character varying(255) NOT NULL, "email" character varying(255) NOT NULL, "fname" character varying(100) NOT NULL, "lname" character varying(100) NOT NULL, "token" character varying(255), "TokenExpires" TIMESTAMP, "phone" character varying(20), "state" integer NOT NULL DEFAULT '0', "regdate" TIMESTAMP NOT NULL DEFAULT now(), "updatedate" TIMESTAMP NOT NULL DEFAULT now(), "Orglogindate" TIMESTAMP, CONSTRAINT "UQ_ffc81a3b97dcbf8e320d5106c0d" UNIQUE ("username"), CONSTRAINT "UQ_3c3ab3f49a87e6ddb607f3c4945" UNIQUE ("email"), CONSTRAINT "PK_16d4f7d636df336db11d87413e3" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE TABLE "Contacts" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying(255) NOT NULL, "email" character varying(255) NOT NULL, "userid" uuid, "type" integer NOT NULL, "txt" text NOT NULL, "state" integer NOT NULL DEFAULT '0', "createDate" TIMESTAMP NOT NULL DEFAULT now(), "updateDate" TIMESTAMP NOT NULL DEFAULT now(), "adminResponse" text, "responseDate" TIMESTAMP, "respondedBy" uuid, CONSTRAINT "PK_68782cec65c8eef577c62958273" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE TABLE "ChatArchives" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "chatId" uuid NOT NULL, "archivedMessages" json NOT NULL, "archivedAt" TIMESTAMP NOT NULL, "createDate" TIMESTAMP NOT NULL DEFAULT now(), "chatType" character varying(50) NOT NULL, "chatName" character varying(255), "gameId" uuid, "participants" uuid array NOT NULL, CONSTRAINT "PK_fe62979fc2061d7afe278d3f14e" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE TABLE "Games" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "gamecode" character varying(10) NOT NULL, "maxplayers" integer NOT NULL, "logintype" integer NOT NULL DEFAULT '0', "createdby" character varying(255), "orgid" character varying(255), "gamedecks" json NOT NULL, "players" json NOT NULL DEFAULT '[]', "started" boolean NOT NULL DEFAULT false, "finished" boolean NOT NULL DEFAULT false, "winner" character varying(255), "state" integer NOT NULL DEFAULT '0', "create_date" TIMESTAMP NOT NULL DEFAULT now(), "start_date" TIMESTAMP, "end_date" TIMESTAMP, "update_date" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_9d52c646079cbe6f242a85c5c41" UNIQUE ("gamecode"), CONSTRAINT "PK_1950492f583d31609c5e9fbbe12" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE TABLE "Organizations" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying(255) NOT NULL, "contactfname" character varying(100) NOT NULL, "contactlname" character varying(100) NOT NULL, "contactphone" character varying(20) NOT NULL, "contactemail" character varying(255) NOT NULL, "state" integer NOT NULL DEFAULT '0', "regdate" TIMESTAMP NOT NULL DEFAULT now(), "updatedate" TIMESTAMP NOT NULL DEFAULT now(), "url" character varying(500), "userinorg" integer NOT NULL DEFAULT '0', "maxOrganizationalDecks" integer, CONSTRAINT "PK_e0690a31419f6666194423526f2" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE TABLE "Decks" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying(255) NOT NULL, "type" integer NOT NULL, "user_id" uuid NOT NULL, "creation_date" TIMESTAMP NOT NULL DEFAULT now(), "cards" json NOT NULL, "played_number" integer NOT NULL DEFAULT '0', "ctype" integer NOT NULL DEFAULT '0', "update_date" TIMESTAMP NOT NULL DEFAULT now(), "state" integer NOT NULL DEFAULT '0', "organization_id" uuid, CONSTRAINT "PK_001f26cb3ec39c1f25269943473" PRIMARY KEY ("id"))`);
await queryRunner.query(`ALTER TABLE "Decks" ADD CONSTRAINT "FK_06ee28f90d68543a03b14aebe13" FOREIGN KEY ("organization_id") REFERENCES "Organizations"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "Decks" DROP CONSTRAINT "FK_06ee28f90d68543a03b14aebe13"`);
await queryRunner.query(`DROP TABLE "Decks"`);
await queryRunner.query(`DROP TABLE "Organizations"`);
await queryRunner.query(`DROP TABLE "Games"`);
await queryRunner.query(`DROP TABLE "ChatArchives"`);
await queryRunner.query(`DROP TABLE "Contacts"`);
await queryRunner.query(`DROP TABLE "Users"`);
await queryRunner.query(`DROP TABLE "Chats"`);
}
}
@@ -0,0 +1,30 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class Full1758463929834 implements MigrationInterface {
name = 'Full1758463929834'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "Games" DROP COLUMN "winner"`);
await queryRunner.query(`ALTER TABLE "Games" DROP COLUMN "create_date"`);
await queryRunner.query(`ALTER TABLE "Games" DROP COLUMN "end_date"`);
await queryRunner.query(`ALTER TABLE "Games" DROP COLUMN "update_date"`);
await queryRunner.query(`ALTER TABLE "Games" ADD "boardsize" integer NOT NULL DEFAULT '50'`);
await queryRunner.query(`ALTER TABLE "Games" ADD "winnerid" uuid`);
await queryRunner.query(`ALTER TABLE "Games" ADD "createDate" TIMESTAMP NOT NULL DEFAULT now()`);
await queryRunner.query(`ALTER TABLE "Games" ADD "finishDate" TIMESTAMP`);
await queryRunner.query(`ALTER TABLE "Games" ADD "updateDate" TIMESTAMP NOT NULL DEFAULT now()`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "Games" DROP COLUMN "updateDate"`);
await queryRunner.query(`ALTER TABLE "Games" DROP COLUMN "finishDate"`);
await queryRunner.query(`ALTER TABLE "Games" DROP COLUMN "createDate"`);
await queryRunner.query(`ALTER TABLE "Games" DROP COLUMN "winnerid"`);
await queryRunner.query(`ALTER TABLE "Games" DROP COLUMN "boardsize"`);
await queryRunner.query(`ALTER TABLE "Games" ADD "update_date" TIMESTAMP NOT NULL DEFAULT now()`);
await queryRunner.query(`ALTER TABLE "Games" ADD "end_date" TIMESTAMP`);
await queryRunner.query(`ALTER TABLE "Games" ADD "create_date" TIMESTAMP NOT NULL DEFAULT now()`);
await queryRunner.query(`ALTER TABLE "Games" ADD "winner" character varying(255)`);
}
}
@@ -1,28 +0,0 @@
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
export class AddMaxOrganizationalDecksToOrganization1692712800000 implements MigrationInterface {
name = 'AddMaxOrganizationalDecksToOrganization1692712800000';
public async up(queryRunner: QueryRunner): Promise<void> {
// Add maxOrganizationalDecks column to Organizations table
await queryRunner.addColumn('Organizations', new TableColumn({
name: 'maxOrganizationalDecks',
type: 'int',
isNullable: true, // No default - set by admin
comment: 'Maximum number of organizational decks a premium user can create in this organization'
}));
// Add performance indexes for deck filtering queries
await queryRunner.query(`CREATE INDEX "IDX_DECK_USER_STATE_CTYPE" ON "Decks" ("user_id", "state", "ctype")`);
await queryRunner.query(`CREATE INDEX "IDX_DECK_ORG_CTYPE_STATE" ON "Decks" ("organization_id", "ctype", "state")`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Drop indexes
await queryRunner.query(`DROP INDEX "IDX_DECK_ORG_CTYPE_STATE"`);
await queryRunner.query(`DROP INDEX "IDX_DECK_USER_STATE_CTYPE"`);
// Remove maxOrganizationalDecks column
await queryRunner.dropColumn('Organizations', 'maxOrganizationalDecks');
}
}
@@ -1,11 +0,0 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddEmailVerificationFields1755706017175 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
}
public async down(queryRunner: QueryRunner): Promise<void> {
}
}
@@ -1,11 +0,0 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class FixEmailVerificationFields1755706055220 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
}
public async down(queryRunner: QueryRunner): Promise<void> {
}
}
@@ -1,11 +0,0 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class Full1757939815062 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
}
public async down(queryRunner: QueryRunner): Promise<void> {
}
}
@@ -1,6 +1,6 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class Test1755691732089 implements MigrationInterface {
export class Full1758463928499 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
}
+50 -13
View File
@@ -1,17 +1,54 @@
# Development Environment Variables
POSTGRES_PASSWORD=postgres
JWT_SECRET=dev_jwt_secret_change_in_production_please_use_a_long_random_string
JWT_EXPIRATION=24h
JWT_REFRESH_EXPIRATION=7d
# ==============================================
# SerpentRace Backend Environment Configuration
# ==============================================
# Copy this file to .env and fill in your values
# APPLICATION CONFIGURATION
NODE_ENV=development
PORT=3000
APP_BASE_URL=http://localhost:3000
# DATABASE CONFIGURATION (PostgreSQL)
DB_HOST=postgres
DB_PORT=5432
DB_NAME=serpentrace
DB_USERNAME=postgres
DB_PASSWORD=postgres
# REDIS CONFIGURATION
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_URL=redis://redis:6379
# MINIO CONFIGURATION
MINIO_ENDPOINT=minio
MINIO_PORT=9000
MINIO_ACCESS_KEY=serpentrace
MINIO_SECRET_KEY=serpentrace123!
MINIO_USE_SSL=false
MINIO_BUCKET_NAME=serpentrace-logs
# Optional: Email configuration for development
EMAIL_HOST=
EMAIL_PORT=
EMAIL_USER=
EMAIL_PASS=
EMAIL_FROM=
# JWT CONFIGURATION
JWT_SECRET=your_super_secret_jwt_key_change_in_production
JWT_EXPIRY=86400
JWT_EXPIRATION=24h
JWT_REFRESH_EXPIRATION=7d
GAME_TOKEN_EXPIRY=86400
# Optional: Other development settings
NODE_ENV=development
# EMAIL SERVICE CONFIGURATION
EMAIL_HOST=mail.serpentrace.hu
EMAIL_PORT=465
EMAIL_SECURE=true
EMAIL_USER=noreply@serpentrace.hu
EMAIL_PASS=ZUx720ece&Cin&F{
EMAIL_FROM=noreply@serpentrace.com
# CHAT SYSTEM CONFIGURATION
CHAT_INACTIVITY_TIMEOUT_MINUTES=30
CHAT_MAX_MESSAGES_PER_USER=100
CHAT_MESSAGE_CLEANUP_WEEKS=4
# GAME CONFIGURATION
MAX_SPECIAL_FIELDS_PERCENTAGE=67
MAX_GENERATION_TIME_SECONDS=20
GENERATION_ERROR_TOLERANCE=15
+1 -1
View File
@@ -60,7 +60,7 @@ services:
- "5173:5173"
environment:
- NODE_ENV=development
- VITE_API_URL=http://localhost:3000
- API_URL=http://localhost:3000
volumes:
- ../SerpentRace_Frontend:/app
- /app/node_modules
+2 -3
View File
@@ -6,6 +6,8 @@ services:
dockerfile: ../SerpentRace_Docker/Dockerfile_backend.dev
container_name: serpentrace-backend-dev
restart: unless-stopped
env_file:
- .env.dev
ports:
- "3000:3000"
environment:
@@ -19,9 +21,6 @@ services:
- REDIS_URL=redis://redis:6379
- REDIS_HOST=redis
- REDIS_PORT=6379
- JWT_SECRET=dev_jwt_secret_change_in_production
- JWT_EXPIRATION=24h
- JWT_REFRESH_EXPIRATION=7d
- MINIO_ENDPOINT=minio
- MINIO_PORT=9000
- MINIO_ACCESS_KEY=serpentrace
-55
View File
@@ -1,55 +0,0 @@
{
"name": "SerpentRace",
"lockfileVersion": 3,
"requires": true,
"packages": {
"node_modules/framer-motion": {
"version": "12.23.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.0.tgz",
"integrity": "sha512-xf6NxTGAyf7zR4r2KlnhFmsRfKIbjqeBupEDBAaEtVIBJX96sAon00kMlsKButSIRwPSHjbRrAPnYdJJ9kyhbA==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.22.0",
"motion-utils": "^12.19.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/motion-dom": {
"version": "12.22.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.22.0.tgz",
"integrity": "sha512-ooH7+/BPw9gOsL9VtPhEJHE2m4ltnhMlcGMhEqA0YGNhKof7jdaszvsyThXI6LVIKshJUZ9/CP6HNqQhJfV7kw==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.19.0"
}
},
"node_modules/motion-utils": {
"version": "12.19.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.19.0.tgz",
"integrity": "sha512-BuFTHINYmV07pdWs6lj6aI63vr2N4dg0vR+td0rtrdpWOhBzIkEklZyLcvKBoEtwSqx8Jg06vUB5RS0xDiUybw==",
"license": "MIT"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
}
}
}
-21
View File
@@ -1,21 +0,0 @@
The MIT License (MIT)
Copyright (c) 2018 Framer B.V.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-137
View File
@@ -1,137 +0,0 @@
<p align="center">
<img width="42" height="42" alt="Motion logo" src="https://github.com/user-attachments/assets/00d6d1c3-72c4-4c2f-a664-69da13182ffc" />
</p>
<h1 align="center">Motion for React</h1>
<h3 align="center">
An open source, production-ready animation library
</h3>
<p align="center">Previously Framer Motion. Also available for <a href="https://github.com/motiondivision/motion/tree/main/packages/motion">JavaScript</a> and <a href="https://github.com/motiondivision/motion-vue">Vue</a></p>
<br>
<p align="center">
<a href="https://www.npmjs.com/package/framer-motion" target="_blank">
<img src="https://img.shields.io/npm/v/framer-motion.svg?style=flat-square" />
</a>
<a href="https://www.npmjs.com/package/framer-motion" target="_blank">
<img src="https://img.shields.io/npm/dm/framer-motion.svg?style=flat-square" />
</a>
<a href="https://twitter.com/mattgperry" target="_blank">
<img src="https://img.shields.io/twitter/follow/mattgperry.svg?style=social&label=Follow" />
</a>
</p>
<br>
Motion for React is an open source, production-ready library thats designed for all creative developers.
It's the only animation library with a hybrid engine, combining the power of JavaScript animations with the performance of native browser APIs.
It looks like this:
```jsx
<motion.div animate={{ x: 0 }} />
```
It does all this:
- [Springs](https://motion.dev/docs/react-transitions#spring)
- [Keyframes](https://motion.dev/docs/react-animation#keyframes)
- [Layout animations](https://motion.dev/docs/react-layout-animations)
- [Shared layout animations](https://motion.dev/docs/react-layout-animations#shared-layout-animations)
- [Gestures (drag/tap/hover)](https://motion.dev/docs/react-gestures)
- [Scroll animations](https://motion.dev/docs/react-scroll-animations)
- [SVG paths](https://motion.dev/docs/react-animation#svg-line-drawing)
- [Exit animations](https://motion.dev/docs/react-animation#exit-animations)
- [Server-side rendering](https://motion.dev/docs/react-motion-component#server-side-rendering)
- [Independent transforms](https://motion.dev/docs/react-motion-component#style)
- [Orchestrate animations across components](https://motion.dev/docs/react-animation#orchestration)
- [CSS variables](https://motion.dev/docs/react-animation#css-variables)
...and a whole lot more.
## Get started
### 🐇 Quick start
```bash
npm install motion
```
```jsx
import { motion } from "motion/react"
function Component() {
return <motion.div animate={{ x: 100 }} />
}
```
Get started with [Motion for React](https://motion.dev/docs/react-quick-start).
## 🎨 Studio
![Video of bezier curve editing](https://framerusercontent.com/images/KO5dnHOUSNGb9S73p1J7nLhoFI.gif)
Motion Studio is a versatile suite of developer tools allowing you to:
- Visually edit CSS and Motion easing curves in VS Code
- Generate CSS springs with LLMs
- Load Motion docs into your LLM
Get started with [Motion Studio](https://motion.dev/docs/tools-quick-start).
## 🎓 Examples
[Motion Examples](https://examples.motion.dev/react) offers 100s of free and Motion+ examples for beginners and advanced users alike. Easy copy/paste code to kickstart your next project.
## ⚡️ Motion+
[Motion+](https://motion.dev/plus) is a one-time fee, lifetime membership that unlocks over 100 premium examples, early access, powerful Studio tools, a private Discord, and exclusive APIs like:
- Cursor
- Ticker
- AnimateNumber
- splitText
[Get Motion+](https://motion.dev/plus)
### 💎 Contribute
- Want to contribute to Motion? Our [contributing guide](https://github.com/motiondivision/motion/blob/master/CONTRIBUTING.md) has you covered.
### 👩🏻‍⚖️ License
- Motion for React is MIT licensed.
## ✨ Sponsors
Motion is sustainable thanks to the kind support of its sponsors.
### Partners
#### Framer
Motion powers Framer animations, the web builder for creative pros. Design and ship your dream site. Zero code, maximum speed.
<a href="https://www.framer.com?utm_source=motion-readme">
<img alt="Framer" src="https://github.com/user-attachments/assets/0404c7a1-c29d-4785-89ae-aae315f3c759" width="300px" height="200px">
</a>
### Platinum
<a href="https://tailwindcss.com"><img alt="Tailwind" src="https://github.com/user-attachments/assets/c0496f09-b8ee-4bc4-85ab-83a071bbbdec" width="300px" height="200px"></a> <a href="https://emilkowal.ski"><img alt="Emil Kowalski" src="https://github.com/user-attachments/assets/29f56b1a-37fb-4695-a6a6-151f6c24864f" width="300px" height="200px"></a> <a href="https://linear.app"><img alt="Linear" src="https://github.com/user-attachments/assets/a93710bb-d8ed-40e3-b0fb-1c5b3e2b16bb" width="300px" height="200px"></a>
### Gold
<a href="https://vercel.com"><img alt="Vercel" src="https://github.com/user-attachments/assets/23cb1e37-fa67-49ad-8f77-7f4b8eaba325" width="225px" height="150px"></a> <a href="https://liveblocks.io"><img alt="Liveblocks" src="https://github.com/user-attachments/assets/31436a47-951e-4eab-9a68-bdd54ccf9444" width="225px" height="150px"></a> <a href="https://lu.ma"><img alt="Luma" src="https://github.com/user-attachments/assets/4fae0c9d-de0f-4042-9cd6-e07885d028a9" width="225px" height="150px"></a>
### Silver
<a href="https://www.frontend.fyi/?utm_source=motion"><img alt="Frontend.fyi" src="https://github.com/user-attachments/assets/07d23aa5-69db-44a0-849d-90177e6fc817" width="150px" height="100px"></a> <a href="https://firecrawl.dev"><img alt="Firecrawl" src="https://github.com/user-attachments/assets/cba90e54-1329-4353-8fba-85beef4d2ee9" width="150px" height="100px"></a> <a href="https://puzzmo.com"><img alt="Puzzmo" src="https://github.com/user-attachments/assets/aa2d5586-e5e2-43b9-8446-db456e4b0758" width="150px" height="100px"></a> <a href="https://buildui.com"><img alt="Build UI" src="https://github.com/user-attachments/assets/024bfcd5-50e8-4b3d-a115-d5c6d6030d1c" width="150px" height="100px"></a>
### Personal
- [OlegWock](https://sinja.io)
- [Lambert Weller](https://github.com/l-mbert)
- [Jake LeBoeuf](https://jklb.wf)
- [Han Lee](https://github.com/hahnlee)
-1
View File
@@ -1 +0,0 @@
This directory is a fallback for `exports["./client"]` in the root `framer-motion` `package.json`.
-6
View File
@@ -1,6 +0,0 @@
{
"private": true,
"types": "../dist/types/client.d.ts",
"main": "../dist/cjs/client.js",
"module": "../dist/es/client.mjs"
}
-365
View File
@@ -1,365 +0,0 @@
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
var create = require('./create-C-c1JfhA.js');
require('motion-dom');
require('motion-utils');
require('react/jsx-runtime');
require('react');
/**
* HTML components
*/
const MotionA = /*@__PURE__*/ create.createMotionComponent("a");
const MotionAbbr = /*@__PURE__*/ create.createMotionComponent("abbr");
const MotionAddress = /*@__PURE__*/ create.createMotionComponent("address");
const MotionArea = /*@__PURE__*/ create.createMotionComponent("area");
const MotionArticle = /*@__PURE__*/ create.createMotionComponent("article");
const MotionAside = /*@__PURE__*/ create.createMotionComponent("aside");
const MotionAudio = /*@__PURE__*/ create.createMotionComponent("audio");
const MotionB = /*@__PURE__*/ create.createMotionComponent("b");
const MotionBase = /*@__PURE__*/ create.createMotionComponent("base");
const MotionBdi = /*@__PURE__*/ create.createMotionComponent("bdi");
const MotionBdo = /*@__PURE__*/ create.createMotionComponent("bdo");
const MotionBig = /*@__PURE__*/ create.createMotionComponent("big");
const MotionBlockquote =
/*@__PURE__*/ create.createMotionComponent("blockquote");
const MotionBody = /*@__PURE__*/ create.createMotionComponent("body");
const MotionButton = /*@__PURE__*/ create.createMotionComponent("button");
const MotionCanvas = /*@__PURE__*/ create.createMotionComponent("canvas");
const MotionCaption = /*@__PURE__*/ create.createMotionComponent("caption");
const MotionCite = /*@__PURE__*/ create.createMotionComponent("cite");
const MotionCode = /*@__PURE__*/ create.createMotionComponent("code");
const MotionCol = /*@__PURE__*/ create.createMotionComponent("col");
const MotionColgroup = /*@__PURE__*/ create.createMotionComponent("colgroup");
const MotionData = /*@__PURE__*/ create.createMotionComponent("data");
const MotionDatalist = /*@__PURE__*/ create.createMotionComponent("datalist");
const MotionDd = /*@__PURE__*/ create.createMotionComponent("dd");
const MotionDel = /*@__PURE__*/ create.createMotionComponent("del");
const MotionDetails = /*@__PURE__*/ create.createMotionComponent("details");
const MotionDfn = /*@__PURE__*/ create.createMotionComponent("dfn");
const MotionDialog = /*@__PURE__*/ create.createMotionComponent("dialog");
const MotionDiv = /*@__PURE__*/ create.createMotionComponent("div");
const MotionDl = /*@__PURE__*/ create.createMotionComponent("dl");
const MotionDt = /*@__PURE__*/ create.createMotionComponent("dt");
const MotionEm = /*@__PURE__*/ create.createMotionComponent("em");
const MotionEmbed = /*@__PURE__*/ create.createMotionComponent("embed");
const MotionFieldset = /*@__PURE__*/ create.createMotionComponent("fieldset");
const MotionFigcaption =
/*@__PURE__*/ create.createMotionComponent("figcaption");
const MotionFigure = /*@__PURE__*/ create.createMotionComponent("figure");
const MotionFooter = /*@__PURE__*/ create.createMotionComponent("footer");
const MotionForm = /*@__PURE__*/ create.createMotionComponent("form");
const MotionH1 = /*@__PURE__*/ create.createMotionComponent("h1");
const MotionH2 = /*@__PURE__*/ create.createMotionComponent("h2");
const MotionH3 = /*@__PURE__*/ create.createMotionComponent("h3");
const MotionH4 = /*@__PURE__*/ create.createMotionComponent("h4");
const MotionH5 = /*@__PURE__*/ create.createMotionComponent("h5");
const MotionH6 = /*@__PURE__*/ create.createMotionComponent("h6");
const MotionHead = /*@__PURE__*/ create.createMotionComponent("head");
const MotionHeader = /*@__PURE__*/ create.createMotionComponent("header");
const MotionHgroup = /*@__PURE__*/ create.createMotionComponent("hgroup");
const MotionHr = /*@__PURE__*/ create.createMotionComponent("hr");
const MotionHtml = /*@__PURE__*/ create.createMotionComponent("html");
const MotionI = /*@__PURE__*/ create.createMotionComponent("i");
const MotionIframe = /*@__PURE__*/ create.createMotionComponent("iframe");
const MotionImg = /*@__PURE__*/ create.createMotionComponent("img");
const MotionInput = /*@__PURE__*/ create.createMotionComponent("input");
const MotionIns = /*@__PURE__*/ create.createMotionComponent("ins");
const MotionKbd = /*@__PURE__*/ create.createMotionComponent("kbd");
const MotionKeygen = /*@__PURE__*/ create.createMotionComponent("keygen");
const MotionLabel = /*@__PURE__*/ create.createMotionComponent("label");
const MotionLegend = /*@__PURE__*/ create.createMotionComponent("legend");
const MotionLi = /*@__PURE__*/ create.createMotionComponent("li");
const MotionLink = /*@__PURE__*/ create.createMotionComponent("link");
const MotionMain = /*@__PURE__*/ create.createMotionComponent("main");
const MotionMap = /*@__PURE__*/ create.createMotionComponent("map");
const MotionMark = /*@__PURE__*/ create.createMotionComponent("mark");
const MotionMenu = /*@__PURE__*/ create.createMotionComponent("menu");
const MotionMenuitem = /*@__PURE__*/ create.createMotionComponent("menuitem");
const MotionMeter = /*@__PURE__*/ create.createMotionComponent("meter");
const MotionNav = /*@__PURE__*/ create.createMotionComponent("nav");
const MotionObject = /*@__PURE__*/ create.createMotionComponent("object");
const MotionOl = /*@__PURE__*/ create.createMotionComponent("ol");
const MotionOptgroup = /*@__PURE__*/ create.createMotionComponent("optgroup");
const MotionOption = /*@__PURE__*/ create.createMotionComponent("option");
const MotionOutput = /*@__PURE__*/ create.createMotionComponent("output");
const MotionP = /*@__PURE__*/ create.createMotionComponent("p");
const MotionParam = /*@__PURE__*/ create.createMotionComponent("param");
const MotionPicture = /*@__PURE__*/ create.createMotionComponent("picture");
const MotionPre = /*@__PURE__*/ create.createMotionComponent("pre");
const MotionProgress = /*@__PURE__*/ create.createMotionComponent("progress");
const MotionQ = /*@__PURE__*/ create.createMotionComponent("q");
const MotionRp = /*@__PURE__*/ create.createMotionComponent("rp");
const MotionRt = /*@__PURE__*/ create.createMotionComponent("rt");
const MotionRuby = /*@__PURE__*/ create.createMotionComponent("ruby");
const MotionS = /*@__PURE__*/ create.createMotionComponent("s");
const MotionSamp = /*@__PURE__*/ create.createMotionComponent("samp");
const MotionScript = /*@__PURE__*/ create.createMotionComponent("script");
const MotionSection = /*@__PURE__*/ create.createMotionComponent("section");
const MotionSelect = /*@__PURE__*/ create.createMotionComponent("select");
const MotionSmall = /*@__PURE__*/ create.createMotionComponent("small");
const MotionSource = /*@__PURE__*/ create.createMotionComponent("source");
const MotionSpan = /*@__PURE__*/ create.createMotionComponent("span");
const MotionStrong = /*@__PURE__*/ create.createMotionComponent("strong");
const MotionStyle = /*@__PURE__*/ create.createMotionComponent("style");
const MotionSub = /*@__PURE__*/ create.createMotionComponent("sub");
const MotionSummary = /*@__PURE__*/ create.createMotionComponent("summary");
const MotionSup = /*@__PURE__*/ create.createMotionComponent("sup");
const MotionTable = /*@__PURE__*/ create.createMotionComponent("table");
const MotionTbody = /*@__PURE__*/ create.createMotionComponent("tbody");
const MotionTd = /*@__PURE__*/ create.createMotionComponent("td");
const MotionTextarea = /*@__PURE__*/ create.createMotionComponent("textarea");
const MotionTfoot = /*@__PURE__*/ create.createMotionComponent("tfoot");
const MotionTh = /*@__PURE__*/ create.createMotionComponent("th");
const MotionThead = /*@__PURE__*/ create.createMotionComponent("thead");
const MotionTime = /*@__PURE__*/ create.createMotionComponent("time");
const MotionTitle = /*@__PURE__*/ create.createMotionComponent("title");
const MotionTr = /*@__PURE__*/ create.createMotionComponent("tr");
const MotionTrack = /*@__PURE__*/ create.createMotionComponent("track");
const MotionU = /*@__PURE__*/ create.createMotionComponent("u");
const MotionUl = /*@__PURE__*/ create.createMotionComponent("ul");
const MotionVideo = /*@__PURE__*/ create.createMotionComponent("video");
const MotionWbr = /*@__PURE__*/ create.createMotionComponent("wbr");
const MotionWebview = /*@__PURE__*/ create.createMotionComponent("webview");
/**
* SVG components
*/
const MotionAnimate = /*@__PURE__*/ create.createMotionComponent("animate");
const MotionCircle = /*@__PURE__*/ create.createMotionComponent("circle");
const MotionDefs = /*@__PURE__*/ create.createMotionComponent("defs");
const MotionDesc = /*@__PURE__*/ create.createMotionComponent("desc");
const MotionEllipse = /*@__PURE__*/ create.createMotionComponent("ellipse");
const MotionG = /*@__PURE__*/ create.createMotionComponent("g");
const MotionImage = /*@__PURE__*/ create.createMotionComponent("image");
const MotionLine = /*@__PURE__*/ create.createMotionComponent("line");
const MotionFilter = /*@__PURE__*/ create.createMotionComponent("filter");
const MotionMarker = /*@__PURE__*/ create.createMotionComponent("marker");
const MotionMask = /*@__PURE__*/ create.createMotionComponent("mask");
const MotionMetadata = /*@__PURE__*/ create.createMotionComponent("metadata");
const MotionPath = /*@__PURE__*/ create.createMotionComponent("path");
const MotionPattern = /*@__PURE__*/ create.createMotionComponent("pattern");
const MotionPolygon = /*@__PURE__*/ create.createMotionComponent("polygon");
const MotionPolyline = /*@__PURE__*/ create.createMotionComponent("polyline");
const MotionRect = /*@__PURE__*/ create.createMotionComponent("rect");
const MotionStop = /*@__PURE__*/ create.createMotionComponent("stop");
const MotionSvg = /*@__PURE__*/ create.createMotionComponent("svg");
const MotionSymbol = /*@__PURE__*/ create.createMotionComponent("symbol");
const MotionText = /*@__PURE__*/ create.createMotionComponent("text");
const MotionTspan = /*@__PURE__*/ create.createMotionComponent("tspan");
const MotionUse = /*@__PURE__*/ create.createMotionComponent("use");
const MotionView = /*@__PURE__*/ create.createMotionComponent("view");
const MotionClipPath = /*@__PURE__*/ create.createMotionComponent("clipPath");
const MotionFeBlend = /*@__PURE__*/ create.createMotionComponent("feBlend");
const MotionFeColorMatrix =
/*@__PURE__*/ create.createMotionComponent("feColorMatrix");
const MotionFeComponentTransfer = /*@__PURE__*/ create.createMotionComponent("feComponentTransfer");
const MotionFeComposite =
/*@__PURE__*/ create.createMotionComponent("feComposite");
const MotionFeConvolveMatrix =
/*@__PURE__*/ create.createMotionComponent("feConvolveMatrix");
const MotionFeDiffuseLighting =
/*@__PURE__*/ create.createMotionComponent("feDiffuseLighting");
const MotionFeDisplacementMap =
/*@__PURE__*/ create.createMotionComponent("feDisplacementMap");
const MotionFeDistantLight =
/*@__PURE__*/ create.createMotionComponent("feDistantLight");
const MotionFeDropShadow =
/*@__PURE__*/ create.createMotionComponent("feDropShadow");
const MotionFeFlood = /*@__PURE__*/ create.createMotionComponent("feFlood");
const MotionFeFuncA = /*@__PURE__*/ create.createMotionComponent("feFuncA");
const MotionFeFuncB = /*@__PURE__*/ create.createMotionComponent("feFuncB");
const MotionFeFuncG = /*@__PURE__*/ create.createMotionComponent("feFuncG");
const MotionFeFuncR = /*@__PURE__*/ create.createMotionComponent("feFuncR");
const MotionFeGaussianBlur =
/*@__PURE__*/ create.createMotionComponent("feGaussianBlur");
const MotionFeImage = /*@__PURE__*/ create.createMotionComponent("feImage");
const MotionFeMerge = /*@__PURE__*/ create.createMotionComponent("feMerge");
const MotionFeMergeNode =
/*@__PURE__*/ create.createMotionComponent("feMergeNode");
const MotionFeMorphology =
/*@__PURE__*/ create.createMotionComponent("feMorphology");
const MotionFeOffset = /*@__PURE__*/ create.createMotionComponent("feOffset");
const MotionFePointLight =
/*@__PURE__*/ create.createMotionComponent("fePointLight");
const MotionFeSpecularLighting =
/*@__PURE__*/ create.createMotionComponent("feSpecularLighting");
const MotionFeSpotLight =
/*@__PURE__*/ create.createMotionComponent("feSpotLight");
const MotionFeTile = /*@__PURE__*/ create.createMotionComponent("feTile");
const MotionFeTurbulence =
/*@__PURE__*/ create.createMotionComponent("feTurbulence");
const MotionForeignObject =
/*@__PURE__*/ create.createMotionComponent("foreignObject");
const MotionLinearGradient =
/*@__PURE__*/ create.createMotionComponent("linearGradient");
const MotionRadialGradient =
/*@__PURE__*/ create.createMotionComponent("radialGradient");
const MotionTextPath = /*@__PURE__*/ create.createMotionComponent("textPath");
exports.create = create.createMotionComponent;
exports.a = MotionA;
exports.abbr = MotionAbbr;
exports.address = MotionAddress;
exports.animate = MotionAnimate;
exports.area = MotionArea;
exports.article = MotionArticle;
exports.aside = MotionAside;
exports.audio = MotionAudio;
exports.b = MotionB;
exports.base = MotionBase;
exports.bdi = MotionBdi;
exports.bdo = MotionBdo;
exports.big = MotionBig;
exports.blockquote = MotionBlockquote;
exports.body = MotionBody;
exports.button = MotionButton;
exports.canvas = MotionCanvas;
exports.caption = MotionCaption;
exports.circle = MotionCircle;
exports.cite = MotionCite;
exports.clipPath = MotionClipPath;
exports.code = MotionCode;
exports.col = MotionCol;
exports.colgroup = MotionColgroup;
exports.data = MotionData;
exports.datalist = MotionDatalist;
exports.dd = MotionDd;
exports.defs = MotionDefs;
exports.del = MotionDel;
exports.desc = MotionDesc;
exports.details = MotionDetails;
exports.dfn = MotionDfn;
exports.dialog = MotionDialog;
exports.div = MotionDiv;
exports.dl = MotionDl;
exports.dt = MotionDt;
exports.ellipse = MotionEllipse;
exports.em = MotionEm;
exports.embed = MotionEmbed;
exports.feBlend = MotionFeBlend;
exports.feColorMatrix = MotionFeColorMatrix;
exports.feComponentTransfer = MotionFeComponentTransfer;
exports.feComposite = MotionFeComposite;
exports.feConvolveMatrix = MotionFeConvolveMatrix;
exports.feDiffuseLighting = MotionFeDiffuseLighting;
exports.feDisplacementMap = MotionFeDisplacementMap;
exports.feDistantLight = MotionFeDistantLight;
exports.feDropShadow = MotionFeDropShadow;
exports.feFlood = MotionFeFlood;
exports.feFuncA = MotionFeFuncA;
exports.feFuncB = MotionFeFuncB;
exports.feFuncG = MotionFeFuncG;
exports.feFuncR = MotionFeFuncR;
exports.feGaussianBlur = MotionFeGaussianBlur;
exports.feImage = MotionFeImage;
exports.feMerge = MotionFeMerge;
exports.feMergeNode = MotionFeMergeNode;
exports.feMorphology = MotionFeMorphology;
exports.feOffset = MotionFeOffset;
exports.fePointLight = MotionFePointLight;
exports.feSpecularLighting = MotionFeSpecularLighting;
exports.feSpotLight = MotionFeSpotLight;
exports.feTile = MotionFeTile;
exports.feTurbulence = MotionFeTurbulence;
exports.fieldset = MotionFieldset;
exports.figcaption = MotionFigcaption;
exports.figure = MotionFigure;
exports.filter = MotionFilter;
exports.footer = MotionFooter;
exports.foreignObject = MotionForeignObject;
exports.form = MotionForm;
exports.g = MotionG;
exports.h1 = MotionH1;
exports.h2 = MotionH2;
exports.h3 = MotionH3;
exports.h4 = MotionH4;
exports.h5 = MotionH5;
exports.h6 = MotionH6;
exports.head = MotionHead;
exports.header = MotionHeader;
exports.hgroup = MotionHgroup;
exports.hr = MotionHr;
exports.html = MotionHtml;
exports.i = MotionI;
exports.iframe = MotionIframe;
exports.image = MotionImage;
exports.img = MotionImg;
exports.input = MotionInput;
exports.ins = MotionIns;
exports.kbd = MotionKbd;
exports.keygen = MotionKeygen;
exports.label = MotionLabel;
exports.legend = MotionLegend;
exports.li = MotionLi;
exports.line = MotionLine;
exports.linearGradient = MotionLinearGradient;
exports.link = MotionLink;
exports.main = MotionMain;
exports.map = MotionMap;
exports.mark = MotionMark;
exports.marker = MotionMarker;
exports.mask = MotionMask;
exports.menu = MotionMenu;
exports.menuitem = MotionMenuitem;
exports.metadata = MotionMetadata;
exports.meter = MotionMeter;
exports.nav = MotionNav;
exports.object = MotionObject;
exports.ol = MotionOl;
exports.optgroup = MotionOptgroup;
exports.option = MotionOption;
exports.output = MotionOutput;
exports.p = MotionP;
exports.param = MotionParam;
exports.path = MotionPath;
exports.pattern = MotionPattern;
exports.picture = MotionPicture;
exports.polygon = MotionPolygon;
exports.polyline = MotionPolyline;
exports.pre = MotionPre;
exports.progress = MotionProgress;
exports.q = MotionQ;
exports.radialGradient = MotionRadialGradient;
exports.rect = MotionRect;
exports.rp = MotionRp;
exports.rt = MotionRt;
exports.ruby = MotionRuby;
exports.s = MotionS;
exports.samp = MotionSamp;
exports.script = MotionScript;
exports.section = MotionSection;
exports.select = MotionSelect;
exports.small = MotionSmall;
exports.source = MotionSource;
exports.span = MotionSpan;
exports.stop = MotionStop;
exports.strong = MotionStrong;
exports.style = MotionStyle;
exports.sub = MotionSub;
exports.summary = MotionSummary;
exports.sup = MotionSup;
exports.svg = MotionSvg;
exports.symbol = MotionSymbol;
exports.table = MotionTable;
exports.tbody = MotionTbody;
exports.td = MotionTd;
exports.text = MotionText;
exports.textPath = MotionTextPath;
exports.textarea = MotionTextarea;
exports.tfoot = MotionTfoot;
exports.th = MotionTh;
exports.thead = MotionThead;
exports.time = MotionTime;
exports.title = MotionTitle;
exports.tr = MotionTr;
exports.track = MotionTrack;
exports.tspan = MotionTspan;
exports.u = MotionU;
exports.ul = MotionUl;
exports.use = MotionUse;
exports.video = MotionVideo;
exports.view = MotionView;
exports.wbr = MotionWbr;
exports.webview = MotionWebview;
File diff suppressed because it is too large Load Diff
-12
View File
@@ -1,12 +0,0 @@
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
var motionDom = require('motion-dom');
Object.defineProperty(exports, "recordStats", {
enumerable: true,
get: function () { return motionDom.recordStats; }
});
-461
View File
@@ -1,461 +0,0 @@
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
var motionDom = require('motion-dom');
var motionUtils = require('motion-utils');
function isDOMKeyframes(keyframes) {
return typeof keyframes === "object" && !Array.isArray(keyframes);
}
function resolveSubjects(subject, keyframes, scope, selectorCache) {
if (typeof subject === "string" && isDOMKeyframes(keyframes)) {
return motionDom.resolveElements(subject, scope, selectorCache);
}
else if (subject instanceof NodeList) {
return Array.from(subject);
}
else if (Array.isArray(subject)) {
return subject;
}
else {
return [subject];
}
}
function calculateRepeatDuration(duration, repeat, _repeatDelay) {
return duration * (repeat + 1);
}
/**
* Given a absolute or relative time definition and current/prev time state of the sequence,
* calculate an absolute time for the next keyframes.
*/
function calcNextTime(current, next, prev, labels) {
if (typeof next === "number") {
return next;
}
else if (next.startsWith("-") || next.startsWith("+")) {
return Math.max(0, current + parseFloat(next));
}
else if (next === "<") {
return prev;
}
else if (next.startsWith("<")) {
return Math.max(0, prev + parseFloat(next.slice(1)));
}
else {
return labels.get(next) ?? current;
}
}
function eraseKeyframes(sequence, startTime, endTime) {
for (let i = 0; i < sequence.length; i++) {
const keyframe = sequence[i];
if (keyframe.at > startTime && keyframe.at < endTime) {
motionUtils.removeItem(sequence, keyframe);
// If we remove this item we have to push the pointer back one
i--;
}
}
}
function addKeyframes(sequence, keyframes, easing, offset, startTime, endTime) {
/**
* Erase every existing value between currentTime and targetTime,
* this will essentially splice this timeline into any currently
* defined ones.
*/
eraseKeyframes(sequence, startTime, endTime);
for (let i = 0; i < keyframes.length; i++) {
sequence.push({
value: keyframes[i],
at: motionDom.mixNumber(startTime, endTime, offset[i]),
easing: motionUtils.getEasingForSegment(easing, i),
});
}
}
/**
* Take an array of times that represent repeated keyframes. For instance
* if we have original times of [0, 0.5, 1] then our repeated times will
* be [0, 0.5, 1, 1, 1.5, 2]. Loop over the times and scale them back
* down to a 0-1 scale.
*/
function normalizeTimes(times, repeat) {
for (let i = 0; i < times.length; i++) {
times[i] = times[i] / (repeat + 1);
}
}
function compareByTime(a, b) {
if (a.at === b.at) {
if (a.value === null)
return 1;
if (b.value === null)
return -1;
return 0;
}
else {
return a.at - b.at;
}
}
const defaultSegmentEasing = "easeInOut";
const MAX_REPEAT = 20;
function createAnimationsFromSequence(sequence, { defaultTransition = {}, ...sequenceTransition } = {}, scope, generators) {
const defaultDuration = defaultTransition.duration || 0.3;
const animationDefinitions = new Map();
const sequences = new Map();
const elementCache = {};
const timeLabels = new Map();
let prevTime = 0;
let currentTime = 0;
let totalDuration = 0;
/**
* Build the timeline by mapping over the sequence array and converting
* the definitions into keyframes and offsets with absolute time values.
* These will later get converted into relative offsets in a second pass.
*/
for (let i = 0; i < sequence.length; i++) {
const segment = sequence[i];
/**
* If this is a timeline label, mark it and skip the rest of this iteration.
*/
if (typeof segment === "string") {
timeLabels.set(segment, currentTime);
continue;
}
else if (!Array.isArray(segment)) {
timeLabels.set(segment.name, calcNextTime(currentTime, segment.at, prevTime, timeLabels));
continue;
}
let [subject, keyframes, transition = {}] = segment;
/**
* If a relative or absolute time value has been specified we need to resolve
* it in relation to the currentTime.
*/
if (transition.at !== undefined) {
currentTime = calcNextTime(currentTime, transition.at, prevTime, timeLabels);
}
/**
* Keep track of the maximum duration in this definition. This will be
* applied to currentTime once the definition has been parsed.
*/
let maxDuration = 0;
const resolveValueSequence = (valueKeyframes, valueTransition, valueSequence, elementIndex = 0, numSubjects = 0) => {
const valueKeyframesAsList = keyframesAsList(valueKeyframes);
const { delay = 0, times = motionDom.defaultOffset(valueKeyframesAsList), type = "keyframes", repeat, repeatType, repeatDelay = 0, ...remainingTransition } = valueTransition;
let { ease = defaultTransition.ease || "easeOut", duration } = valueTransition;
/**
* Resolve stagger() if defined.
*/
const calculatedDelay = typeof delay === "function"
? delay(elementIndex, numSubjects)
: delay;
/**
* If this animation should and can use a spring, generate a spring easing function.
*/
const numKeyframes = valueKeyframesAsList.length;
const createGenerator = motionDom.isGenerator(type)
? type
: generators?.[type || "keyframes"];
if (numKeyframes <= 2 && createGenerator) {
/**
* As we're creating an easing function from a spring,
* ideally we want to generate it using the real distance
* between the two keyframes. However this isn't always
* possible - in these situations we use 0-100.
*/
let absoluteDelta = 100;
if (numKeyframes === 2 &&
isNumberKeyframesArray(valueKeyframesAsList)) {
const delta = valueKeyframesAsList[1] - valueKeyframesAsList[0];
absoluteDelta = Math.abs(delta);
}
const springTransition = { ...remainingTransition };
if (duration !== undefined) {
springTransition.duration = motionUtils.secondsToMilliseconds(duration);
}
const springEasing = motionDom.createGeneratorEasing(springTransition, absoluteDelta, createGenerator);
ease = springEasing.ease;
duration = springEasing.duration;
}
duration ?? (duration = defaultDuration);
const startTime = currentTime + calculatedDelay;
/**
* If there's only one time offset of 0, fill in a second with length 1
*/
if (times.length === 1 && times[0] === 0) {
times[1] = 1;
}
/**
* Fill out if offset if fewer offsets than keyframes
*/
const remainder = times.length - valueKeyframesAsList.length;
remainder > 0 && motionDom.fillOffset(times, remainder);
/**
* If only one value has been set, ie [1], push a null to the start of
* the keyframe array. This will let us mark a keyframe at this point
* that will later be hydrated with the previous value.
*/
valueKeyframesAsList.length === 1 &&
valueKeyframesAsList.unshift(null);
/**
* Handle repeat options
*/
if (repeat) {
motionUtils.invariant(repeat < MAX_REPEAT, "Repeat count too high, must be less than 20");
duration = calculateRepeatDuration(duration, repeat);
const originalKeyframes = [...valueKeyframesAsList];
const originalTimes = [...times];
ease = Array.isArray(ease) ? [...ease] : [ease];
const originalEase = [...ease];
for (let repeatIndex = 0; repeatIndex < repeat; repeatIndex++) {
valueKeyframesAsList.push(...originalKeyframes);
for (let keyframeIndex = 0; keyframeIndex < originalKeyframes.length; keyframeIndex++) {
times.push(originalTimes[keyframeIndex] + (repeatIndex + 1));
ease.push(keyframeIndex === 0
? "linear"
: motionUtils.getEasingForSegment(originalEase, keyframeIndex - 1));
}
}
normalizeTimes(times, repeat);
}
const targetTime = startTime + duration;
/**
* Add keyframes, mapping offsets to absolute time.
*/
addKeyframes(valueSequence, valueKeyframesAsList, ease, times, startTime, targetTime);
maxDuration = Math.max(calculatedDelay + duration, maxDuration);
totalDuration = Math.max(targetTime, totalDuration);
};
if (motionDom.isMotionValue(subject)) {
const subjectSequence = getSubjectSequence(subject, sequences);
resolveValueSequence(keyframes, transition, getValueSequence("default", subjectSequence));
}
else {
const subjects = resolveSubjects(subject, keyframes, scope, elementCache);
const numSubjects = subjects.length;
/**
* For every element in this segment, process the defined values.
*/
for (let subjectIndex = 0; subjectIndex < numSubjects; subjectIndex++) {
/**
* Cast necessary, but we know these are of this type
*/
keyframes = keyframes;
transition = transition;
const thisSubject = subjects[subjectIndex];
const subjectSequence = getSubjectSequence(thisSubject, sequences);
for (const key in keyframes) {
resolveValueSequence(keyframes[key], getValueTransition(transition, key), getValueSequence(key, subjectSequence), subjectIndex, numSubjects);
}
}
}
prevTime = currentTime;
currentTime += maxDuration;
}
/**
* For every element and value combination create a new animation.
*/
sequences.forEach((valueSequences, element) => {
for (const key in valueSequences) {
const valueSequence = valueSequences[key];
/**
* Arrange all the keyframes in ascending time order.
*/
valueSequence.sort(compareByTime);
const keyframes = [];
const valueOffset = [];
const valueEasing = [];
/**
* For each keyframe, translate absolute times into
* relative offsets based on the total duration of the timeline.
*/
for (let i = 0; i < valueSequence.length; i++) {
const { at, value, easing } = valueSequence[i];
keyframes.push(value);
valueOffset.push(motionUtils.progress(0, totalDuration, at));
valueEasing.push(easing || "easeOut");
}
/**
* If the first keyframe doesn't land on offset: 0
* provide one by duplicating the initial keyframe. This ensures
* it snaps to the first keyframe when the animation starts.
*/
if (valueOffset[0] !== 0) {
valueOffset.unshift(0);
keyframes.unshift(keyframes[0]);
valueEasing.unshift(defaultSegmentEasing);
}
/**
* If the last keyframe doesn't land on offset: 1
* provide one with a null wildcard value. This will ensure it
* stays static until the end of the animation.
*/
if (valueOffset[valueOffset.length - 1] !== 1) {
valueOffset.push(1);
keyframes.push(null);
}
if (!animationDefinitions.has(element)) {
animationDefinitions.set(element, {
keyframes: {},
transition: {},
});
}
const definition = animationDefinitions.get(element);
definition.keyframes[key] = keyframes;
definition.transition[key] = {
...defaultTransition,
duration: totalDuration,
ease: valueEasing,
times: valueOffset,
...sequenceTransition,
};
}
});
return animationDefinitions;
}
function getSubjectSequence(subject, sequences) {
!sequences.has(subject) && sequences.set(subject, {});
return sequences.get(subject);
}
function getValueSequence(name, sequences) {
if (!sequences[name])
sequences[name] = [];
return sequences[name];
}
function keyframesAsList(keyframes) {
return Array.isArray(keyframes) ? keyframes : [keyframes];
}
function getValueTransition(transition, key) {
return transition && transition[key]
? {
...transition,
...transition[key],
}
: { ...transition };
}
const isNumber = (keyframe) => typeof keyframe === "number";
const isNumberKeyframesArray = (keyframes) => keyframes.every(isNumber);
function animateElements(elementOrSelector, keyframes, options, scope) {
const elements = motionDom.resolveElements(elementOrSelector, scope);
const numElements = elements.length;
motionUtils.invariant(Boolean(numElements), "No valid element provided.");
/**
* WAAPI doesn't support interrupting animations.
*
* Therefore, starting animations requires a three-step process:
* 1. Stop existing animations (write styles to DOM)
* 2. Resolve keyframes (read styles from DOM)
* 3. Create new animations (write styles to DOM)
*
* The hybrid `animate()` function uses AsyncAnimation to resolve
* keyframes before creating new animations, which removes style
* thrashing. Here, we have much stricter filesize constraints.
* Therefore we do this in a synchronous way that ensures that
* at least within `animate()` calls there is no style thrashing.
*
* In the motion-native-animate-mini-interrupt benchmark this
* was 80% faster than a single loop.
*/
const animationDefinitions = [];
/**
* Step 1: Build options and stop existing animations (write)
*/
for (let i = 0; i < numElements; i++) {
const element = elements[i];
const elementTransition = { ...options };
/**
* Resolve stagger function if provided.
*/
if (typeof elementTransition.delay === "function") {
elementTransition.delay = elementTransition.delay(i, numElements);
}
for (const valueName in keyframes) {
let valueKeyframes = keyframes[valueName];
if (!Array.isArray(valueKeyframes)) {
valueKeyframes = [valueKeyframes];
}
const valueOptions = {
...motionDom.getValueTransition(elementTransition, valueName),
};
valueOptions.duration && (valueOptions.duration = motionUtils.secondsToMilliseconds(valueOptions.duration));
valueOptions.delay && (valueOptions.delay = motionUtils.secondsToMilliseconds(valueOptions.delay));
/**
* If there's an existing animation playing on this element then stop it
* before creating a new one.
*/
const map = motionDom.getAnimationMap(element);
const key = motionDom.animationMapKey(valueName, valueOptions.pseudoElement || "");
const currentAnimation = map.get(key);
currentAnimation && currentAnimation.stop();
animationDefinitions.push({
map,
key,
unresolvedKeyframes: valueKeyframes,
options: {
...valueOptions,
element,
name: valueName,
allowFlatten: !elementTransition.type && !elementTransition.ease,
},
});
}
}
/**
* Step 2: Resolve keyframes (read)
*/
for (let i = 0; i < animationDefinitions.length; i++) {
const { unresolvedKeyframes, options: animationOptions } = animationDefinitions[i];
const { element, name, pseudoElement } = animationOptions;
if (!pseudoElement && unresolvedKeyframes[0] === null) {
unresolvedKeyframes[0] = motionDom.getComputedStyle(element, name);
}
motionDom.fillWildcards(unresolvedKeyframes);
motionDom.applyPxDefaults(unresolvedKeyframes, name);
/**
* If we only have one keyframe, explicitly read the initial keyframe
* from the computed style. This is to ensure consistency with WAAPI behaviour
* for restarting animations, for instance .play() after finish, when it
* has one vs two keyframes.
*/
if (!pseudoElement && unresolvedKeyframes.length < 2) {
unresolvedKeyframes.unshift(motionDom.getComputedStyle(element, name));
}
animationOptions.keyframes = unresolvedKeyframes;
}
/**
* Step 3: Create new animations (write)
*/
const animations = [];
for (let i = 0; i < animationDefinitions.length; i++) {
const { map, key, options: animationOptions } = animationDefinitions[i];
const animation = new motionDom.NativeAnimation(animationOptions);
map.set(key, animation);
animation.finished.finally(() => map.delete(key));
animations.push(animation);
}
return animations;
}
function animateSequence(definition, options) {
const animations = [];
createAnimationsFromSequence(definition, options).forEach(({ keyframes, transition }, element) => {
animations.push(...animateElements(element, keyframes, transition));
});
return new motionDom.GroupAnimationWithThen(animations);
}
const createScopedWaapiAnimate = (scope) => {
function scopedAnimate(elementOrSelector, keyframes, options) {
return new motionDom.GroupAnimationWithThen(animateElements(elementOrSelector, keyframes, options, scope));
}
return scopedAnimate;
};
const animateMini = /*@__PURE__*/ createScopedWaapiAnimate();
exports.animate = animateMini;
exports.animateSequence = animateSequence;
-2471
View File
File diff suppressed because it is too large Load Diff
-2939
View File
File diff suppressed because it is too large Load Diff
-1444
View File
File diff suppressed because it is too large Load Diff
-148
View File
@@ -1,148 +0,0 @@
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
var react = require('react');
var motionDom = require('motion-dom');
var motionUtils = require('motion-utils');
/**
* Creates a constant value over the lifecycle of a component.
*
* Even if `useMemo` is provided an empty array as its final argument, it doesn't offer
* a guarantee that it won't re-run for performance reasons later on. By using `useConstant`
* you can ensure that initialisers don't execute twice or more.
*/
function useConstant(init) {
const ref = react.useRef(null);
if (ref.current === null) {
ref.current = init();
}
return ref.current;
}
function useUnmountEffect(callback) {
return react.useEffect(() => () => callback(), []);
}
function animateElements(elementOrSelector, keyframes, options, scope) {
const elements = motionDom.resolveElements(elementOrSelector, scope);
const numElements = elements.length;
motionUtils.invariant(Boolean(numElements), "No valid element provided.");
/**
* WAAPI doesn't support interrupting animations.
*
* Therefore, starting animations requires a three-step process:
* 1. Stop existing animations (write styles to DOM)
* 2. Resolve keyframes (read styles from DOM)
* 3. Create new animations (write styles to DOM)
*
* The hybrid `animate()` function uses AsyncAnimation to resolve
* keyframes before creating new animations, which removes style
* thrashing. Here, we have much stricter filesize constraints.
* Therefore we do this in a synchronous way that ensures that
* at least within `animate()` calls there is no style thrashing.
*
* In the motion-native-animate-mini-interrupt benchmark this
* was 80% faster than a single loop.
*/
const animationDefinitions = [];
/**
* Step 1: Build options and stop existing animations (write)
*/
for (let i = 0; i < numElements; i++) {
const element = elements[i];
const elementTransition = { ...options };
/**
* Resolve stagger function if provided.
*/
if (typeof elementTransition.delay === "function") {
elementTransition.delay = elementTransition.delay(i, numElements);
}
for (const valueName in keyframes) {
let valueKeyframes = keyframes[valueName];
if (!Array.isArray(valueKeyframes)) {
valueKeyframes = [valueKeyframes];
}
const valueOptions = {
...motionDom.getValueTransition(elementTransition, valueName),
};
valueOptions.duration && (valueOptions.duration = motionUtils.secondsToMilliseconds(valueOptions.duration));
valueOptions.delay && (valueOptions.delay = motionUtils.secondsToMilliseconds(valueOptions.delay));
/**
* If there's an existing animation playing on this element then stop it
* before creating a new one.
*/
const map = motionDom.getAnimationMap(element);
const key = motionDom.animationMapKey(valueName, valueOptions.pseudoElement || "");
const currentAnimation = map.get(key);
currentAnimation && currentAnimation.stop();
animationDefinitions.push({
map,
key,
unresolvedKeyframes: valueKeyframes,
options: {
...valueOptions,
element,
name: valueName,
allowFlatten: !elementTransition.type && !elementTransition.ease,
},
});
}
}
/**
* Step 2: Resolve keyframes (read)
*/
for (let i = 0; i < animationDefinitions.length; i++) {
const { unresolvedKeyframes, options: animationOptions } = animationDefinitions[i];
const { element, name, pseudoElement } = animationOptions;
if (!pseudoElement && unresolvedKeyframes[0] === null) {
unresolvedKeyframes[0] = motionDom.getComputedStyle(element, name);
}
motionDom.fillWildcards(unresolvedKeyframes);
motionDom.applyPxDefaults(unresolvedKeyframes, name);
/**
* If we only have one keyframe, explicitly read the initial keyframe
* from the computed style. This is to ensure consistency with WAAPI behaviour
* for restarting animations, for instance .play() after finish, when it
* has one vs two keyframes.
*/
if (!pseudoElement && unresolvedKeyframes.length < 2) {
unresolvedKeyframes.unshift(motionDom.getComputedStyle(element, name));
}
animationOptions.keyframes = unresolvedKeyframes;
}
/**
* Step 3: Create new animations (write)
*/
const animations = [];
for (let i = 0; i < animationDefinitions.length; i++) {
const { map, key, options: animationOptions } = animationDefinitions[i];
const animation = new motionDom.NativeAnimation(animationOptions);
map.set(key, animation);
animation.finished.finally(() => map.delete(key));
animations.push(animation);
}
return animations;
}
const createScopedWaapiAnimate = (scope) => {
function scopedAnimate(elementOrSelector, keyframes, options) {
return new motionDom.GroupAnimationWithThen(animateElements(elementOrSelector, keyframes, options, scope));
}
return scopedAnimate;
};
function useAnimateMini() {
const scope = useConstant(() => ({
current: null, // Will be hydrated by React
animations: [],
}));
const animate = useConstant(() => createScopedWaapiAnimate(scope));
useUnmountEffect(() => {
scope.animations.forEach((animation) => animation.stop());
});
return [scope, animate];
}
exports.useAnimate = useAnimateMini;
-2
View File
@@ -1,2 +0,0 @@
export * from 'motion-dom';
export { recordStats } from 'motion-dom';
-48
View File
@@ -1,48 +0,0 @@
import { AnimationPlaybackOptions, Transition, MotionValue, UnresolvedValueKeyframe, ElementOrSelector, DOMKeyframesDefinition, AnimationOptions, GroupAnimationWithThen, AnimationPlaybackControlsWithThen } from 'motion-dom';
type ObjectTarget<O> = {
[K in keyof O]?: O[K] | UnresolvedValueKeyframe[];
};
type SequenceTime = number | "<" | `+${number}` | `-${number}` | `${string}`;
type SequenceLabel = string;
interface SequenceLabelWithTime {
name: SequenceLabel;
at: SequenceTime;
}
interface At {
at?: SequenceTime;
}
type MotionValueSegment = [
MotionValue,
UnresolvedValueKeyframe | UnresolvedValueKeyframe[]
];
type MotionValueSegmentWithTransition = [
MotionValue,
UnresolvedValueKeyframe | UnresolvedValueKeyframe[],
Transition & At
];
type DOMSegment = [ElementOrSelector, DOMKeyframesDefinition];
type DOMSegmentWithTransition = [
ElementOrSelector,
DOMKeyframesDefinition,
AnimationOptions & At
];
type ObjectSegment<O extends {} = {}> = [O, ObjectTarget<O>];
type ObjectSegmentWithTransition<O extends {} = {}> = [
O,
ObjectTarget<O>,
AnimationOptions & At
];
type Segment = ObjectSegment | ObjectSegmentWithTransition | SequenceLabel | SequenceLabelWithTime | MotionValueSegment | MotionValueSegmentWithTransition | DOMSegment | DOMSegmentWithTransition;
type AnimationSequence = Segment[];
interface SequenceOptions extends AnimationPlaybackOptions {
delay?: number;
duration?: number;
defaultTransition?: Transition;
}
declare function animateSequence(definition: AnimationSequence, options?: SequenceOptions): GroupAnimationWithThen;
declare const animateMini: (elementOrSelector: ElementOrSelector, keyframes: DOMKeyframesDefinition, options?: AnimationOptions) => AnimationPlaybackControlsWithThen;
export { animateMini as animate, animateSequence };
-1
View File
File diff suppressed because one or more lines are too long
-151
View File
@@ -1,151 +0,0 @@
import { UnresolvedValueKeyframe, MotionValue, Transition, ElementOrSelector, DOMKeyframesDefinition, AnimationOptions, AnimationPlaybackOptions, AnyResolvedKeyframe, AnimationScope, AnimationPlaybackControlsWithThen, ValueAnimationTransition, AnimationPlaybackControls } from 'motion-dom';
export * from 'motion-dom';
import { Easing, EasingFunction, Point } from 'motion-utils';
export * from 'motion-utils';
type ObjectTarget<O> = {
[K in keyof O]?: O[K] | UnresolvedValueKeyframe[];
};
type SequenceTime = number | "<" | `+${number}` | `-${number}` | `${string}`;
type SequenceLabel = string;
interface SequenceLabelWithTime {
name: SequenceLabel;
at: SequenceTime;
}
interface At {
at?: SequenceTime;
}
type MotionValueSegment = [
MotionValue,
UnresolvedValueKeyframe | UnresolvedValueKeyframe[]
];
type MotionValueSegmentWithTransition = [
MotionValue,
UnresolvedValueKeyframe | UnresolvedValueKeyframe[],
Transition & At
];
type DOMSegment = [ElementOrSelector, DOMKeyframesDefinition];
type DOMSegmentWithTransition = [
ElementOrSelector,
DOMKeyframesDefinition,
AnimationOptions & At
];
type ObjectSegment<O extends {} = {}> = [O, ObjectTarget<O>];
type ObjectSegmentWithTransition<O extends {} = {}> = [
O,
ObjectTarget<O>,
AnimationOptions & At
];
type Segment = ObjectSegment | ObjectSegmentWithTransition | SequenceLabel | SequenceLabelWithTime | MotionValueSegment | MotionValueSegmentWithTransition | DOMSegment | DOMSegmentWithTransition;
type AnimationSequence = Segment[];
interface SequenceOptions extends AnimationPlaybackOptions {
delay?: number;
duration?: number;
defaultTransition?: Transition;
}
interface AbsoluteKeyframe {
value: AnyResolvedKeyframe | null;
at: number;
easing?: Easing;
}
type ValueSequence = AbsoluteKeyframe[];
interface SequenceMap {
[key: string]: ValueSequence;
}
type ResolvedAnimationDefinition = {
keyframes: {
[key: string]: UnresolvedValueKeyframe[];
};
transition: {
[key: string]: Transition;
};
};
type ResolvedAnimationDefinitions = Map<Element | MotionValue, ResolvedAnimationDefinition>;
/**
* Creates an animation function that is optionally scoped
* to a specific element.
*/
declare function createScopedAnimate(scope?: AnimationScope): {
(sequence: AnimationSequence, options?: SequenceOptions): AnimationPlaybackControlsWithThen;
(value: string | MotionValue<string>, keyframes: string | UnresolvedValueKeyframe<string>[], options?: ValueAnimationTransition<string>): AnimationPlaybackControlsWithThen;
(value: number | MotionValue<number>, keyframes: number | UnresolvedValueKeyframe<number>[], options?: ValueAnimationTransition<number>): AnimationPlaybackControlsWithThen;
<V extends string | number>(value: V | MotionValue<V>, keyframes: V | UnresolvedValueKeyframe<V>[], options?: ValueAnimationTransition<V>): AnimationPlaybackControlsWithThen;
(element: ElementOrSelector, keyframes: DOMKeyframesDefinition, options?: AnimationOptions): AnimationPlaybackControlsWithThen;
<O extends {}>(object: O | O[], keyframes: ObjectTarget<O>, options?: AnimationOptions): AnimationPlaybackControlsWithThen;
};
declare const animate: {
(sequence: AnimationSequence, options?: SequenceOptions): AnimationPlaybackControlsWithThen;
(value: string | MotionValue<string>, keyframes: string | UnresolvedValueKeyframe<string>[], options?: ValueAnimationTransition<string>): AnimationPlaybackControlsWithThen;
(value: number | MotionValue<number>, keyframes: number | UnresolvedValueKeyframe<number>[], options?: ValueAnimationTransition<number>): AnimationPlaybackControlsWithThen;
<V extends string | number>(value: V | MotionValue<V>, keyframes: V | UnresolvedValueKeyframe<V>[], options?: ValueAnimationTransition<V>): AnimationPlaybackControlsWithThen;
(element: ElementOrSelector, keyframes: DOMKeyframesDefinition, options?: AnimationOptions): AnimationPlaybackControlsWithThen;
<O extends {}>(object: O | O[], keyframes: ObjectTarget<O>, options?: AnimationOptions): AnimationPlaybackControlsWithThen;
};
declare const animateMini: (elementOrSelector: ElementOrSelector, keyframes: DOMKeyframesDefinition, options?: AnimationOptions) => AnimationPlaybackControlsWithThen;
interface ScrollOptions {
source?: HTMLElement;
container?: Element;
target?: Element;
axis?: "x" | "y";
offset?: ScrollOffset;
}
type OnScrollProgress = (progress: number) => void;
type OnScrollWithInfo = (progress: number, info: ScrollInfo) => void;
type OnScroll = OnScrollProgress | OnScrollWithInfo;
interface AxisScrollInfo {
current: number;
offset: number[];
progress: number;
scrollLength: number;
velocity: number;
targetOffset: number;
targetLength: number;
containerLength: number;
interpolatorOffsets?: number[];
interpolate?: EasingFunction;
}
interface ScrollInfo {
time: number;
x: AxisScrollInfo;
y: AxisScrollInfo;
}
type OnScrollInfo = (info: ScrollInfo) => void;
type SupportedEdgeUnit = "px" | "vw" | "vh" | "%";
type EdgeUnit = `${number}${SupportedEdgeUnit}`;
type NamedEdges = "start" | "end" | "center";
type EdgeString = NamedEdges | EdgeUnit | `${number}`;
type Edge = EdgeString | number;
type ProgressIntersection = [number, number];
type Intersection = `${Edge} ${Edge}`;
type ScrollOffset = Array<Edge | Intersection | ProgressIntersection>;
interface ScrollInfoOptions {
container?: Element;
target?: Element;
axis?: "x" | "y";
offset?: ScrollOffset;
}
declare function scroll(onScroll: OnScroll | AnimationPlaybackControls, { axis, container, ...options }?: ScrollOptions): VoidFunction;
declare function scrollInfo(onScroll: OnScrollInfo, { container, ...options }?: ScrollInfoOptions): VoidFunction;
type ViewChangeHandler = (entry: IntersectionObserverEntry) => void;
type MarginValue = `${number}${"px" | "%"}`;
type MarginType = MarginValue | `${MarginValue} ${MarginValue}` | `${MarginValue} ${MarginValue} ${MarginValue}` | `${MarginValue} ${MarginValue} ${MarginValue} ${MarginValue}`;
interface InViewOptions {
root?: Element | Document;
margin?: MarginType;
amount?: "some" | "all" | number;
}
declare function inView(elementOrSelector: ElementOrSelector, onStart: (element: Element, entry: IntersectionObserverEntry) => void | ViewChangeHandler, { root, margin: rootMargin, amount }?: InViewOptions): VoidFunction;
type DelayedFunction = (overshoot: number) => void;
declare function delayInSeconds(callback: DelayedFunction, timeout: number): () => void;
declare const distance: (a: number, b: number) => number;
declare function distance2D(a: Point, b: Point): number;
export { type AbsoluteKeyframe, type AnimationSequence, type At, type DOMSegment, type DOMSegmentWithTransition, type DelayedFunction, type MotionValueSegment, type MotionValueSegmentWithTransition, type ObjectSegment, type ObjectSegmentWithTransition, type ObjectTarget, type ResolvedAnimationDefinition, type ResolvedAnimationDefinitions, type Segment, type SequenceLabel, type SequenceLabelWithTime, type SequenceMap, type SequenceOptions, type SequenceTime, type ValueSequence, animate, animateMini, createScopedAnimate, delayInSeconds as delay, distance, distance2D, inView, scroll, scrollInfo };
-1
View File
File diff suppressed because one or more lines are too long
-38
View File
@@ -1,38 +0,0 @@
import { GroupAnimationWithThen } from 'motion-dom';
import { removeItem } from 'motion-utils';
import { animateSequence } from './sequence.mjs';
import { animateSubject } from './subject.mjs';
function isSequence(value) {
return Array.isArray(value) && value.some(Array.isArray);
}
/**
* Creates an animation function that is optionally scoped
* to a specific element.
*/
function createScopedAnimate(scope) {
/**
* Implementation
*/
function scopedAnimate(subjectOrSequence, optionsOrKeyframes, options) {
let animations = [];
if (isSequence(subjectOrSequence)) {
animations = animateSequence(subjectOrSequence, optionsOrKeyframes, scope);
}
else {
animations = animateSubject(subjectOrSequence, optionsOrKeyframes, options, scope);
}
const animation = new GroupAnimationWithThen(animations);
if (scope) {
scope.animations.push(animation);
animation.finished.then(() => {
removeItem(scope.animations, animation);
});
}
return animation;
}
return scopedAnimate;
}
const animate = createScopedAnimate();
export { animate, createScopedAnimate };
@@ -1,19 +0,0 @@
import { resolveElements } from 'motion-dom';
import { isDOMKeyframes } from '../utils/is-dom-keyframes.mjs';
function resolveSubjects(subject, keyframes, scope, selectorCache) {
if (typeof subject === "string" && isDOMKeyframes(keyframes)) {
return resolveElements(subject, scope, selectorCache);
}
else if (subject instanceof NodeList) {
return Array.from(subject);
}
else if (Array.isArray(subject)) {
return subject;
}
else {
return [subject];
}
}
export { resolveSubjects };
-14
View File
@@ -1,14 +0,0 @@
import { spring } from 'motion-dom';
import { createAnimationsFromSequence } from '../sequence/create.mjs';
import { animateSubject } from './subject.mjs';
function animateSequence(sequence, options, scope) {
const animations = [];
const animationDefinitions = createAnimationsFromSequence(sequence, options, scope, { spring });
animationDefinitions.forEach(({ keyframes, transition }, subject) => {
animations.push(...animateSubject(subject, keyframes, transition));
});
return animations;
}
export { animateSequence };
-10
View File
@@ -1,10 +0,0 @@
import { isMotionValue, motionValue } from 'motion-dom';
import { animateMotionValue } from '../interfaces/motion-value.mjs';
function animateSingleValue(value, keyframes, options) {
const motionValue$1 = isMotionValue(value) ? value : motionValue(value);
motionValue$1.start(animateMotionValue("", motionValue$1, keyframes, options));
return motionValue$1.animation;
}
export { animateSingleValue };
-52
View File
@@ -1,52 +0,0 @@
import { isMotionValue } from 'motion-dom';
import { invariant } from 'motion-utils';
import { visualElementStore } from '../../render/store.mjs';
import { animateTarget } from '../interfaces/visual-element-target.mjs';
import { createDOMVisualElement, createObjectVisualElement } from '../utils/create-visual-element.mjs';
import { isDOMKeyframes } from '../utils/is-dom-keyframes.mjs';
import { resolveSubjects } from './resolve-subjects.mjs';
import { animateSingleValue } from './single-value.mjs';
function isSingleValue(subject, keyframes) {
return (isMotionValue(subject) ||
typeof subject === "number" ||
(typeof subject === "string" && !isDOMKeyframes(keyframes)));
}
/**
* Implementation
*/
function animateSubject(subject, keyframes, options, scope) {
const animations = [];
if (isSingleValue(subject, keyframes)) {
animations.push(animateSingleValue(subject, isDOMKeyframes(keyframes)
? keyframes.default || keyframes
: keyframes, options ? options.default || options : options));
}
else {
const subjects = resolveSubjects(subject, keyframes, scope);
const numSubjects = subjects.length;
invariant(Boolean(numSubjects), "No valid elements provided.");
for (let i = 0; i < numSubjects; i++) {
const thisSubject = subjects[i];
const createVisualElement = thisSubject instanceof Element
? createDOMVisualElement
: createObjectVisualElement;
if (!visualElementStore.has(thisSubject)) {
createVisualElement(thisSubject);
}
const visualElement = visualElementStore.get(thisSubject);
const transition = { ...options };
/**
* Resolve stagger function if provided.
*/
if ("delay" in transition &&
typeof transition.delay === "function") {
transition.delay = transition.delay(i, numSubjects);
}
animations.push(...animateTarget(visualElement, { ...keyframes, transition }, {}));
}
}
return animations;
}
export { animateSubject };
@@ -1,105 +0,0 @@
import { resolveElements, getValueTransition, getAnimationMap, animationMapKey, getComputedStyle, fillWildcards, applyPxDefaults, NativeAnimation } from 'motion-dom';
import { invariant, secondsToMilliseconds } from 'motion-utils';
function animateElements(elementOrSelector, keyframes, options, scope) {
const elements = resolveElements(elementOrSelector, scope);
const numElements = elements.length;
invariant(Boolean(numElements), "No valid element provided.");
/**
* WAAPI doesn't support interrupting animations.
*
* Therefore, starting animations requires a three-step process:
* 1. Stop existing animations (write styles to DOM)
* 2. Resolve keyframes (read styles from DOM)
* 3. Create new animations (write styles to DOM)
*
* The hybrid `animate()` function uses AsyncAnimation to resolve
* keyframes before creating new animations, which removes style
* thrashing. Here, we have much stricter filesize constraints.
* Therefore we do this in a synchronous way that ensures that
* at least within `animate()` calls there is no style thrashing.
*
* In the motion-native-animate-mini-interrupt benchmark this
* was 80% faster than a single loop.
*/
const animationDefinitions = [];
/**
* Step 1: Build options and stop existing animations (write)
*/
for (let i = 0; i < numElements; i++) {
const element = elements[i];
const elementTransition = { ...options };
/**
* Resolve stagger function if provided.
*/
if (typeof elementTransition.delay === "function") {
elementTransition.delay = elementTransition.delay(i, numElements);
}
for (const valueName in keyframes) {
let valueKeyframes = keyframes[valueName];
if (!Array.isArray(valueKeyframes)) {
valueKeyframes = [valueKeyframes];
}
const valueOptions = {
...getValueTransition(elementTransition, valueName),
};
valueOptions.duration && (valueOptions.duration = secondsToMilliseconds(valueOptions.duration));
valueOptions.delay && (valueOptions.delay = secondsToMilliseconds(valueOptions.delay));
/**
* If there's an existing animation playing on this element then stop it
* before creating a new one.
*/
const map = getAnimationMap(element);
const key = animationMapKey(valueName, valueOptions.pseudoElement || "");
const currentAnimation = map.get(key);
currentAnimation && currentAnimation.stop();
animationDefinitions.push({
map,
key,
unresolvedKeyframes: valueKeyframes,
options: {
...valueOptions,
element,
name: valueName,
allowFlatten: !elementTransition.type && !elementTransition.ease,
},
});
}
}
/**
* Step 2: Resolve keyframes (read)
*/
for (let i = 0; i < animationDefinitions.length; i++) {
const { unresolvedKeyframes, options: animationOptions } = animationDefinitions[i];
const { element, name, pseudoElement } = animationOptions;
if (!pseudoElement && unresolvedKeyframes[0] === null) {
unresolvedKeyframes[0] = getComputedStyle(element, name);
}
fillWildcards(unresolvedKeyframes);
applyPxDefaults(unresolvedKeyframes, name);
/**
* If we only have one keyframe, explicitly read the initial keyframe
* from the computed style. This is to ensure consistency with WAAPI behaviour
* for restarting animations, for instance .play() after finish, when it
* has one vs two keyframes.
*/
if (!pseudoElement && unresolvedKeyframes.length < 2) {
unresolvedKeyframes.unshift(getComputedStyle(element, name));
}
animationOptions.keyframes = unresolvedKeyframes;
}
/**
* Step 3: Create new animations (write)
*/
const animations = [];
for (let i = 0; i < animationDefinitions.length; i++) {
const { map, key, options: animationOptions } = animationDefinitions[i];
const animation = new NativeAnimation(animationOptions);
map.set(key, animation);
animation.finished.finally(() => map.delete(key));
animations.push(animation);
}
return animations;
}
export { animateElements };
@@ -1,13 +0,0 @@
import { GroupAnimationWithThen } from 'motion-dom';
import { createAnimationsFromSequence } from '../../sequence/create.mjs';
import { animateElements } from './animate-elements.mjs';
function animateSequence(definition, options) {
const animations = [];
createAnimationsFromSequence(definition, options).forEach(({ keyframes, transition }, element) => {
animations.push(...animateElements(element, keyframes, transition));
});
return new GroupAnimationWithThen(animations);
}
export { animateSequence };
@@ -1,12 +0,0 @@
import { GroupAnimationWithThen } from 'motion-dom';
import { animateElements } from './animate-elements.mjs';
const createScopedWaapiAnimate = (scope) => {
function scopedAnimate(elementOrSelector, keyframes, options) {
return new GroupAnimationWithThen(animateElements(elementOrSelector, keyframes, options, scope));
}
return scopedAnimate;
};
const animateMini = /*@__PURE__*/ createScopedWaapiAnimate();
export { animateMini, createScopedWaapiAnimate };
@@ -1,12 +0,0 @@
const isNotNull = (value) => value !== null;
function getFinalKeyframe(keyframes, { repeat, repeatType = "loop" }, finalKeyframe) {
const resolvedKeyframes = keyframes.filter(isNotNull);
const index = repeat && repeatType !== "loop" && repeat % 2 === 1
? 0
: resolvedKeyframes.length - 1;
return !index || finalKeyframe === undefined
? resolvedKeyframes[index]
: finalKeyframe;
}
export { getFinalKeyframe };
@@ -1,80 +0,0 @@
import { invariant } from 'motion-utils';
import { setTarget } from '../../render/utils/setters.mjs';
import { animateVisualElement } from '../interfaces/visual-element.mjs';
function stopAnimation(visualElement) {
visualElement.values.forEach((value) => value.stop());
}
function setVariants(visualElement, variantLabels) {
const reversedLabels = [...variantLabels].reverse();
reversedLabels.forEach((key) => {
const variant = visualElement.getVariant(key);
variant && setTarget(visualElement, variant);
if (visualElement.variantChildren) {
visualElement.variantChildren.forEach((child) => {
setVariants(child, variantLabels);
});
}
});
}
function setValues(visualElement, definition) {
if (Array.isArray(definition)) {
return setVariants(visualElement, definition);
}
else if (typeof definition === "string") {
return setVariants(visualElement, [definition]);
}
else {
setTarget(visualElement, definition);
}
}
/**
* @public
*/
function animationControls() {
/**
* Track whether the host component has mounted.
*/
let hasMounted = false;
/**
* A collection of linked component animation controls.
*/
const subscribers = new Set();
const controls = {
subscribe(visualElement) {
subscribers.add(visualElement);
return () => void subscribers.delete(visualElement);
},
start(definition, transitionOverride) {
invariant(hasMounted, "controls.start() should only be called after a component has mounted. Consider calling within a useEffect hook.");
const animations = [];
subscribers.forEach((visualElement) => {
animations.push(animateVisualElement(visualElement, definition, {
transitionOverride,
}));
});
return Promise.all(animations);
},
set(definition) {
invariant(hasMounted, "controls.set() should only be called after a component has mounted. Consider calling within a useEffect hook.");
return subscribers.forEach((visualElement) => {
setValues(visualElement, definition);
});
},
stop() {
subscribers.forEach((visualElement) => {
stopAnimation(visualElement);
});
},
mount() {
hasMounted = true;
return () => {
hasMounted = false;
controls.stop();
};
},
};
return controls;
}
export { animationControls, setValues };
@@ -1,17 +0,0 @@
import { useConstant } from '../../utils/use-constant.mjs';
import { useUnmountEffect } from '../../utils/use-unmount-effect.mjs';
import { createScopedWaapiAnimate } from '../animators/waapi/animate-style.mjs';
function useAnimateMini() {
const scope = useConstant(() => ({
current: null, // Will be hydrated by React
animations: [],
}));
const animate = useConstant(() => createScopedWaapiAnimate(scope));
useUnmountEffect(() => {
scope.animations.forEach((animation) => animation.stop());
});
return [scope, animate];
}
export { useAnimateMini };
-18
View File
@@ -1,18 +0,0 @@
import { useConstant } from '../../utils/use-constant.mjs';
import { useUnmountEffect } from '../../utils/use-unmount-effect.mjs';
import { createScopedAnimate } from '../animate/index.mjs';
function useAnimate() {
const scope = useConstant(() => ({
current: null, // Will be hydrated by React
animations: [],
}));
const animate = useConstant(() => createScopedAnimate(scope));
useUnmountEffect(() => {
scope.animations.forEach((animation) => animation.stop());
scope.animations.length = 0;
});
return [scope, animate];
}
export { useAnimate };
@@ -1,64 +0,0 @@
import { useState, useLayoutEffect } from 'react';
import { makeUseVisualState } from '../../motion/utils/use-visual-state.mjs';
import { createBox } from '../../projection/geometry/models.mjs';
import { VisualElement } from '../../render/VisualElement.mjs';
import { useConstant } from '../../utils/use-constant.mjs';
import { animateVisualElement } from '../interfaces/visual-element.mjs';
const createObject = () => ({});
class StateVisualElement extends VisualElement {
constructor() {
super(...arguments);
this.measureInstanceViewportBox = createBox;
}
build() { }
resetTransform() { }
restoreTransform() { }
removeValueFromRenderState() { }
renderInstance() { }
scrapeMotionValuesFromProps() {
return createObject();
}
getBaseTargetFromProps() {
return undefined;
}
readValueFromInstance(_state, key, options) {
return options.initialState[key] || 0;
}
sortInstanceNodePosition() {
return 0;
}
}
const useVisualState = makeUseVisualState({
scrapeMotionValuesFromProps: createObject,
createRenderState: createObject,
});
/**
* This is not an officially supported API and may be removed
* on any version.
*/
function useAnimatedState(initialState) {
const [animationState, setAnimationState] = useState(initialState);
const visualState = useVisualState({}, false);
const element = useConstant(() => {
return new StateVisualElement({
props: {
onUpdate: (v) => {
setAnimationState({ ...v });
},
},
visualState,
presenceContext: null,
}, { initialState });
});
useLayoutEffect(() => {
element.mount({});
return () => element.unmount();
}, [element]);
const startAnimation = useConstant(() => (animationDefinition) => {
return animateVisualElement(element, animationDefinition);
});
return [animationState, startAnimation];
}
export { useAnimatedState };
-41
View File
@@ -1,41 +0,0 @@
import { useConstant } from '../../utils/use-constant.mjs';
import { useIsomorphicLayoutEffect } from '../../utils/use-isomorphic-effect.mjs';
import { animationControls } from './animation-controls.mjs';
/**
* Creates `LegacyAnimationControls`, which can be used to manually start, stop
* and sequence animations on one or more components.
*
* The returned `LegacyAnimationControls` should be passed to the `animate` property
* of the components you want to animate.
*
* These components can then be animated with the `start` method.
*
* ```jsx
* import * as React from 'react'
* import { motion, useAnimation } from 'framer-motion'
*
* export function MyComponent(props) {
* const controls = useAnimation()
*
* controls.start({
* x: 100,
* transition: { duration: 0.5 },
* })
*
* return <motion.div animate={controls} />
* }
* ```
*
* @returns Animation controller with `start` and `stop` methods
*
* @public
*/
function useAnimationControls() {
const controls = useConstant(animationControls);
useIsomorphicLayoutEffect(controls.mount, []);
return controls;
}
const useAnimation = useAnimationControls;
export { useAnimation, useAnimationControls };
@@ -1,98 +0,0 @@
import { getValueTransition, frame, JSAnimation, AsyncMotionValueAnimation } from 'motion-dom';
import { secondsToMilliseconds, MotionGlobalConfig } from 'motion-utils';
import { getFinalKeyframe } from '../animators/waapi/utils/get-final-keyframe.mjs';
import { getDefaultTransition } from '../utils/default-transitions.mjs';
import { isTransitionDefined } from '../utils/is-transition-defined.mjs';
const animateMotionValue = (name, value, target, transition = {}, element, isHandoff) => (onComplete) => {
const valueTransition = getValueTransition(transition, name) || {};
/**
* Most transition values are currently completely overwritten by value-specific
* transitions. In the future it'd be nicer to blend these transitions. But for now
* delay actually does inherit from the root transition if not value-specific.
*/
const delay = valueTransition.delay || transition.delay || 0;
/**
* Elapsed isn't a public transition option but can be passed through from
* optimized appear effects in milliseconds.
*/
let { elapsed = 0 } = transition;
elapsed = elapsed - secondsToMilliseconds(delay);
const options = {
keyframes: Array.isArray(target) ? target : [null, target],
ease: "easeOut",
velocity: value.getVelocity(),
...valueTransition,
delay: -elapsed,
onUpdate: (v) => {
value.set(v);
valueTransition.onUpdate && valueTransition.onUpdate(v);
},
onComplete: () => {
onComplete();
valueTransition.onComplete && valueTransition.onComplete();
},
name,
motionValue: value,
element: isHandoff ? undefined : element,
};
/**
* If there's no transition defined for this value, we can generate
* unique transition settings for this value.
*/
if (!isTransitionDefined(valueTransition)) {
Object.assign(options, getDefaultTransition(name, options));
}
/**
* Both WAAPI and our internal animation functions use durations
* as defined by milliseconds, while our external API defines them
* as seconds.
*/
options.duration && (options.duration = secondsToMilliseconds(options.duration));
options.repeatDelay && (options.repeatDelay = secondsToMilliseconds(options.repeatDelay));
/**
* Support deprecated way to set initial value. Prefer keyframe syntax.
*/
if (options.from !== undefined) {
options.keyframes[0] = options.from;
}
let shouldSkip = false;
if (options.type === false ||
(options.duration === 0 && !options.repeatDelay)) {
options.duration = 0;
if (options.delay === 0) {
shouldSkip = true;
}
}
if (MotionGlobalConfig.instantAnimations ||
MotionGlobalConfig.skipAnimations) {
shouldSkip = true;
options.duration = 0;
options.delay = 0;
}
/**
* If the transition type or easing has been explicitly set by the user
* then we don't want to allow flattening the animation.
*/
options.allowFlatten = !valueTransition.type && !valueTransition.ease;
/**
* If we can or must skip creating the animation, and apply only
* the final keyframe, do so. We also check once keyframes are resolved but
* this early check prevents the need to create an animation at all.
*/
if (shouldSkip && !isHandoff && value.get() !== undefined) {
const finalKeyframe = getFinalKeyframe(options.keyframes, valueTransition);
if (finalKeyframe !== undefined) {
frame.update(() => {
options.onUpdate(finalKeyframe);
options.onComplete();
});
return;
}
}
return valueTransition.isSync
? new JSAnimation(options)
: new AsyncMotionValueAnimation(options);
};
export { animateMotionValue };
@@ -1,83 +0,0 @@
import { getValueTransition, frame, positionalKeys } from 'motion-dom';
import { setTarget } from '../../render/utils/setters.mjs';
import { addValueToWillChange } from '../../value/use-will-change/add-will-change.mjs';
import { getOptimisedAppearId } from '../optimized-appear/get-appear-id.mjs';
import { animateMotionValue } from './motion-value.mjs';
/**
* Decide whether we should block this animation. Previously, we achieved this
* just by checking whether the key was listed in protectedKeys, but this
* posed problems if an animation was triggered by afterChildren and protectedKeys
* had been set to true in the meantime.
*/
function shouldBlockAnimation({ protectedKeys, needsAnimating }, key) {
const shouldBlock = protectedKeys.hasOwnProperty(key) && needsAnimating[key] !== true;
needsAnimating[key] = false;
return shouldBlock;
}
function animateTarget(visualElement, targetAndTransition, { delay = 0, transitionOverride, type } = {}) {
let { transition = visualElement.getDefaultTransition(), transitionEnd, ...target } = targetAndTransition;
if (transitionOverride)
transition = transitionOverride;
const animations = [];
const animationTypeState = type &&
visualElement.animationState &&
visualElement.animationState.getState()[type];
for (const key in target) {
const value = visualElement.getValue(key, visualElement.latestValues[key] ?? null);
const valueTarget = target[key];
if (valueTarget === undefined ||
(animationTypeState &&
shouldBlockAnimation(animationTypeState, key))) {
continue;
}
const valueTransition = {
delay,
...getValueTransition(transition || {}, key),
};
/**
* If the value is already at the defined target, skip the animation.
*/
const currentValue = value.get();
if (currentValue !== undefined &&
!value.isAnimating &&
!Array.isArray(valueTarget) &&
valueTarget === currentValue &&
!valueTransition.velocity) {
continue;
}
/**
* If this is the first time a value is being animated, check
* to see if we're handling off from an existing animation.
*/
let isHandoff = false;
if (window.MotionHandoffAnimation) {
const appearId = getOptimisedAppearId(visualElement);
if (appearId) {
const startTime = window.MotionHandoffAnimation(appearId, key, frame);
if (startTime !== null) {
valueTransition.startTime = startTime;
isHandoff = true;
}
}
}
addValueToWillChange(visualElement, key);
value.start(animateMotionValue(key, value, valueTarget, visualElement.shouldReduceMotion && positionalKeys.has(key)
? { type: false }
: valueTransition, visualElement, isHandoff));
const animation = value.animation;
if (animation) {
animations.push(animation);
}
}
if (transitionEnd) {
Promise.all(animations).then(() => {
frame.update(() => {
transitionEnd && setTarget(visualElement, transitionEnd);
});
});
}
return animations;
}
export { animateTarget };
@@ -1,72 +0,0 @@
import { resolveVariant } from '../../render/utils/resolve-dynamic-variants.mjs';
import { animateTarget } from './visual-element-target.mjs';
function animateVariant(visualElement, variant, options = {}) {
const resolved = resolveVariant(visualElement, variant, options.type === "exit"
? visualElement.presenceContext?.custom
: undefined);
let { transition = visualElement.getDefaultTransition() || {} } = resolved || {};
if (options.transitionOverride) {
transition = options.transitionOverride;
}
/**
* If we have a variant, create a callback that runs it as an animation.
* Otherwise, we resolve a Promise immediately for a composable no-op.
*/
const getAnimation = resolved
? () => Promise.all(animateTarget(visualElement, resolved, options))
: () => Promise.resolve();
/**
* If we have children, create a callback that runs all their animations.
* Otherwise, we resolve a Promise immediately for a composable no-op.
*/
const getChildAnimations = visualElement.variantChildren && visualElement.variantChildren.size
? (forwardDelay = 0) => {
const { delayChildren = 0, staggerChildren, staggerDirection, } = transition;
return animateChildren(visualElement, variant, forwardDelay, delayChildren, staggerChildren, staggerDirection, options);
}
: () => Promise.resolve();
/**
* If the transition explicitly defines a "when" option, we need to resolve either
* this animation or all children animations before playing the other.
*/
const { when } = transition;
if (when) {
const [first, last] = when === "beforeChildren"
? [getAnimation, getChildAnimations]
: [getChildAnimations, getAnimation];
return first().then(() => last());
}
else {
return Promise.all([getAnimation(), getChildAnimations(options.delay)]);
}
}
function animateChildren(visualElement, variant, delay = 0, delayChildren = 0, staggerChildren = 0, staggerDirection = 1, options) {
const animations = [];
const numChildren = visualElement.variantChildren.size;
const maxStaggerDuration = (numChildren - 1) * staggerChildren;
const delayIsFunction = typeof delayChildren === "function";
const generateStaggerDuration = delayIsFunction
? (i) => delayChildren(i, numChildren)
: // Support deprecated staggerChildren
staggerDirection === 1
? (i = 0) => i * staggerChildren
: (i = 0) => maxStaggerDuration - i * staggerChildren;
Array.from(visualElement.variantChildren)
.sort(sortByTreeOrder)
.forEach((child, i) => {
child.notify("AnimationStart", variant);
animations.push(animateVariant(child, variant, {
...options,
delay: delay +
(delayIsFunction ? 0 : delayChildren) +
generateStaggerDuration(i),
}).then(() => child.notify("AnimationComplete", variant)));
});
return Promise.all(animations);
}
function sortByTreeOrder(a, b) {
return a.sortNodePosition(b);
}
export { animateVariant, sortByTreeOrder };
@@ -1,26 +0,0 @@
import { resolveVariant } from '../../render/utils/resolve-dynamic-variants.mjs';
import { animateTarget } from './visual-element-target.mjs';
import { animateVariant } from './visual-element-variant.mjs';
function animateVisualElement(visualElement, definition, options = {}) {
visualElement.notify("AnimationStart", definition);
let animation;
if (Array.isArray(definition)) {
const animations = definition.map((variant) => animateVariant(visualElement, variant, options));
animation = Promise.all(animations);
}
else if (typeof definition === "string") {
animation = animateVariant(visualElement, definition, options);
}
else {
const resolvedDefinition = typeof definition === "function"
? resolveVariant(visualElement, definition, options.custom)
: definition;
animation = Promise.all(animateTarget(visualElement, resolvedDefinition, options));
}
return animation.then(() => {
visualElement.notify("AnimationComplete", definition);
});
}
export { animateVisualElement };
@@ -1,6 +0,0 @@
import { camelToDash } from '../../render/dom/utils/camel-to-dash.mjs';
const optimizedAppearDataId = "framerAppearId";
const optimizedAppearDataAttribute = "data-" + camelToDash(optimizedAppearDataId);
export { optimizedAppearDataAttribute, optimizedAppearDataId };
@@ -1,7 +0,0 @@
import { optimizedAppearDataAttribute } from './data-id.mjs';
function getOptimisedAppearId(visualElement) {
return visualElement.props[optimizedAppearDataAttribute];
}
export { getOptimisedAppearId };
@@ -1,38 +0,0 @@
import { appearAnimationStore } from './store.mjs';
import { appearStoreId } from './store-id.mjs';
function handoffOptimizedAppearAnimation(elementId, valueName, frame) {
const storeId = appearStoreId(elementId, valueName);
const optimisedAnimation = appearAnimationStore.get(storeId);
if (!optimisedAnimation) {
return null;
}
const { animation, startTime } = optimisedAnimation;
function cancelAnimation() {
window.MotionCancelOptimisedAnimation?.(elementId, valueName, frame);
}
/**
* We can cancel the animation once it's finished now that we've synced
* with Motion.
*
* Prefer onfinish over finished as onfinish is backwards compatible with
* older browsers.
*/
animation.onfinish = cancelAnimation;
if (startTime === null || window.MotionHandoffIsComplete?.(elementId)) {
/**
* If the startTime is null, this animation is the Paint Ready detection animation
* and we can cancel it immediately without handoff.
*
* Or if we've already handed off the animation then we're now interrupting it.
* In which case we need to cancel it.
*/
cancelAnimation();
return null;
}
else {
return startTime;
}
}
export { handoffOptimizedAppearAnimation };
-171
View File
@@ -1,171 +0,0 @@
import { startWaapiAnimation } from 'motion-dom';
import { noop } from 'motion-utils';
import { optimizedAppearDataId } from './data-id.mjs';
import { getOptimisedAppearId } from './get-appear-id.mjs';
import { handoffOptimizedAppearAnimation } from './handoff.mjs';
import { appearAnimationStore, appearComplete } from './store.mjs';
import { appearStoreId } from './store-id.mjs';
/**
* A single time to use across all animations to manually set startTime
* and ensure they're all in sync.
*/
let startFrameTime;
/**
* A dummy animation to detect when Chrome is ready to start
* painting the page and hold off from triggering the real animation
* until then. We only need one animation to detect paint ready.
*
* https://bugs.chromium.org/p/chromium/issues/detail?id=1406850
*/
let readyAnimation;
/**
* Keep track of animations that were suspended vs cancelled so we
* can easily resume them when we're done measuring layout.
*/
const suspendedAnimations = new Set();
function resumeSuspendedAnimations() {
suspendedAnimations.forEach((data) => {
data.animation.play();
data.animation.startTime = data.startTime;
});
suspendedAnimations.clear();
}
function startOptimizedAppearAnimation(element, name, keyframes, options, onReady) {
// Prevent optimised appear animations if Motion has already started animating.
if (window.MotionIsMounted) {
return;
}
const id = element.dataset[optimizedAppearDataId];
if (!id)
return;
window.MotionHandoffAnimation = handoffOptimizedAppearAnimation;
const storeId = appearStoreId(id, name);
if (!readyAnimation) {
readyAnimation = startWaapiAnimation(element, name, [keyframes[0], keyframes[0]],
/**
* 10 secs is basically just a super-safe duration to give Chrome
* long enough to get the animation ready.
*/
{ duration: 10000, ease: "linear" });
appearAnimationStore.set(storeId, {
animation: readyAnimation,
startTime: null,
});
/**
* If there's no readyAnimation then there's been no instantiation
* of handoff animations.
*/
window.MotionHandoffAnimation = handoffOptimizedAppearAnimation;
window.MotionHasOptimisedAnimation = (elementId, valueName) => {
if (!elementId)
return false;
/**
* Keep a map of elementIds that have started animating. We check
* via ID instead of Element because of hydration errors and
* pre-hydration checks. We also actively record IDs as they start
* animating rather than simply checking for data-appear-id as
* this attrbute might be present but not lead to an animation, for
* instance if the element's appear animation is on a different
* breakpoint.
*/
if (!valueName) {
return appearComplete.has(elementId);
}
const animationId = appearStoreId(elementId, valueName);
return Boolean(appearAnimationStore.get(animationId));
};
window.MotionHandoffMarkAsComplete = (elementId) => {
if (appearComplete.has(elementId)) {
appearComplete.set(elementId, true);
}
};
window.MotionHandoffIsComplete = (elementId) => {
return appearComplete.get(elementId) === true;
};
/**
* We only need to cancel transform animations as
* they're the ones that will interfere with the
* layout animation measurements.
*/
window.MotionCancelOptimisedAnimation = (elementId, valueName, frame, canResume) => {
const animationId = appearStoreId(elementId, valueName);
const data = appearAnimationStore.get(animationId);
if (!data)
return;
if (frame && canResume === undefined) {
/**
* Wait until the end of the subsequent frame to cancel the animation
* to ensure we don't remove the animation before the main thread has
* had a chance to resolve keyframes and render.
*/
frame.postRender(() => {
frame.postRender(() => {
data.animation.cancel();
});
});
}
else {
data.animation.cancel();
}
if (frame && canResume) {
suspendedAnimations.add(data);
frame.render(resumeSuspendedAnimations);
}
else {
appearAnimationStore.delete(animationId);
/**
* If there are no more animations left, we can remove the cancel function.
* This will let us know when we can stop checking for conflicting layout animations.
*/
if (!appearAnimationStore.size) {
window.MotionCancelOptimisedAnimation = undefined;
}
}
};
window.MotionCheckAppearSync = (visualElement, valueName, value) => {
const appearId = getOptimisedAppearId(visualElement);
if (!appearId)
return;
const valueIsOptimised = window.MotionHasOptimisedAnimation?.(appearId, valueName);
const externalAnimationValue = visualElement.props.values?.[valueName];
if (!valueIsOptimised || !externalAnimationValue)
return;
const removeSyncCheck = value.on("change", (latestValue) => {
if (externalAnimationValue.get() !== latestValue) {
window.MotionCancelOptimisedAnimation?.(appearId, valueName);
removeSyncCheck();
}
});
return removeSyncCheck;
};
}
const startAnimation = () => {
readyAnimation.cancel();
const appearAnimation = startWaapiAnimation(element, name, keyframes, options);
/**
* Record the time of the first started animation. We call performance.now() once
* here and once in handoff to ensure we're getting
* close to a frame-locked time. This keeps all animations in sync.
*/
if (startFrameTime === undefined) {
startFrameTime = performance.now();
}
appearAnimation.startTime = startFrameTime;
appearAnimationStore.set(storeId, {
animation: appearAnimation,
startTime: startFrameTime,
});
if (onReady)
onReady(appearAnimation);
};
appearComplete.set(id, false);
if (readyAnimation.ready) {
readyAnimation.ready.then(startAnimation).catch(noop);
}
else {
startAnimation();
}
}
export { startOptimizedAppearAnimation };
@@ -1,8 +0,0 @@
import { transformProps } from 'motion-dom';
const appearStoreId = (elementId, valueName) => {
const key = transformProps.has(valueName) ? "transform" : valueName;
return `${elementId}: ${key}`;
};
export { appearStoreId };
@@ -1,4 +0,0 @@
const appearAnimationStore = new Map();
const appearComplete = new Map();
export { appearAnimationStore, appearComplete };
-249
View File
@@ -1,249 +0,0 @@
import { isMotionValue, defaultOffset, isGenerator, createGeneratorEasing, fillOffset } from 'motion-dom';
import { progress, secondsToMilliseconds, invariant, getEasingForSegment } from 'motion-utils';
import { resolveSubjects } from '../animate/resolve-subjects.mjs';
import { calculateRepeatDuration } from './utils/calc-repeat-duration.mjs';
import { calcNextTime } from './utils/calc-time.mjs';
import { addKeyframes } from './utils/edit.mjs';
import { normalizeTimes } from './utils/normalize-times.mjs';
import { compareByTime } from './utils/sort.mjs';
const defaultSegmentEasing = "easeInOut";
const MAX_REPEAT = 20;
function createAnimationsFromSequence(sequence, { defaultTransition = {}, ...sequenceTransition } = {}, scope, generators) {
const defaultDuration = defaultTransition.duration || 0.3;
const animationDefinitions = new Map();
const sequences = new Map();
const elementCache = {};
const timeLabels = new Map();
let prevTime = 0;
let currentTime = 0;
let totalDuration = 0;
/**
* Build the timeline by mapping over the sequence array and converting
* the definitions into keyframes and offsets with absolute time values.
* These will later get converted into relative offsets in a second pass.
*/
for (let i = 0; i < sequence.length; i++) {
const segment = sequence[i];
/**
* If this is a timeline label, mark it and skip the rest of this iteration.
*/
if (typeof segment === "string") {
timeLabels.set(segment, currentTime);
continue;
}
else if (!Array.isArray(segment)) {
timeLabels.set(segment.name, calcNextTime(currentTime, segment.at, prevTime, timeLabels));
continue;
}
let [subject, keyframes, transition = {}] = segment;
/**
* If a relative or absolute time value has been specified we need to resolve
* it in relation to the currentTime.
*/
if (transition.at !== undefined) {
currentTime = calcNextTime(currentTime, transition.at, prevTime, timeLabels);
}
/**
* Keep track of the maximum duration in this definition. This will be
* applied to currentTime once the definition has been parsed.
*/
let maxDuration = 0;
const resolveValueSequence = (valueKeyframes, valueTransition, valueSequence, elementIndex = 0, numSubjects = 0) => {
const valueKeyframesAsList = keyframesAsList(valueKeyframes);
const { delay = 0, times = defaultOffset(valueKeyframesAsList), type = "keyframes", repeat, repeatType, repeatDelay = 0, ...remainingTransition } = valueTransition;
let { ease = defaultTransition.ease || "easeOut", duration } = valueTransition;
/**
* Resolve stagger() if defined.
*/
const calculatedDelay = typeof delay === "function"
? delay(elementIndex, numSubjects)
: delay;
/**
* If this animation should and can use a spring, generate a spring easing function.
*/
const numKeyframes = valueKeyframesAsList.length;
const createGenerator = isGenerator(type)
? type
: generators?.[type || "keyframes"];
if (numKeyframes <= 2 && createGenerator) {
/**
* As we're creating an easing function from a spring,
* ideally we want to generate it using the real distance
* between the two keyframes. However this isn't always
* possible - in these situations we use 0-100.
*/
let absoluteDelta = 100;
if (numKeyframes === 2 &&
isNumberKeyframesArray(valueKeyframesAsList)) {
const delta = valueKeyframesAsList[1] - valueKeyframesAsList[0];
absoluteDelta = Math.abs(delta);
}
const springTransition = { ...remainingTransition };
if (duration !== undefined) {
springTransition.duration = secondsToMilliseconds(duration);
}
const springEasing = createGeneratorEasing(springTransition, absoluteDelta, createGenerator);
ease = springEasing.ease;
duration = springEasing.duration;
}
duration ?? (duration = defaultDuration);
const startTime = currentTime + calculatedDelay;
/**
* If there's only one time offset of 0, fill in a second with length 1
*/
if (times.length === 1 && times[0] === 0) {
times[1] = 1;
}
/**
* Fill out if offset if fewer offsets than keyframes
*/
const remainder = times.length - valueKeyframesAsList.length;
remainder > 0 && fillOffset(times, remainder);
/**
* If only one value has been set, ie [1], push a null to the start of
* the keyframe array. This will let us mark a keyframe at this point
* that will later be hydrated with the previous value.
*/
valueKeyframesAsList.length === 1 &&
valueKeyframesAsList.unshift(null);
/**
* Handle repeat options
*/
if (repeat) {
invariant(repeat < MAX_REPEAT, "Repeat count too high, must be less than 20");
duration = calculateRepeatDuration(duration, repeat);
const originalKeyframes = [...valueKeyframesAsList];
const originalTimes = [...times];
ease = Array.isArray(ease) ? [...ease] : [ease];
const originalEase = [...ease];
for (let repeatIndex = 0; repeatIndex < repeat; repeatIndex++) {
valueKeyframesAsList.push(...originalKeyframes);
for (let keyframeIndex = 0; keyframeIndex < originalKeyframes.length; keyframeIndex++) {
times.push(originalTimes[keyframeIndex] + (repeatIndex + 1));
ease.push(keyframeIndex === 0
? "linear"
: getEasingForSegment(originalEase, keyframeIndex - 1));
}
}
normalizeTimes(times, repeat);
}
const targetTime = startTime + duration;
/**
* Add keyframes, mapping offsets to absolute time.
*/
addKeyframes(valueSequence, valueKeyframesAsList, ease, times, startTime, targetTime);
maxDuration = Math.max(calculatedDelay + duration, maxDuration);
totalDuration = Math.max(targetTime, totalDuration);
};
if (isMotionValue(subject)) {
const subjectSequence = getSubjectSequence(subject, sequences);
resolveValueSequence(keyframes, transition, getValueSequence("default", subjectSequence));
}
else {
const subjects = resolveSubjects(subject, keyframes, scope, elementCache);
const numSubjects = subjects.length;
/**
* For every element in this segment, process the defined values.
*/
for (let subjectIndex = 0; subjectIndex < numSubjects; subjectIndex++) {
/**
* Cast necessary, but we know these are of this type
*/
keyframes = keyframes;
transition = transition;
const thisSubject = subjects[subjectIndex];
const subjectSequence = getSubjectSequence(thisSubject, sequences);
for (const key in keyframes) {
resolveValueSequence(keyframes[key], getValueTransition(transition, key), getValueSequence(key, subjectSequence), subjectIndex, numSubjects);
}
}
}
prevTime = currentTime;
currentTime += maxDuration;
}
/**
* For every element and value combination create a new animation.
*/
sequences.forEach((valueSequences, element) => {
for (const key in valueSequences) {
const valueSequence = valueSequences[key];
/**
* Arrange all the keyframes in ascending time order.
*/
valueSequence.sort(compareByTime);
const keyframes = [];
const valueOffset = [];
const valueEasing = [];
/**
* For each keyframe, translate absolute times into
* relative offsets based on the total duration of the timeline.
*/
for (let i = 0; i < valueSequence.length; i++) {
const { at, value, easing } = valueSequence[i];
keyframes.push(value);
valueOffset.push(progress(0, totalDuration, at));
valueEasing.push(easing || "easeOut");
}
/**
* If the first keyframe doesn't land on offset: 0
* provide one by duplicating the initial keyframe. This ensures
* it snaps to the first keyframe when the animation starts.
*/
if (valueOffset[0] !== 0) {
valueOffset.unshift(0);
keyframes.unshift(keyframes[0]);
valueEasing.unshift(defaultSegmentEasing);
}
/**
* If the last keyframe doesn't land on offset: 1
* provide one with a null wildcard value. This will ensure it
* stays static until the end of the animation.
*/
if (valueOffset[valueOffset.length - 1] !== 1) {
valueOffset.push(1);
keyframes.push(null);
}
if (!animationDefinitions.has(element)) {
animationDefinitions.set(element, {
keyframes: {},
transition: {},
});
}
const definition = animationDefinitions.get(element);
definition.keyframes[key] = keyframes;
definition.transition[key] = {
...defaultTransition,
duration: totalDuration,
ease: valueEasing,
times: valueOffset,
...sequenceTransition,
};
}
});
return animationDefinitions;
}
function getSubjectSequence(subject, sequences) {
!sequences.has(subject) && sequences.set(subject, {});
return sequences.get(subject);
}
function getValueSequence(name, sequences) {
if (!sequences[name])
sequences[name] = [];
return sequences[name];
}
function keyframesAsList(keyframes) {
return Array.isArray(keyframes) ? keyframes : [keyframes];
}
function getValueTransition(transition, key) {
return transition && transition[key]
? {
...transition,
...transition[key],
}
: { ...transition };
}
const isNumber = (keyframe) => typeof keyframe === "number";
const isNumberKeyframesArray = (keyframes) => keyframes.every(isNumber);
export { createAnimationsFromSequence, getValueTransition };
@@ -1,5 +0,0 @@
function calculateRepeatDuration(duration, repeat, _repeatDelay) {
return duration * (repeat + 1);
}
export { calculateRepeatDuration };
@@ -1,23 +0,0 @@
/**
* Given a absolute or relative time definition and current/prev time state of the sequence,
* calculate an absolute time for the next keyframes.
*/
function calcNextTime(current, next, prev, labels) {
if (typeof next === "number") {
return next;
}
else if (next.startsWith("-") || next.startsWith("+")) {
return Math.max(0, current + parseFloat(next));
}
else if (next === "<") {
return prev;
}
else if (next.startsWith("<")) {
return Math.max(0, prev + parseFloat(next.slice(1)));
}
else {
return labels.get(next) ?? current;
}
}
export { calcNextTime };
-30
View File
@@ -1,30 +0,0 @@
import { mixNumber } from 'motion-dom';
import { getEasingForSegment, removeItem } from 'motion-utils';
function eraseKeyframes(sequence, startTime, endTime) {
for (let i = 0; i < sequence.length; i++) {
const keyframe = sequence[i];
if (keyframe.at > startTime && keyframe.at < endTime) {
removeItem(sequence, keyframe);
// If we remove this item we have to push the pointer back one
i--;
}
}
}
function addKeyframes(sequence, keyframes, easing, offset, startTime, endTime) {
/**
* Erase every existing value between currentTime and targetTime,
* this will essentially splice this timeline into any currently
* defined ones.
*/
eraseKeyframes(sequence, startTime, endTime);
for (let i = 0; i < keyframes.length; i++) {
sequence.push({
value: keyframes[i],
at: mixNumber(startTime, endTime, offset[i]),
easing: getEasingForSegment(easing, i),
});
}
}
export { addKeyframes, eraseKeyframes };
@@ -1,13 +0,0 @@
/**
* Take an array of times that represent repeated keyframes. For instance
* if we have original times of [0, 0.5, 1] then our repeated times will
* be [0, 0.5, 1, 1, 1.5, 2]. Loop over the times and scale them back
* down to a 0-1 scale.
*/
function normalizeTimes(times, repeat) {
for (let i = 0; i < times.length; i++) {
times[i] = times[i] / (repeat + 1);
}
}
export { normalizeTimes };
-14
View File
@@ -1,14 +0,0 @@
function compareByTime(a, b) {
if (a.at === b.at) {
if (a.value === null)
return 1;
if (b.value === null)
return -1;
return 0;
}
else {
return a.at - b.at;
}
}
export { compareByTime };
@@ -1,44 +0,0 @@
import { isSVGElement, isSVGSVGElement } from 'motion-dom';
import { HTMLVisualElement } from '../../render/html/HTMLVisualElement.mjs';
import { ObjectVisualElement } from '../../render/object/ObjectVisualElement.mjs';
import { visualElementStore } from '../../render/store.mjs';
import { SVGVisualElement } from '../../render/svg/SVGVisualElement.mjs';
function createDOMVisualElement(element) {
const options = {
presenceContext: null,
props: {},
visualState: {
renderState: {
transform: {},
transformOrigin: {},
style: {},
vars: {},
attrs: {},
},
latestValues: {},
},
};
const node = isSVGElement(element) && !isSVGSVGElement(element)
? new SVGVisualElement(options)
: new HTMLVisualElement(options);
node.mount(element);
visualElementStore.set(element, node);
}
function createObjectVisualElement(subject) {
const options = {
presenceContext: null,
props: {},
visualState: {
renderState: {
output: {},
},
latestValues: {},
},
};
const node = new ObjectVisualElement(options);
node.mount(subject);
visualElementStore.set(subject, node);
}
export { createDOMVisualElement, createObjectVisualElement };
@@ -1,40 +0,0 @@
import { transformProps } from 'motion-dom';
const underDampedSpring = {
type: "spring",
stiffness: 500,
damping: 25,
restSpeed: 10,
};
const criticallyDampedSpring = (target) => ({
type: "spring",
stiffness: 550,
damping: target === 0 ? 2 * Math.sqrt(550) : 30,
restSpeed: 10,
});
const keyframesTransition = {
type: "keyframes",
duration: 0.8,
};
/**
* Default easing curve is a slightly shallower version of
* the default browser easing curve.
*/
const ease = {
type: "keyframes",
ease: [0.25, 0.1, 0.35, 1],
duration: 0.3,
};
const getDefaultTransition = (valueKey, { keyframes }) => {
if (keyframes.length > 2) {
return keyframesTransition;
}
else if (transformProps.has(valueKey)) {
return valueKey.startsWith("scale")
? criticallyDampedSpring(keyframes[1])
: underDampedSpring;
}
return ease;
};
export { getDefaultTransition };
@@ -1,7 +0,0 @@
function isAnimationControls(v) {
return (v !== null &&
typeof v === "object" &&
typeof v.start === "function");
}
export { isAnimationControls };
@@ -1,5 +0,0 @@
function isDOMKeyframes(keyframes) {
return typeof keyframes === "object" && !Array.isArray(keyframes);
}
export { isDOMKeyframes };
@@ -1,5 +0,0 @@
const isKeyframesTarget = (v) => {
return Array.isArray(v);
};
export { isKeyframesTarget };
@@ -1,10 +0,0 @@
/**
* Decide whether a transition is defined on a given Transition.
* This filters out orchestration options and returns true
* if any options are left.
*/
function isTransitionDefined({ when, delay: _delay, delayChildren, staggerChildren, staggerDirection, repeat, repeatType, repeatDelay, from, elapsed, ...transition }) {
return !!Object.keys(transition).length;
}
export { isTransitionDefined };
-3
View File
@@ -1,3 +0,0 @@
"use client";
export { MotionA as a, MotionAbbr as abbr, MotionAddress as address, MotionAnimate as animate, MotionArea as area, MotionArticle as article, MotionAside as aside, MotionAudio as audio, MotionB as b, MotionBase as base, MotionBdi as bdi, MotionBdo as bdo, MotionBig as big, MotionBlockquote as blockquote, MotionBody as body, MotionButton as button, MotionCanvas as canvas, MotionCaption as caption, MotionCircle as circle, MotionCite as cite, MotionClipPath as clipPath, MotionCode as code, MotionCol as col, MotionColgroup as colgroup, MotionData as data, MotionDatalist as datalist, MotionDd as dd, MotionDefs as defs, MotionDel as del, MotionDesc as desc, MotionDetails as details, MotionDfn as dfn, MotionDialog as dialog, MotionDiv as div, MotionDl as dl, MotionDt as dt, MotionEllipse as ellipse, MotionEm as em, MotionEmbed as embed, MotionFeBlend as feBlend, MotionFeColorMatrix as feColorMatrix, MotionFeComponentTransfer as feComponentTransfer, MotionFeComposite as feComposite, MotionFeConvolveMatrix as feConvolveMatrix, MotionFeDiffuseLighting as feDiffuseLighting, MotionFeDisplacementMap as feDisplacementMap, MotionFeDistantLight as feDistantLight, MotionFeDropShadow as feDropShadow, MotionFeFlood as feFlood, MotionFeFuncA as feFuncA, MotionFeFuncB as feFuncB, MotionFeFuncG as feFuncG, MotionFeFuncR as feFuncR, MotionFeGaussianBlur as feGaussianBlur, MotionFeImage as feImage, MotionFeMerge as feMerge, MotionFeMergeNode as feMergeNode, MotionFeMorphology as feMorphology, MotionFeOffset as feOffset, MotionFePointLight as fePointLight, MotionFeSpecularLighting as feSpecularLighting, MotionFeSpotLight as feSpotLight, MotionFeTile as feTile, MotionFeTurbulence as feTurbulence, MotionFieldset as fieldset, MotionFigcaption as figcaption, MotionFigure as figure, MotionFilter as filter, MotionFooter as footer, MotionForeignObject as foreignObject, MotionForm as form, MotionG as g, MotionH1 as h1, MotionH2 as h2, MotionH3 as h3, MotionH4 as h4, MotionH5 as h5, MotionH6 as h6, MotionHead as head, MotionHeader as header, MotionHgroup as hgroup, MotionHr as hr, MotionHtml as html, MotionI as i, MotionIframe as iframe, MotionImage as image, MotionImg as img, MotionInput as input, MotionIns as ins, MotionKbd as kbd, MotionKeygen as keygen, MotionLabel as label, MotionLegend as legend, MotionLi as li, MotionLine as line, MotionLinearGradient as linearGradient, MotionLink as link, MotionMain as main, MotionMap as map, MotionMark as mark, MotionMarker as marker, MotionMask as mask, MotionMenu as menu, MotionMenuitem as menuitem, MotionMetadata as metadata, MotionMeter as meter, MotionNav as nav, MotionObject as object, MotionOl as ol, MotionOptgroup as optgroup, MotionOption as option, MotionOutput as output, MotionP as p, MotionParam as param, MotionPath as path, MotionPattern as pattern, MotionPicture as picture, MotionPolygon as polygon, MotionPolyline as polyline, MotionPre as pre, MotionProgress as progress, MotionQ as q, MotionRadialGradient as radialGradient, MotionRect as rect, MotionRp as rp, MotionRt as rt, MotionRuby as ruby, MotionS as s, MotionSamp as samp, MotionScript as script, MotionSection as section, MotionSelect as select, MotionSmall as small, MotionSource as source, MotionSpan as span, MotionStop as stop, MotionStrong as strong, MotionStyle as style, MotionSub as sub, MotionSummary as summary, MotionSup as sup, MotionSvg as svg, MotionSymbol as symbol, MotionTable as table, MotionTbody as tbody, MotionTd as td, MotionText as text, MotionTextPath as textPath, MotionTextarea as textarea, MotionTfoot as tfoot, MotionTh as th, MotionThead as thead, MotionTime as time, MotionTitle as title, MotionTr as tr, MotionTrack as track, MotionTspan as tspan, MotionU as u, MotionUl as ul, MotionUse as use, MotionVideo as video, MotionView as view, MotionWbr as wbr, MotionWebview as webview } from './render/components/motion/elements.mjs';
export { createMotionComponent as create } from './render/components/motion/create.mjs';
@@ -1,89 +0,0 @@
"use client";
import { jsx } from 'react/jsx-runtime';
import { isHTMLElement } from 'motion-dom';
import * as React from 'react';
import { useId, useRef, useContext, useInsertionEffect } from 'react';
import { MotionConfigContext } from '../../context/MotionConfigContext.mjs';
/**
* Measurement functionality has to be within a separate component
* to leverage snapshot lifecycle.
*/
class PopChildMeasure extends React.Component {
getSnapshotBeforeUpdate(prevProps) {
const element = this.props.childRef.current;
if (element && prevProps.isPresent && !this.props.isPresent) {
const parent = element.offsetParent;
const parentWidth = isHTMLElement(parent)
? parent.offsetWidth || 0
: 0;
const size = this.props.sizeRef.current;
size.height = element.offsetHeight || 0;
size.width = element.offsetWidth || 0;
size.top = element.offsetTop;
size.left = element.offsetLeft;
size.right = parentWidth - size.width - size.left;
}
return null;
}
/**
* Required with getSnapshotBeforeUpdate to stop React complaining.
*/
componentDidUpdate() { }
render() {
return this.props.children;
}
}
function PopChild({ children, isPresent, anchorX, root }) {
const id = useId();
const ref = useRef(null);
const size = useRef({
width: 0,
height: 0,
top: 0,
left: 0,
right: 0,
});
const { nonce } = useContext(MotionConfigContext);
/**
* We create and inject a style block so we can apply this explicit
* sizing in a non-destructive manner by just deleting the style block.
*
* We can't apply size via render as the measurement happens
* in getSnapshotBeforeUpdate (post-render), likewise if we apply the
* styles directly on the DOM node, we might be overwriting
* styles set via the style prop.
*/
useInsertionEffect(() => {
const { width, height, top, left, right } = size.current;
if (isPresent || !ref.current || !width || !height)
return;
const x = anchorX === "left" ? `left: ${left}` : `right: ${right}`;
ref.current.dataset.motionPopId = id;
const style = document.createElement("style");
if (nonce)
style.nonce = nonce;
const parent = root ?? document.head;
parent.appendChild(style);
if (style.sheet) {
style.sheet.insertRule(`
[data-motion-pop-id="${id}"] {
position: absolute !important;
width: ${width}px !important;
height: ${height}px !important;
${x}px !important;
top: ${top}px !important;
}
`);
}
return () => {
parent.removeChild(style);
if (parent.contains(style)) {
parent.removeChild(style);
}
};
}, [isPresent]);
return (jsx(PopChildMeasure, { isPresent: isPresent, childRef: ref, sizeRef: size, children: React.cloneElement(children, { ref }) }));
}
export { PopChild };
@@ -1,64 +0,0 @@
"use client";
import { jsx } from 'react/jsx-runtime';
import * as React from 'react';
import { useId, useMemo } from 'react';
import { PresenceContext } from '../../context/PresenceContext.mjs';
import { useConstant } from '../../utils/use-constant.mjs';
import { PopChild } from './PopChild.mjs';
const PresenceChild = ({ children, initial, isPresent, onExitComplete, custom, presenceAffectsLayout, mode, anchorX, root }) => {
const presenceChildren = useConstant(newChildrenMap);
const id = useId();
let isReusedContext = true;
let context = useMemo(() => {
isReusedContext = false;
return {
id,
initial,
isPresent,
custom,
onExitComplete: (childId) => {
presenceChildren.set(childId, true);
for (const isComplete of presenceChildren.values()) {
if (!isComplete)
return; // can stop searching when any is incomplete
}
onExitComplete && onExitComplete();
},
register: (childId) => {
presenceChildren.set(childId, false);
return () => presenceChildren.delete(childId);
},
};
}, [isPresent, presenceChildren, onExitComplete]);
/**
* If the presence of a child affects the layout of the components around it,
* we want to make a new context value to ensure they get re-rendered
* so they can detect that layout change.
*/
if (presenceAffectsLayout && isReusedContext) {
context = { ...context };
}
useMemo(() => {
presenceChildren.forEach((_, key) => presenceChildren.set(key, false));
}, [isPresent]);
/**
* If there's no `motion` components to fire exit animations, we want to remove this
* component immediately.
*/
React.useEffect(() => {
!isPresent &&
!presenceChildren.size &&
onExitComplete &&
onExitComplete();
}, [isPresent]);
if (mode === "popLayout") {
children = (jsx(PopChild, { isPresent: isPresent, anchorX: anchorX, root: root, children: children }));
}
return (jsx(PresenceContext.Provider, { value: context, children: children }));
};
function newChildrenMap() {
return new Map();
}
export { PresenceChild };
-166
View File
@@ -1,166 +0,0 @@
"use client";
import { jsx, Fragment } from 'react/jsx-runtime';
import { useMemo, useRef, useState, useContext } from 'react';
import { LayoutGroupContext } from '../../context/LayoutGroupContext.mjs';
import { useConstant } from '../../utils/use-constant.mjs';
import { useIsomorphicLayoutEffect } from '../../utils/use-isomorphic-effect.mjs';
import { PresenceChild } from './PresenceChild.mjs';
import { usePresence } from './use-presence.mjs';
import { onlyElements, getChildKey } from './utils.mjs';
/**
* `AnimatePresence` enables the animation of components that have been removed from the tree.
*
* When adding/removing more than a single child, every child **must** be given a unique `key` prop.
*
* Any `motion` components that have an `exit` property defined will animate out when removed from
* the tree.
*
* ```jsx
* import { motion, AnimatePresence } from 'framer-motion'
*
* export const Items = ({ items }) => (
* <AnimatePresence>
* {items.map(item => (
* <motion.div
* key={item.id}
* initial={{ opacity: 0 }}
* animate={{ opacity: 1 }}
* exit={{ opacity: 0 }}
* />
* ))}
* </AnimatePresence>
* )
* ```
*
* You can sequence exit animations throughout a tree using variants.
*
* If a child contains multiple `motion` components with `exit` props, it will only unmount the child
* once all `motion` components have finished animating out. Likewise, any components using
* `usePresence` all need to call `safeToRemove`.
*
* @public
*/
const AnimatePresence = ({ children, custom, initial = true, onExitComplete, presenceAffectsLayout = true, mode = "sync", propagate = false, anchorX = "left", root }) => {
const [isParentPresent, safeToRemove] = usePresence(propagate);
/**
* Filter any children that aren't ReactElements. We can only track components
* between renders with a props.key.
*/
const presentChildren = useMemo(() => onlyElements(children), [children]);
/**
* Track the keys of the currently rendered children. This is used to
* determine which children are exiting.
*/
const presentKeys = propagate && !isParentPresent ? [] : presentChildren.map(getChildKey);
/**
* If `initial={false}` we only want to pass this to components in the first render.
*/
const isInitialRender = useRef(true);
/**
* A ref containing the currently present children. When all exit animations
* are complete, we use this to re-render the component with the latest children
* *committed* rather than the latest children *rendered*.
*/
const pendingPresentChildren = useRef(presentChildren);
/**
* Track which exiting children have finished animating out.
*/
const exitComplete = useConstant(() => new Map());
/**
* Save children to render as React state. To ensure this component is concurrent-safe,
* we check for exiting children via an effect.
*/
const [diffedChildren, setDiffedChildren] = useState(presentChildren);
const [renderedChildren, setRenderedChildren] = useState(presentChildren);
useIsomorphicLayoutEffect(() => {
isInitialRender.current = false;
pendingPresentChildren.current = presentChildren;
/**
* Update complete status of exiting children.
*/
for (let i = 0; i < renderedChildren.length; i++) {
const key = getChildKey(renderedChildren[i]);
if (!presentKeys.includes(key)) {
if (exitComplete.get(key) !== true) {
exitComplete.set(key, false);
}
}
else {
exitComplete.delete(key);
}
}
}, [renderedChildren, presentKeys.length, presentKeys.join("-")]);
const exitingChildren = [];
if (presentChildren !== diffedChildren) {
let nextChildren = [...presentChildren];
/**
* Loop through all the currently rendered components and decide which
* are exiting.
*/
for (let i = 0; i < renderedChildren.length; i++) {
const child = renderedChildren[i];
const key = getChildKey(child);
if (!presentKeys.includes(key)) {
nextChildren.splice(i, 0, child);
exitingChildren.push(child);
}
}
/**
* If we're in "wait" mode, and we have exiting children, we want to
* only render these until they've all exited.
*/
if (mode === "wait" && exitingChildren.length) {
nextChildren = exitingChildren;
}
setRenderedChildren(onlyElements(nextChildren));
setDiffedChildren(presentChildren);
/**
* Early return to ensure once we've set state with the latest diffed
* children, we can immediately re-render.
*/
return null;
}
if (process.env.NODE_ENV !== "production" &&
mode === "wait" &&
renderedChildren.length > 1) {
console.warn(`You're attempting to animate multiple children within AnimatePresence, but its mode is set to "wait". This will lead to odd visual behaviour.`);
}
/**
* If we've been provided a forceRender function by the LayoutGroupContext,
* we can use it to force a re-render amongst all surrounding components once
* all components have finished animating out.
*/
const { forceRender } = useContext(LayoutGroupContext);
return (jsx(Fragment, { children: renderedChildren.map((child) => {
const key = getChildKey(child);
const isPresent = propagate && !isParentPresent
? false
: presentChildren === renderedChildren ||
presentKeys.includes(key);
const onExit = () => {
if (exitComplete.has(key)) {
exitComplete.set(key, true);
}
else {
return;
}
let isEveryExitComplete = true;
exitComplete.forEach((isExitComplete) => {
if (!isExitComplete)
isEveryExitComplete = false;
});
if (isEveryExitComplete) {
forceRender?.();
setRenderedChildren(pendingPresentChildren.current);
propagate && safeToRemove?.();
onExitComplete && onExitComplete();
}
};
return (jsx(PresenceChild, { isPresent: isPresent, initial: !isInitialRender.current || initial
? undefined
: false, custom: custom, presenceAffectsLayout: presenceAffectsLayout, mode: mode, root: root, onExitComplete: isPresent ? undefined : onExit, anchorX: anchorX, children: child }, key));
}) }));
};
export { AnimatePresence };
@@ -1,9 +0,0 @@
import { useContext } from 'react';
import { PresenceContext } from '../../context/PresenceContext.mjs';
function usePresenceData() {
const context = useContext(PresenceContext);
return context ? context.custom : undefined;
}
export { usePresenceData };
@@ -1,70 +0,0 @@
import { useContext, useId, useEffect, useCallback } from 'react';
import { PresenceContext } from '../../context/PresenceContext.mjs';
/**
* When a component is the child of `AnimatePresence`, it can use `usePresence`
* to access information about whether it's still present in the React tree.
*
* ```jsx
* import { usePresence } from "framer-motion"
*
* export const Component = () => {
* const [isPresent, safeToRemove] = usePresence()
*
* useEffect(() => {
* !isPresent && setTimeout(safeToRemove, 1000)
* }, [isPresent])
*
* return <div />
* }
* ```
*
* If `isPresent` is `false`, it means that a component has been removed the tree, but
* `AnimatePresence` won't really remove it until `safeToRemove` has been called.
*
* @public
*/
function usePresence(subscribe = true) {
const context = useContext(PresenceContext);
if (context === null)
return [true, null];
const { isPresent, onExitComplete, register } = context;
// It's safe to call the following hooks conditionally (after an early return) because the context will always
// either be null or non-null for the lifespan of the component.
const id = useId();
useEffect(() => {
if (subscribe) {
return register(id);
}
}, [subscribe]);
const safeToRemove = useCallback(() => subscribe && onExitComplete && onExitComplete(id), [id, onExitComplete, subscribe]);
return !isPresent && onExitComplete ? [false, safeToRemove] : [true];
}
/**
* Similar to `usePresence`, except `useIsPresent` simply returns whether or not the component is present.
* There is no `safeToRemove` function.
*
* ```jsx
* import { useIsPresent } from "framer-motion"
*
* export const Component = () => {
* const isPresent = useIsPresent()
*
* useEffect(() => {
* !isPresent && console.log("I've been removed!")
* }, [isPresent])
*
* return <div />
* }
* ```
*
* @public
*/
function useIsPresent() {
return isPresent(useContext(PresenceContext));
}
function isPresent(context) {
return context === null ? true : context.isPresent;
}
export { isPresent, useIsPresent, usePresence };

Some files were not shown because too many files have changed in this diff Show More