animate-vector 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -0
- package/package.json +26 -0
- package/svg-animator.js +480 -0
package/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# animate-vector
|
|
2
|
+
|
|
3
|
+
A React component that animates SVG paths by drawing them sequentially. It supports loading SVGs from a string or a URL and allows for customizable animation timings.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install animate-vector
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```jsx
|
|
14
|
+
import React from 'react';
|
|
15
|
+
import SVGPathAnimator from 'animate-vector';
|
|
16
|
+
|
|
17
|
+
const App = () => {
|
|
18
|
+
return (
|
|
19
|
+
<div>
|
|
20
|
+
{/* Load from URL */}
|
|
21
|
+
<SVGPathAnimator
|
|
22
|
+
src="/path/to/image.svg"
|
|
23
|
+
duration={3000}
|
|
24
|
+
stagger={100}
|
|
25
|
+
/>
|
|
26
|
+
|
|
27
|
+
{/* Or using an SVG string */}
|
|
28
|
+
<SVGPathAnimator
|
|
29
|
+
svgString="<svg>...</svg>"
|
|
30
|
+
/>
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
};
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Props
|
|
37
|
+
|
|
38
|
+
| Prop | Type | Default | Description |
|
|
39
|
+
|------|------|---------|-------------|
|
|
40
|
+
| `src` | `string` | - | URL to the SVG file. |
|
|
41
|
+
| `svgString` | `string` | - | Raw SVG content string. |
|
|
42
|
+
| `duration` | `number` | `2000` | Duration of the drawing animation per path (ms). |
|
|
43
|
+
| `fillDelay` | `number` | `250` | Delay before the fill animation starts after drawing (ms). |
|
|
44
|
+
| `reverseDuration` | `number` | `300` | Duration of the reverse (undraw) animation (ms). |
|
|
45
|
+
| `stagger` | `number` | `0.25` | Delay between starting animation for each path (ms). |
|
|
46
|
+
| `enableReverse` | `boolean` | `true` | Enables double-click/touch interaction to reverse the animation. |
|
|
47
|
+
|
|
48
|
+
## SVG Data Attributes
|
|
49
|
+
|
|
50
|
+
You can also embed configuration directly into your SVG file using `data-` attributes on the root `<svg>` element. These values override the props passed to the component.
|
|
51
|
+
|
|
52
|
+
```xml
|
|
53
|
+
<svg
|
|
54
|
+
viewBox="0 0 100 100"
|
|
55
|
+
data-duration="3000"
|
|
56
|
+
data-stagger="100"
|
|
57
|
+
data-fill-delay="500"
|
|
58
|
+
data-enable-reverse="false"
|
|
59
|
+
>
|
|
60
|
+
...
|
|
61
|
+
</svg>
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Features
|
|
65
|
+
|
|
66
|
+
- **Automatic Path Detection**: Finds and animates all paths in the SVG.
|
|
67
|
+
- **Interaction**: Double-click or touch to reverse and restart the animation (configurable).
|
|
68
|
+
- **Reduced Motion**: Respects the user's `prefers-reduced-motion` setting.
|
|
69
|
+
|
|
70
|
+
## Author
|
|
71
|
+
|
|
72
|
+
**Oliver R. Fox**
|
|
73
|
+
[https://ofox.co.uk](https://ofox.co.uk)
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "animate-vector",
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"description": "A React component to animate SVG paths",
|
|
5
|
+
"main": "svg-animator.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"react",
|
|
11
|
+
"svg",
|
|
12
|
+
"animation",
|
|
13
|
+
"path",
|
|
14
|
+
"drawing"
|
|
15
|
+
],
|
|
16
|
+
"author": "Oliver R. Fox <https://ofox.co.uk>",
|
|
17
|
+
"homepage": "https://ofox.co.uk",
|
|
18
|
+
"license": "ISC",
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"react": ">=16.8.0",
|
|
21
|
+
"react-dom": ">=16.8.0"
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
}
|
|
26
|
+
}
|
package/svg-animator.js
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
import React, {useRef, useEffect, useState} from 'react';
|
|
2
|
+
import './landing.css';
|
|
3
|
+
|
|
4
|
+
function getInheritedStyleOrAttribute(node, propertyName) {
|
|
5
|
+
let currentNode = node;
|
|
6
|
+
// Traverse up from the current node until the SVG root or the attribute/style is found
|
|
7
|
+
while (currentNode && currentNode.nodeName && currentNode.nodeName.toLowerCase() !== 'svg') {
|
|
8
|
+
if (typeof currentNode.getAttribute === 'function') { // Ensure it's an element node
|
|
9
|
+
// Check for direct attribute first
|
|
10
|
+
const directAttrValue = currentNode.getAttribute(propertyName);
|
|
11
|
+
if (directAttrValue !== null && directAttrValue.toLowerCase() !== 'inherit') {
|
|
12
|
+
return directAttrValue;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Check for inline style attribute if direct attribute wasn't found or was 'inherit'
|
|
16
|
+
const styleAttr = currentNode.getAttribute('style');
|
|
17
|
+
if (styleAttr) {
|
|
18
|
+
// Simple parsing for the specific property
|
|
19
|
+
const declarations = styleAttr.split(';');
|
|
20
|
+
for (const declaration of declarations) {
|
|
21
|
+
// Split only on the first colon to handle values like data URIs or complex gradients
|
|
22
|
+
const parts = declaration.split(/:(.+)/);
|
|
23
|
+
if (parts.length === 3) { // Ensure we have a property and a value part
|
|
24
|
+
const prop = parts[0];
|
|
25
|
+
const value = parts[1]; // The rest of the string after the first colon
|
|
26
|
+
if (prop && value && prop.trim() === propertyName) {
|
|
27
|
+
const trimmedValue = value.trim();
|
|
28
|
+
if (trimmedValue.toLowerCase() !== 'inherit') {
|
|
29
|
+
return trimmedValue;
|
|
30
|
+
}
|
|
31
|
+
// If 'inherit' is found in style, break style parsing for this node and continue search upwards
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// Move to parent if not found or if 'inherit' was specified
|
|
39
|
+
if (!currentNode.parentNode) break;
|
|
40
|
+
currentNode = currentNode.parentNode;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Check SVG root last (as a fallback) - handles attributes/styles set directly on the <svg> tag
|
|
44
|
+
if (currentNode && currentNode.nodeName && currentNode.nodeName.toLowerCase() === 'svg') {
|
|
45
|
+
if (typeof currentNode.getAttribute === 'function') {
|
|
46
|
+
// Check direct attribute on SVG root
|
|
47
|
+
const svgRootAttrValue = currentNode.getAttribute(propertyName);
|
|
48
|
+
if (svgRootAttrValue !== null && svgRootAttrValue.toLowerCase() !== 'inherit') {
|
|
49
|
+
return svgRootAttrValue;
|
|
50
|
+
}
|
|
51
|
+
// Check style attribute on SVG root
|
|
52
|
+
const svgRootStyleAttr = currentNode.getAttribute('style');
|
|
53
|
+
if (svgRootStyleAttr) {
|
|
54
|
+
const declarations = svgRootStyleAttr.split(';');
|
|
55
|
+
for (const declaration of declarations) {
|
|
56
|
+
const parts = declaration.split(/:(.+)/);
|
|
57
|
+
if (parts.length === 3) {
|
|
58
|
+
const prop = parts[0];
|
|
59
|
+
const value = parts[1];
|
|
60
|
+
if (prop && value && prop.trim() === propertyName) {
|
|
61
|
+
const trimmedValue = value.trim();
|
|
62
|
+
if (trimmedValue.toLowerCase() !== 'inherit') {
|
|
63
|
+
return trimmedValue;
|
|
64
|
+
}
|
|
65
|
+
break; // Stop checking styles on SVG root if 'inherit'
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return null; // Attribute/style not found in the hierarchy up to the SVG element
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Helper function to optimise path data by rounding numbers to one decimal place
|
|
77
|
+
function optimizePathData(d) {
|
|
78
|
+
if (!d) return '';
|
|
79
|
+
// Regex to find commands (letters) and numbers (including decimals and negatives)
|
|
80
|
+
const parts = d.match(/[a-df-zA-DF-Z]|[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?/g);
|
|
81
|
+
if (!parts) return d; // Return original if parsing fails
|
|
82
|
+
|
|
83
|
+
let optimizedD = "";
|
|
84
|
+
let currentCommand = "";
|
|
85
|
+
|
|
86
|
+
for (const part of parts) {
|
|
87
|
+
if (/[a-zA-Z]/.test(part)) { // If it's a command letter
|
|
88
|
+
currentCommand = part;
|
|
89
|
+
optimizedD += currentCommand;
|
|
90
|
+
} else { // If it's a number (or part of a number sequence like "10,20")
|
|
91
|
+
const numbers = part.split(/[\s,]+/).filter(Boolean);
|
|
92
|
+
const roundedNumbers = numbers.map(numStr => {
|
|
93
|
+
const num = parseFloat(numStr);
|
|
94
|
+
if (!isNaN(num)) {
|
|
95
|
+
return (Math.round(num * 10) / 10).toString(); // Round to 1 decimal place
|
|
96
|
+
}
|
|
97
|
+
return numStr;
|
|
98
|
+
});
|
|
99
|
+
optimizedD += roundedNumbers.join(' ');
|
|
100
|
+
if (optimizedD.length > 0 && !/[a-zA-Z]$/.test(optimizedD.slice(-1))) {
|
|
101
|
+
optimizedD += " ";
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return optimizedD.trim();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
// Component to parse and animate SVG paths
|
|
110
|
+
const SVGPathAnimator = ({
|
|
111
|
+
svgString,
|
|
112
|
+
src,
|
|
113
|
+
duration = 2000,
|
|
114
|
+
fillDelay = 250,
|
|
115
|
+
reverseDuration = 300,
|
|
116
|
+
stagger = 0.25,
|
|
117
|
+
enableReverse = true
|
|
118
|
+
}) => {
|
|
119
|
+
const [internalSvgString, setInternalSvgString] = useState(null);
|
|
120
|
+
const [pathsData, setPathsData] = useState([]);
|
|
121
|
+
const [svgAttributes, setSvgAttributes] = useState({});
|
|
122
|
+
const [animationKey, setAnimationKey] = useState(Date.now());
|
|
123
|
+
const [isReversing, setIsReversing] = useState(false);
|
|
124
|
+
const [isAnimating, setIsAnimating] = useState(false);
|
|
125
|
+
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
|
|
126
|
+
|
|
127
|
+
const lastTapTimeRef = useRef(0);
|
|
128
|
+
const lastTapTargetRef = useRef(null);
|
|
129
|
+
const reverseAnimationTimeoutRef = useRef(null);
|
|
130
|
+
const animationCompletionTimeoutRef = useRef(null);
|
|
131
|
+
|
|
132
|
+
const configRef = useRef({
|
|
133
|
+
duration,
|
|
134
|
+
fillDelay,
|
|
135
|
+
reverseDuration,
|
|
136
|
+
stagger,
|
|
137
|
+
enableReverse
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
142
|
+
setPrefersReducedMotion(mediaQuery.matches);
|
|
143
|
+
const handleChange = (event) => setPrefersReducedMotion(event.matches);
|
|
144
|
+
mediaQuery.addEventListener('change', handleChange);
|
|
145
|
+
|
|
146
|
+
return () => {
|
|
147
|
+
if (reverseAnimationTimeoutRef.current) clearTimeout(reverseAnimationTimeoutRef.current);
|
|
148
|
+
if (animationCompletionTimeoutRef.current) clearTimeout(animationCompletionTimeoutRef.current);
|
|
149
|
+
mediaQuery.removeEventListener('change', handleChange);
|
|
150
|
+
};
|
|
151
|
+
}, []);
|
|
152
|
+
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
if (svgString) {
|
|
155
|
+
setInternalSvgString(svgString);
|
|
156
|
+
} else if (src) {
|
|
157
|
+
fetch(src)
|
|
158
|
+
.then(res => {
|
|
159
|
+
if (!res.ok) throw new Error("Failed to fetch SVG");
|
|
160
|
+
return res.text();
|
|
161
|
+
})
|
|
162
|
+
.then(text => setInternalSvgString(text))
|
|
163
|
+
.catch(err => {
|
|
164
|
+
console.error("Error loading SVG from src:", err);
|
|
165
|
+
setInternalSvgString(null);
|
|
166
|
+
});
|
|
167
|
+
} else {
|
|
168
|
+
setInternalSvgString(null);
|
|
169
|
+
}
|
|
170
|
+
}, [svgString, src]);
|
|
171
|
+
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
if (!internalSvgString) {
|
|
174
|
+
setPathsData([]);
|
|
175
|
+
setSvgAttributes({});
|
|
176
|
+
setIsAnimating(false);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (reverseAnimationTimeoutRef.current) clearTimeout(reverseAnimationTimeoutRef.current);
|
|
181
|
+
if (animationCompletionTimeoutRef.current) clearTimeout(animationCompletionTimeoutRef.current);
|
|
182
|
+
|
|
183
|
+
setIsReversing(false);
|
|
184
|
+
setAnimationKey(Date.now());
|
|
185
|
+
setIsAnimating(true);
|
|
186
|
+
|
|
187
|
+
const parser = new DOMParser();
|
|
188
|
+
const svgDoc = parser.parseFromString(internalSvgString, "image/svg+xml");
|
|
189
|
+
const svgElement = svgDoc.documentElement;
|
|
190
|
+
|
|
191
|
+
if (!svgElement || svgElement.nodeName.toLowerCase() !== 'svg') {
|
|
192
|
+
console.error("Invalid SVG content or root element is not <svg>. Found:", svgElement ? svgElement.nodeName : "null");
|
|
193
|
+
const parserError = svgDoc.querySelector('parsererror');
|
|
194
|
+
if (parserError) console.error("SVG Parsing Error:", parserError.textContent);
|
|
195
|
+
setPathsData([]); setSvgAttributes({}); setIsAnimating(false); return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const attrDuration = svgElement.getAttribute('data-duration');
|
|
199
|
+
const attrFillDelay = svgElement.getAttribute('data-fill-delay');
|
|
200
|
+
const attrReverseDuration = svgElement.getAttribute('data-reverse-duration');
|
|
201
|
+
const attrStagger = svgElement.getAttribute('data-stagger');
|
|
202
|
+
const attrEnableReverse = svgElement.getAttribute('data-enable-reverse');
|
|
203
|
+
|
|
204
|
+
const effectiveDuration = attrDuration ? parseFloat(attrDuration) : duration;
|
|
205
|
+
const effectiveFillDelay = attrFillDelay ? parseFloat(attrFillDelay) : fillDelay;
|
|
206
|
+
const effectiveReverseDuration = attrReverseDuration ? parseFloat(attrReverseDuration) : reverseDuration;
|
|
207
|
+
const effectiveStagger = attrStagger ? parseFloat(attrStagger) : stagger;
|
|
208
|
+
const effectiveEnableReverse = attrEnableReverse !== null ? attrEnableReverse === 'true' : enableReverse;
|
|
209
|
+
|
|
210
|
+
configRef.current = {
|
|
211
|
+
duration: effectiveDuration,
|
|
212
|
+
fillDelay: effectiveFillDelay,
|
|
213
|
+
reverseDuration: effectiveReverseDuration,
|
|
214
|
+
stagger: effectiveStagger,
|
|
215
|
+
enableReverse: effectiveEnableReverse
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const attributes = {};
|
|
219
|
+
for (const attr of svgElement.attributes) {
|
|
220
|
+
if (!['data-duration', 'data-fill-delay', 'data-reverse-duration', 'data-stagger', 'data-enable-reverse'].includes(attr.name)) {
|
|
221
|
+
attributes[attr.name] = attr.value;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (!attributes.width || attributes.width === "100%") attributes.width = "100%";
|
|
226
|
+
if (!attributes.height || attributes.height === "100%") attributes.height = attributes.viewBox ? "auto" : "300px";
|
|
227
|
+
setSvgAttributes(attributes);
|
|
228
|
+
|
|
229
|
+
const pathNodes = svgDoc.querySelectorAll('path');
|
|
230
|
+
const extractedPaths = [];
|
|
231
|
+
let maxTotalAnimationTimeMs = 0;
|
|
232
|
+
|
|
233
|
+
let tempMeasurementDiv = document.getElementById('temp-svg-path-measurement-div');
|
|
234
|
+
if (!tempMeasurementDiv) {
|
|
235
|
+
tempMeasurementDiv = document.createElement('div');
|
|
236
|
+
tempMeasurementDiv.id = 'temp-svg-path-measurement-div';
|
|
237
|
+
tempMeasurementDiv.style.cssText = 'visibility:hidden; position:absolute; width:0; height:0; top:-1000px; left:-1000px; overflow:hidden;';
|
|
238
|
+
document.body.appendChild(tempMeasurementDiv);
|
|
239
|
+
}
|
|
240
|
+
const tempMeasureSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
241
|
+
if (attributes.viewBox) tempMeasureSvg.setAttribute('viewBox', attributes.viewBox);
|
|
242
|
+
tempMeasurementDiv.appendChild(tempMeasureSvg);
|
|
243
|
+
|
|
244
|
+
pathNodes.forEach((pathNode, index) => {
|
|
245
|
+
const originalD = pathNode.getAttribute('d');
|
|
246
|
+
if (!originalD) return;
|
|
247
|
+
|
|
248
|
+
const optimizedD = optimizePathData(originalD);
|
|
249
|
+
|
|
250
|
+
const pathFillAttr = getInheritedStyleOrAttribute(pathNode, 'fill');
|
|
251
|
+
const pathStrokeAttr = getInheritedStyleOrAttribute(pathNode, 'stroke');
|
|
252
|
+
const pathStrokeWidthAttr = getInheritedStyleOrAttribute(pathNode, 'stroke-width');
|
|
253
|
+
|
|
254
|
+
const originalStroke = pathStrokeAttr !== null ? pathStrokeAttr : 'currentColor';
|
|
255
|
+
const originalStrokeWidth = pathStrokeWidthAttr !== null ? pathStrokeWidthAttr : '0.5';
|
|
256
|
+
|
|
257
|
+
let effectiveFill;
|
|
258
|
+
if (pathFillAttr === 'none') effectiveFill = 'none';
|
|
259
|
+
else if (pathFillAttr === null) effectiveFill = 'black';
|
|
260
|
+
else effectiveFill = pathFillAttr;
|
|
261
|
+
|
|
262
|
+
const tempPath = pathNode.cloneNode(false);
|
|
263
|
+
tempPath.setAttribute('d', optimizedD);
|
|
264
|
+
if (originalStrokeWidth !== '1') tempPath.setAttribute('stroke-width', originalStrokeWidth);
|
|
265
|
+
|
|
266
|
+
tempMeasureSvg.appendChild(tempPath);
|
|
267
|
+
let rawLength = 0;
|
|
268
|
+
try { rawLength = tempPath.getTotalLength(); }
|
|
269
|
+
catch (e) { console.warn(`Could not measure path ${index}. D=${optimizedD}`, e); }
|
|
270
|
+
tempMeasureSvg.removeChild(tempPath);
|
|
271
|
+
|
|
272
|
+
const length = Math.round(rawLength * 10) / 10;
|
|
273
|
+
|
|
274
|
+
if (length >= 1) {
|
|
275
|
+
const currentPathBaseDelayRaw = index * (effectiveStagger / 1000);
|
|
276
|
+
const currentPathBaseDelay = Math.round(currentPathBaseDelayRaw * 1000) / 1000;
|
|
277
|
+
|
|
278
|
+
const currentPathDrawDuration = effectiveDuration / 1000;
|
|
279
|
+
const fillAndStrokeRemoveEffectiveDuration = (effectiveDuration - effectiveFillDelay) / 1000;
|
|
280
|
+
|
|
281
|
+
const pathTotalTime = (currentPathBaseDelay * 1000) + effectiveDuration; // Use original delay for total time calculation
|
|
282
|
+
if (pathTotalTime > maxTotalAnimationTimeMs) maxTotalAnimationTimeMs = pathTotalTime;
|
|
283
|
+
|
|
284
|
+
extractedPaths.push({
|
|
285
|
+
id: `animated-path-${index}-${Date.now()}`,
|
|
286
|
+
d: optimizedD,
|
|
287
|
+
length,
|
|
288
|
+
originalStroke, originalStrokeWidth, effectiveFill,
|
|
289
|
+
animationDelay: `${currentPathBaseDelay}s`,
|
|
290
|
+
animationDuration: `${currentPathDrawDuration}s`,
|
|
291
|
+
fillAnimationDuration: `${fillAndStrokeRemoveEffectiveDuration > 0 ? fillAndStrokeRemoveEffectiveDuration : 0}s`,
|
|
292
|
+
strokeRemoveDuration: `${fillAndStrokeRemoveEffectiveDuration > 0 ? fillAndStrokeRemoveEffectiveDuration : 0}s`,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
if (tempMeasureSvg.parentNode === tempMeasurementDiv) tempMeasurementDiv.removeChild(tempMeasureSvg);
|
|
298
|
+
setPathsData(extractedPaths);
|
|
299
|
+
|
|
300
|
+
animationCompletionTimeoutRef.current = setTimeout(() => {
|
|
301
|
+
setIsAnimating(false);
|
|
302
|
+
console.log("Initial animation complete.");
|
|
303
|
+
}, maxTotalAnimationTimeMs );
|
|
304
|
+
|
|
305
|
+
}, [internalSvgString, duration, fillDelay, reverseDuration, stagger, enableReverse]);
|
|
306
|
+
|
|
307
|
+
const triggerAnimationRestart = (e) => {
|
|
308
|
+
if (!configRef.current.enableReverse) return;
|
|
309
|
+
if (isAnimating) { console.log("Animation in progress, restart ignored."); return; }
|
|
310
|
+
if (e) e.stopPropagation();
|
|
311
|
+
|
|
312
|
+
console.log("Interaction detected, initiating reverse animation.");
|
|
313
|
+
setIsAnimating(true); setIsReversing(true);
|
|
314
|
+
|
|
315
|
+
if (reverseAnimationTimeoutRef.current) clearTimeout(reverseAnimationTimeoutRef.current);
|
|
316
|
+
if (animationCompletionTimeoutRef.current) clearTimeout(animationCompletionTimeoutRef.current);
|
|
317
|
+
|
|
318
|
+
const { reverseDuration, duration } = configRef.current;
|
|
319
|
+
|
|
320
|
+
reverseAnimationTimeoutRef.current = setTimeout(() => {
|
|
321
|
+
console.log("Reverse animation finished, starting forward animation.");
|
|
322
|
+
setIsReversing(false); setAnimationKey(Date.now());
|
|
323
|
+
|
|
324
|
+
let maxTotalAnimationTimeMs = 0;
|
|
325
|
+
if (pathsData.length > 0) {
|
|
326
|
+
pathsData.forEach(p => {
|
|
327
|
+
const currentPathBaseDelay = parseFloat(p.animationDelay.replace('s','')) * 1000; // Use the stored (possibly rounded) delay
|
|
328
|
+
const pathTotalTime = currentPathBaseDelay + duration;
|
|
329
|
+
if(pathTotalTime > maxTotalAnimationTimeMs) maxTotalAnimationTimeMs = pathTotalTime;
|
|
330
|
+
});
|
|
331
|
+
} else { maxTotalAnimationTimeMs = duration; }
|
|
332
|
+
|
|
333
|
+
animationCompletionTimeoutRef.current = setTimeout(() => {
|
|
334
|
+
setIsAnimating(false); console.log("Restarted animation complete.");
|
|
335
|
+
}, maxTotalAnimationTimeMs);
|
|
336
|
+
}, reverseDuration);
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
const handleTouchEnd = (e) => {
|
|
340
|
+
if (!configRef.current.enableReverse) return;
|
|
341
|
+
const currentTime = new Date().getTime();
|
|
342
|
+
const tapLength = currentTime - lastTapTimeRef.current;
|
|
343
|
+
if (tapLength < 300 && tapLength > 0 && lastTapTargetRef.current && lastTapTargetRef.current.contains(e.target)) {
|
|
344
|
+
triggerAnimationRestart(e); lastTapTimeRef.current = 0;
|
|
345
|
+
} else {
|
|
346
|
+
lastTapTimeRef.current = currentTime; lastTapTargetRef.current = e.currentTarget;
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
if (!internalSvgString || (pathsData.length === 0 && Object.keys(svgAttributes).length === 0)) return null;
|
|
351
|
+
|
|
352
|
+
const { style: rawStyleAttribute, ...otherSvgAttributes } = svgAttributes;
|
|
353
|
+
const parsedStyleFromSvg = {};
|
|
354
|
+
if (typeof rawStyleAttribute === 'string') {
|
|
355
|
+
rawStyleAttribute.split(';').forEach(declaration => {
|
|
356
|
+
if (declaration.trim() === '') return;
|
|
357
|
+
const parts = declaration.split(/:(.+)/);
|
|
358
|
+
if (parts.length === 3) {
|
|
359
|
+
const property = parts[0]; const value = parts[1];
|
|
360
|
+
if (property && value) {
|
|
361
|
+
const propName = property.trim();
|
|
362
|
+
const camelCasePropName = propName.replace(/-([a-z])/g, (match, letter) => letter.toUpperCase());
|
|
363
|
+
if (camelCasePropName) parsedStyleFromSvg[camelCasePropName] = value.trim();
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
} else if (typeof rawStyleAttribute === 'object' && rawStyleAttribute !== null) {
|
|
368
|
+
Object.assign(parsedStyleFromSvg, rawStyleAttribute);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const finalSvgElementStyle = { ...parsedStyleFromSvg, overflow: 'visible', cursor: configRef.current.enableReverse ? 'pointer' : 'default' };
|
|
372
|
+
const { fillDelay: effectiveFillDelay, reverseDuration: effectiveReverseDuration } = configRef.current;
|
|
373
|
+
|
|
374
|
+
if (internalSvgString && pathsData.length === 0 && Object.keys(svgAttributes).length > 0) {
|
|
375
|
+
return (
|
|
376
|
+
<div className="text-center text-gray-500 p-4">
|
|
377
|
+
<div onDoubleClick={triggerAnimationRestart} onTouchEnd={handleTouchEnd} style={{ display: 'inline-block', cursor: configRef.current.enableReverse ? 'pointer' : 'default' }}>
|
|
378
|
+
<svg {...otherSvgAttributes} style={finalSvgElementStyle} data-reduced-motion={prefersReducedMotion}></svg>
|
|
379
|
+
</div>
|
|
380
|
+
<p className="mt-2">SVG loaded, but no animatable <path> elements with length >= 1 found.</p>
|
|
381
|
+
</div>
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return (
|
|
386
|
+
<svg key={animationKey} {...otherSvgAttributes} style={finalSvgElementStyle}
|
|
387
|
+
onDoubleClick={triggerAnimationRestart} onTouchEnd={handleTouchEnd} data-reduced-motion={prefersReducedMotion} >
|
|
388
|
+
<style>
|
|
389
|
+
{`
|
|
390
|
+
.animated-svg-path {
|
|
391
|
+
fill-opacity: 0;
|
|
392
|
+
fill: var(--path-fill, black);
|
|
393
|
+
stroke: var(--path-stroke-draw-color, currentColor);
|
|
394
|
+
stroke-opacity: 1;
|
|
395
|
+
stroke-dasharray: var(--path-length);
|
|
396
|
+
stroke-dashoffset: var(--path-length);
|
|
397
|
+
will-change: stroke-dashoffset, stroke-opacity, fill-opacity;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
svg:not([data-reduced-motion="true"]) .animated-svg-path:not(.reversing) {
|
|
401
|
+
animation:
|
|
402
|
+
drawPath var(--animation-duration) ease-in-out var(--animation-delay, 0s) forwards,
|
|
403
|
+
fillPath var(--fill-animation-effective-duration) ease-in calc(var(--animation-delay) + ${effectiveFillDelay / 1000}s) forwards,
|
|
404
|
+
removeStroke var(--fill-animation-effective-duration) ease-in calc(var(--animation-delay) + ${effectiveFillDelay / 1000}s) forwards;
|
|
405
|
+
}
|
|
406
|
+
svg:not([data-reduced-motion="true"]) .animated-svg-path[fill="none"]:not(.reversing) {
|
|
407
|
+
animation: drawPath var(--animation-duration) ease-in-out var(--animation-delay, 0s) forwards;
|
|
408
|
+
fill-opacity: 0 !important; fill: none !important;
|
|
409
|
+
stroke: var(--original-stroke-color, currentColor); stroke-opacity: 1 !important;
|
|
410
|
+
}
|
|
411
|
+
svg:not([data-reduced-motion="true"]) .animated-svg-path.reversing {
|
|
412
|
+
stroke-dashoffset: 0; fill-opacity: 1; stroke-opacity: 1;
|
|
413
|
+
stroke: var(--path-stroke-draw-color);
|
|
414
|
+
animation: quickReverse ${effectiveReverseDuration / 1000}s ease-out forwards;
|
|
415
|
+
}
|
|
416
|
+
svg:not([data-reduced-motion="true"]) .animated-svg-path[fill="none"].reversing {
|
|
417
|
+
stroke-dashoffset: 0; stroke-opacity: 1 !important; fill-opacity: 0 !important;
|
|
418
|
+
fill: none !important; stroke: var(--original-stroke-color, currentColor) !important;
|
|
419
|
+
animation: unDrawPathReverseOriginalStroke ${effectiveReverseDuration / 1000}s ease-out forwards;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
svg[data-reduced-motion="true"] .animated-svg-path:not([fill="none"]):not(.reversing) {
|
|
423
|
+
stroke-dashoffset: 0; stroke-opacity: 0;
|
|
424
|
+
animation: fillPath var(--animation-duration) ease-in calc(var(--animation-delay) + ${effectiveFillDelay / 1000}s) forwards;
|
|
425
|
+
}
|
|
426
|
+
svg[data-reduced-motion="true"] .animated-svg-path[fill="none"]:not(.reversing) {
|
|
427
|
+
stroke-dashoffset: 0; stroke: var(--original-stroke-color, currentColor);
|
|
428
|
+
stroke-opacity: 1 !important; fill-opacity: 0 !important; fill: none !important;
|
|
429
|
+
animation: none;
|
|
430
|
+
}
|
|
431
|
+
svg[data-reduced-motion="true"] .animated-svg-path:not([fill="none"]).reversing {
|
|
432
|
+
fill-opacity: 1; stroke-opacity: 0; stroke-dashoffset: 0;
|
|
433
|
+
animation: quickReverseFillOnlyReducedMotion ${effectiveReverseDuration / 1000}s ease-out forwards;
|
|
434
|
+
}
|
|
435
|
+
svg[data-reduced-motion="true"] .animated-svg-path[fill="none"].reversing {
|
|
436
|
+
stroke-dashoffset: 0; stroke: var(--original-stroke-color, currentColor);
|
|
437
|
+
stroke-opacity: 1 !important; fill-opacity: 0 !important; fill: none !important;
|
|
438
|
+
animation: none;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
@keyframes drawPath { to { stroke-dashoffset: 0; } }
|
|
442
|
+
@keyframes fillPath { to { fill-opacity: 1; } }
|
|
443
|
+
@keyframes removeStroke { to { stroke-opacity: 0; } }
|
|
444
|
+
@keyframes quickReverse {
|
|
445
|
+
from { fill-opacity: 1; stroke-dashoffset: 0; stroke-opacity: 1; }
|
|
446
|
+
to { fill-opacity: 0; stroke-dashoffset: var(--path-length); stroke-opacity: 1; }
|
|
447
|
+
}
|
|
448
|
+
@keyframes unDrawPathReverseOriginalStroke {
|
|
449
|
+
from { stroke-dashoffset: 0; stroke-opacity: 1; }
|
|
450
|
+
to { stroke-dashoffset: var(--path-length); stroke-opacity: 1; }
|
|
451
|
+
}
|
|
452
|
+
@keyframes quickReverseFillOnlyReducedMotion {
|
|
453
|
+
from { fill-opacity: 1; } to { fill-opacity: 0; }
|
|
454
|
+
}
|
|
455
|
+
`}
|
|
456
|
+
</style>
|
|
457
|
+
{pathsData.map((path) => (
|
|
458
|
+
<path
|
|
459
|
+
key={path.id}
|
|
460
|
+
d={path.d}
|
|
461
|
+
strokeWidth={path.originalStrokeWidth}
|
|
462
|
+
className={`animated-svg-path ${isReversing ? 'reversing' : ''}`}
|
|
463
|
+
style={{
|
|
464
|
+
'--path-length': path.length, // Rounded length
|
|
465
|
+
'--animation-duration': path.animationDuration,
|
|
466
|
+
'--animation-delay': path.animationDelay, // Rounded delay
|
|
467
|
+
'--fill-animation-effective-duration': path.fillAnimationDuration,
|
|
468
|
+
'--path-fill': path.effectiveFill === 'none' ? 'none' : path.effectiveFill,
|
|
469
|
+
'--path-stroke-draw-color': path.effectiveFill === 'none' ? path.originalStroke : path.effectiveFill,
|
|
470
|
+
'--original-stroke-color': path.originalStroke,
|
|
471
|
+
}}
|
|
472
|
+
fill={path.effectiveFill}
|
|
473
|
+
stroke={path.originalStroke}
|
|
474
|
+
/>
|
|
475
|
+
))}
|
|
476
|
+
</svg>
|
|
477
|
+
);
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
export default SVGPathAnimator;
|