@toankhontech/arctimer-core 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +801 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +252 -0
- package/dist/index.d.ts +252 -0
- package/dist/index.js +773 -0
- package/dist/index.js.map +1 -0
- package/package.json +58 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,773 @@
|
|
|
1
|
+
import { createContext, useReducer, useState, useRef, useMemo, useCallback, useEffect, useContext } from 'react';
|
|
2
|
+
|
|
3
|
+
// src/useCountdown.ts
|
|
4
|
+
|
|
5
|
+
// src/engine/timerMachine.ts
|
|
6
|
+
function createInitialState(duration, initialRemainingTime) {
|
|
7
|
+
const elapsed = initialRemainingTime !== void 0 ? duration - initialRemainingTime : 0;
|
|
8
|
+
return {
|
|
9
|
+
status: "idle",
|
|
10
|
+
duration,
|
|
11
|
+
elapsed: Math.max(0, Math.min(elapsed, duration)),
|
|
12
|
+
startedAt: null,
|
|
13
|
+
pausedAt: null
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function timerReducer(state, event) {
|
|
17
|
+
switch (event.type) {
|
|
18
|
+
case "PLAY": {
|
|
19
|
+
if (state.status === "playing") return state;
|
|
20
|
+
const now = performance.now();
|
|
21
|
+
if (state.status === "completed") {
|
|
22
|
+
return {
|
|
23
|
+
...state,
|
|
24
|
+
status: "playing",
|
|
25
|
+
elapsed: 0,
|
|
26
|
+
startedAt: now,
|
|
27
|
+
pausedAt: null
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
...state,
|
|
32
|
+
status: "playing",
|
|
33
|
+
startedAt: now - state.elapsed * 1e3,
|
|
34
|
+
pausedAt: null
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
case "PAUSE": {
|
|
38
|
+
if (state.status !== "playing") return state;
|
|
39
|
+
return {
|
|
40
|
+
...state,
|
|
41
|
+
status: "paused",
|
|
42
|
+
pausedAt: performance.now()
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
case "COMPLETE": {
|
|
46
|
+
if (state.status !== "playing") return state;
|
|
47
|
+
return {
|
|
48
|
+
...state,
|
|
49
|
+
status: "completed",
|
|
50
|
+
elapsed: state.duration,
|
|
51
|
+
startedAt: null,
|
|
52
|
+
pausedAt: null
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
case "RESET": {
|
|
56
|
+
const newDuration = event.duration ?? state.duration;
|
|
57
|
+
return createInitialState(newDuration);
|
|
58
|
+
}
|
|
59
|
+
default:
|
|
60
|
+
return state;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// src/engine/rafLoop.ts
|
|
65
|
+
var subscribers = /* @__PURE__ */ new Map();
|
|
66
|
+
var rafId = null;
|
|
67
|
+
var isRunning = false;
|
|
68
|
+
function tick(timestamp) {
|
|
69
|
+
subscribers.forEach((cb) => {
|
|
70
|
+
try {
|
|
71
|
+
cb(timestamp);
|
|
72
|
+
} catch (_e) {
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
if (subscribers.size > 0) {
|
|
76
|
+
rafId = requestAnimationFrame(tick);
|
|
77
|
+
} else {
|
|
78
|
+
isRunning = false;
|
|
79
|
+
rafId = null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function startLoop() {
|
|
83
|
+
if (isRunning) return;
|
|
84
|
+
isRunning = true;
|
|
85
|
+
rafId = requestAnimationFrame(tick);
|
|
86
|
+
}
|
|
87
|
+
function stopLoop() {
|
|
88
|
+
if (rafId !== null) {
|
|
89
|
+
cancelAnimationFrame(rafId);
|
|
90
|
+
rafId = null;
|
|
91
|
+
}
|
|
92
|
+
isRunning = false;
|
|
93
|
+
}
|
|
94
|
+
function subscribe(id, callback) {
|
|
95
|
+
subscribers.set(id, callback);
|
|
96
|
+
if (subscribers.size === 1) {
|
|
97
|
+
startLoop();
|
|
98
|
+
}
|
|
99
|
+
return () => {
|
|
100
|
+
subscribers.delete(id);
|
|
101
|
+
if (subscribers.size === 0) {
|
|
102
|
+
stopLoop();
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function _resetForTesting() {
|
|
107
|
+
subscribers.clear();
|
|
108
|
+
stopLoop();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// src/engine/colorInterpolation.ts
|
|
112
|
+
function hexToRgb(hex) {
|
|
113
|
+
const cleaned = hex.replace("#", "");
|
|
114
|
+
const fullHex = cleaned.length === 3 ? cleaned.split("").map((c) => c + c).join("") : cleaned;
|
|
115
|
+
const num = parseInt(fullHex, 16);
|
|
116
|
+
return [num >> 16 & 255, num >> 8 & 255, num & 255];
|
|
117
|
+
}
|
|
118
|
+
function rgbToHex(r, g, b) {
|
|
119
|
+
const toHex = (n) => Math.round(Math.max(0, Math.min(255, n))).toString(16).padStart(2, "0");
|
|
120
|
+
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
|
121
|
+
}
|
|
122
|
+
function interpolateColor(color1, color2, factor) {
|
|
123
|
+
const clampedFactor = Math.max(0, Math.min(1, factor));
|
|
124
|
+
const [r1, g1, b1] = hexToRgb(color1);
|
|
125
|
+
const [r2, g2, b2] = hexToRgb(color2);
|
|
126
|
+
return rgbToHex(
|
|
127
|
+
r1 + (r2 - r1) * clampedFactor,
|
|
128
|
+
g1 + (g2 - g1) * clampedFactor,
|
|
129
|
+
b1 + (b2 - b1) * clampedFactor
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
function getStrokeColor(colors, colorsTime, remainingTime, duration) {
|
|
133
|
+
const defaultColor = "#3498DB";
|
|
134
|
+
if (!colors) return defaultColor;
|
|
135
|
+
if (typeof colors === "string") return colors;
|
|
136
|
+
if (colors.length === 0) return defaultColor;
|
|
137
|
+
if (colors.length === 1) return colors[0];
|
|
138
|
+
const times = colorsTime ?? colors.map((_, i) => duration - duration / (colors.length - 1) * i);
|
|
139
|
+
for (let i = 0; i < times.length - 1; i++) {
|
|
140
|
+
const currentTime = times[i];
|
|
141
|
+
const nextTime = times[i + 1];
|
|
142
|
+
if (remainingTime <= currentTime && remainingTime >= nextTime) {
|
|
143
|
+
const range = currentTime - nextTime;
|
|
144
|
+
if (range === 0) return colors[i];
|
|
145
|
+
const factor = (currentTime - remainingTime) / range;
|
|
146
|
+
return interpolateColor(colors[i], colors[i + 1], factor);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (remainingTime >= times[0]) return colors[0];
|
|
150
|
+
return colors[colors.length - 1];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// src/svg/pathCalculation.ts
|
|
154
|
+
function getCirclePath(size, strokeWidth, rotation = "clockwise") {
|
|
155
|
+
const halfSize = size / 2;
|
|
156
|
+
const radius = halfSize - strokeWidth / 2;
|
|
157
|
+
const isClockwise = rotation === "clockwise";
|
|
158
|
+
const sweepFlag = isClockwise ? 1 : 0;
|
|
159
|
+
return [
|
|
160
|
+
`M ${halfSize},${strokeWidth / 2}`,
|
|
161
|
+
`A ${radius},${radius} 0 1,${sweepFlag} ${halfSize},${size - strokeWidth / 2}`,
|
|
162
|
+
`A ${radius},${radius} 0 1,${sweepFlag} ${halfSize},${strokeWidth / 2}`
|
|
163
|
+
].join(" ");
|
|
164
|
+
}
|
|
165
|
+
function getPathLength(size, strokeWidth) {
|
|
166
|
+
const radius = (size - strokeWidth) / 2;
|
|
167
|
+
return 2 * Math.PI * radius;
|
|
168
|
+
}
|
|
169
|
+
function getStrokeDashoffset(pathLength, progress) {
|
|
170
|
+
const clampedProgress = Math.max(0, Math.min(1, progress));
|
|
171
|
+
return pathLength * (1 - clampedProgress);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// src/animation/spring.ts
|
|
175
|
+
function springStep(state, stiffness, damping, mass, dt) {
|
|
176
|
+
function acceleration(pos, vel) {
|
|
177
|
+
return (-stiffness * (pos - 1) - damping * vel) / mass;
|
|
178
|
+
}
|
|
179
|
+
const k1v = acceleration(state.position, state.velocity);
|
|
180
|
+
const k1x = state.velocity;
|
|
181
|
+
const k2v = acceleration(
|
|
182
|
+
state.position + k1x * dt * 0.5,
|
|
183
|
+
state.velocity + k1v * dt * 0.5
|
|
184
|
+
);
|
|
185
|
+
const k2x = state.velocity + k1v * dt * 0.5;
|
|
186
|
+
const k3v = acceleration(
|
|
187
|
+
state.position + k2x * dt * 0.5,
|
|
188
|
+
state.velocity + k2v * dt * 0.5
|
|
189
|
+
);
|
|
190
|
+
const k3x = state.velocity + k2v * dt * 0.5;
|
|
191
|
+
const k4v = acceleration(
|
|
192
|
+
state.position + k3x * dt,
|
|
193
|
+
state.velocity + k3v * dt
|
|
194
|
+
);
|
|
195
|
+
const k4x = state.velocity + k3v * dt;
|
|
196
|
+
const newPosition = state.position + dt / 6 * (k1x + 2 * k2x + 2 * k3x + k4x);
|
|
197
|
+
const newVelocity = state.velocity + dt / 6 * (k1v + 2 * k2v + 2 * k3v + k4v);
|
|
198
|
+
return { position: newPosition, velocity: newVelocity };
|
|
199
|
+
}
|
|
200
|
+
function createSpringEasing(config) {
|
|
201
|
+
const tension = config.tension ?? 170;
|
|
202
|
+
const friction = config.friction ?? 26;
|
|
203
|
+
const mass = config.mass ?? 1;
|
|
204
|
+
const samples = [];
|
|
205
|
+
const sampleCount = 1e3;
|
|
206
|
+
const dt = 1e-3;
|
|
207
|
+
let state = { position: 0, velocity: 0 };
|
|
208
|
+
let totalTime = 0;
|
|
209
|
+
const threshold = 1e-4;
|
|
210
|
+
for (let i = 0; i < 1e4; i++) {
|
|
211
|
+
state = springStep(state, tension, friction, mass, dt);
|
|
212
|
+
totalTime += dt;
|
|
213
|
+
if (Math.abs(state.position - 1) < threshold && Math.abs(state.velocity) < threshold) {
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const settleDuration = totalTime;
|
|
218
|
+
state = { position: 0, velocity: 0 };
|
|
219
|
+
for (let i = 0; i <= sampleCount; i++) {
|
|
220
|
+
const targetTime = i / sampleCount * settleDuration;
|
|
221
|
+
while (totalTime < targetTime) {
|
|
222
|
+
state = springStep(state, tension, friction, mass, dt);
|
|
223
|
+
totalTime += dt;
|
|
224
|
+
}
|
|
225
|
+
samples.push(state.position);
|
|
226
|
+
if (i === 0) {
|
|
227
|
+
totalTime = 0;
|
|
228
|
+
state = { position: 0, velocity: 0 };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const properSamples = [];
|
|
232
|
+
state = { position: 0, velocity: 0 };
|
|
233
|
+
for (let i = 0; i <= sampleCount; i++) {
|
|
234
|
+
const stepsPerSample = Math.ceil(settleDuration / dt / sampleCount);
|
|
235
|
+
for (let j = 0; j < stepsPerSample; j++) {
|
|
236
|
+
state = springStep(state, tension, friction, mass, dt);
|
|
237
|
+
}
|
|
238
|
+
properSamples.push(Math.max(0, Math.min(state.position, 1.5)));
|
|
239
|
+
}
|
|
240
|
+
return (t) => {
|
|
241
|
+
if (t <= 0) return 0;
|
|
242
|
+
if (t >= 1) return 1;
|
|
243
|
+
const index = t * sampleCount;
|
|
244
|
+
const lower = Math.floor(index);
|
|
245
|
+
const upper = Math.min(Math.ceil(index), sampleCount);
|
|
246
|
+
const fraction = index - lower;
|
|
247
|
+
const lowerVal = properSamples[lower] ?? 0;
|
|
248
|
+
const upperVal = properSamples[upper] ?? 1;
|
|
249
|
+
return lowerVal + (upperVal - lowerVal) * fraction;
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// src/animation/easings.ts
|
|
254
|
+
var easings = {
|
|
255
|
+
linear: (t) => t,
|
|
256
|
+
easeIn: (t) => t * t,
|
|
257
|
+
easeOut: (t) => t * (2 - t),
|
|
258
|
+
easeInOut: (t) => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t,
|
|
259
|
+
easeInCubic: (t) => t * t * t,
|
|
260
|
+
easeOutCubic: (t) => {
|
|
261
|
+
const t1 = t - 1;
|
|
262
|
+
return t1 * t1 * t1 + 1;
|
|
263
|
+
},
|
|
264
|
+
easeInOutCubic: (t) => t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1
|
|
265
|
+
};
|
|
266
|
+
function resolveEasing(easing) {
|
|
267
|
+
if (!easing) return easings.linear;
|
|
268
|
+
if (typeof easing === "function") return easing;
|
|
269
|
+
if (typeof easing === "string") {
|
|
270
|
+
return easings[easing] ?? easings.linear;
|
|
271
|
+
}
|
|
272
|
+
if (typeof easing === "object" && easing.type === "spring") {
|
|
273
|
+
return createSpringEasing(easing);
|
|
274
|
+
}
|
|
275
|
+
return easings.linear;
|
|
276
|
+
}
|
|
277
|
+
function getEasingNames() {
|
|
278
|
+
return Object.keys(easings);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// src/animation/effects.ts
|
|
282
|
+
function computeBounce(remainingTime, prevRemainingTime, bounceAt, bounceOnComplete, isComplete, elapsedSinceBounce) {
|
|
283
|
+
const bounceDuration = 300;
|
|
284
|
+
const shouldBounce = bounceOnComplete && isComplete || bounceAt.some(
|
|
285
|
+
(threshold) => prevRemainingTime > threshold && remainingTime <= threshold
|
|
286
|
+
);
|
|
287
|
+
if (!shouldBounce && elapsedSinceBounce > bounceDuration) {
|
|
288
|
+
return { scale: 1, active: false };
|
|
289
|
+
}
|
|
290
|
+
if (shouldBounce || elapsedSinceBounce <= bounceDuration) {
|
|
291
|
+
const progress = Math.min(elapsedSinceBounce / bounceDuration, 1);
|
|
292
|
+
const bounceScale = 1 + 0.1 * Math.sin(progress * Math.PI);
|
|
293
|
+
return { scale: bounceScale, active: progress < 1 };
|
|
294
|
+
}
|
|
295
|
+
return { scale: 1, active: false };
|
|
296
|
+
}
|
|
297
|
+
function computePulse(elapsedTime, config) {
|
|
298
|
+
if (!config) {
|
|
299
|
+
return { scale: 1, opacity: 1 };
|
|
300
|
+
}
|
|
301
|
+
const { interval, scale = 1.05, opacity = 1 } = config;
|
|
302
|
+
if (interval <= 0) {
|
|
303
|
+
return { scale: 1, opacity: 1 };
|
|
304
|
+
}
|
|
305
|
+
const cycleProgress = elapsedTime % interval / interval;
|
|
306
|
+
const sinValue = Math.sin(cycleProgress * 2 * Math.PI);
|
|
307
|
+
const normalized = (sinValue + 1) / 2;
|
|
308
|
+
const currentScale = 1 + (scale - 1) * normalized;
|
|
309
|
+
const minOpacity = Math.max(0, 2 * opacity - 1);
|
|
310
|
+
const currentOpacity = minOpacity + (1 - minOpacity) * normalized;
|
|
311
|
+
return {
|
|
312
|
+
scale: currentScale,
|
|
313
|
+
opacity: currentOpacity
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// src/useCountdown.ts
|
|
318
|
+
var idCounter = 0;
|
|
319
|
+
function generateId() {
|
|
320
|
+
return `arc-timer-${++idCounter}`;
|
|
321
|
+
}
|
|
322
|
+
function useCountdown(props) {
|
|
323
|
+
const {
|
|
324
|
+
duration,
|
|
325
|
+
isPlaying = false,
|
|
326
|
+
colors,
|
|
327
|
+
colorsTime,
|
|
328
|
+
size = 180,
|
|
329
|
+
strokeWidth = 12,
|
|
330
|
+
rotation = "clockwise",
|
|
331
|
+
isCountUp = false,
|
|
332
|
+
initialRemainingTime,
|
|
333
|
+
updateInterval = 1,
|
|
334
|
+
easing,
|
|
335
|
+
bounceOnComplete = false,
|
|
336
|
+
bounceAt = [],
|
|
337
|
+
pulse,
|
|
338
|
+
onComplete,
|
|
339
|
+
onUpdate,
|
|
340
|
+
onAnimationFrame
|
|
341
|
+
} = props;
|
|
342
|
+
const [state, dispatch] = useReducer(
|
|
343
|
+
timerReducer,
|
|
344
|
+
{ duration, initialRemainingTime },
|
|
345
|
+
({ duration: d, initialRemainingTime: irt }) => createInitialState(d, irt)
|
|
346
|
+
);
|
|
347
|
+
const [displayTime, setDisplayTime] = useState(() => {
|
|
348
|
+
if (initialRemainingTime !== void 0) {
|
|
349
|
+
return isCountUp ? duration - initialRemainingTime : initialRemainingTime;
|
|
350
|
+
}
|
|
351
|
+
return isCountUp ? 0 : duration;
|
|
352
|
+
});
|
|
353
|
+
const [smoothProgress, setSmoothProgress] = useState(0);
|
|
354
|
+
const [animationScale, setAnimationScale] = useState(1);
|
|
355
|
+
const [animationOpacity, setAnimationOpacity] = useState(1);
|
|
356
|
+
const idRef = useRef(generateId());
|
|
357
|
+
const prevDisplayTimeRef = useRef(displayTime);
|
|
358
|
+
const onCompleteRef = useRef(onComplete);
|
|
359
|
+
const onUpdateRef = useRef(onUpdate);
|
|
360
|
+
const onAnimationFrameRef = useRef(onAnimationFrame);
|
|
361
|
+
const repeatTimeoutRef = useRef(null);
|
|
362
|
+
const bounceStartRef = useRef(null);
|
|
363
|
+
const prevRemainingForBounceRef = useRef(displayTime);
|
|
364
|
+
const easingFn = useMemo(() => resolveEasing(easing), [easing]);
|
|
365
|
+
onCompleteRef.current = onComplete;
|
|
366
|
+
onUpdateRef.current = onUpdate;
|
|
367
|
+
onAnimationFrameRef.current = onAnimationFrame;
|
|
368
|
+
const play = useCallback(() => {
|
|
369
|
+
dispatch({ type: "PLAY" });
|
|
370
|
+
}, []);
|
|
371
|
+
const pause = useCallback(() => {
|
|
372
|
+
dispatch({ type: "PAUSE" });
|
|
373
|
+
}, []);
|
|
374
|
+
const reset = useCallback(
|
|
375
|
+
(newDuration) => {
|
|
376
|
+
dispatch({ type: "RESET", duration: newDuration });
|
|
377
|
+
const dur = newDuration ?? duration;
|
|
378
|
+
setDisplayTime(isCountUp ? 0 : dur);
|
|
379
|
+
setSmoothProgress(0);
|
|
380
|
+
setAnimationScale(1);
|
|
381
|
+
setAnimationOpacity(1);
|
|
382
|
+
bounceStartRef.current = null;
|
|
383
|
+
},
|
|
384
|
+
[duration, isCountUp]
|
|
385
|
+
);
|
|
386
|
+
useEffect(() => {
|
|
387
|
+
if (isPlaying) {
|
|
388
|
+
if (state.status !== "playing") {
|
|
389
|
+
dispatch({ type: "PLAY" });
|
|
390
|
+
}
|
|
391
|
+
} else {
|
|
392
|
+
if (state.status === "playing") {
|
|
393
|
+
dispatch({ type: "PAUSE" });
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}, [isPlaying, state.status]);
|
|
397
|
+
useEffect(() => {
|
|
398
|
+
if (duration !== state.duration) {
|
|
399
|
+
dispatch({ type: "RESET", duration });
|
|
400
|
+
}
|
|
401
|
+
}, [duration, state.duration]);
|
|
402
|
+
useEffect(() => {
|
|
403
|
+
if (state.status !== "playing" || state.startedAt === null) return;
|
|
404
|
+
const startedAt = state.startedAt;
|
|
405
|
+
let lastUpdateTime = 0;
|
|
406
|
+
const unsubscribe = subscribe(idRef.current, (timestamp) => {
|
|
407
|
+
const elapsedMs = timestamp - startedAt;
|
|
408
|
+
const elapsedSeconds = elapsedMs / 1e3;
|
|
409
|
+
const clampedElapsed = Math.min(elapsedSeconds, duration);
|
|
410
|
+
const linearProgress = duration > 0 ? clampedElapsed / duration : 1;
|
|
411
|
+
const easedProgress = easingFn(linearProgress);
|
|
412
|
+
setSmoothProgress(easedProgress);
|
|
413
|
+
onAnimationFrameRef.current?.(easedProgress);
|
|
414
|
+
const rawRemainingTime = Math.max(
|
|
415
|
+
0,
|
|
416
|
+
duration - duration * easedProgress
|
|
417
|
+
);
|
|
418
|
+
const remainingTimeInt = Math.ceil(rawRemainingTime);
|
|
419
|
+
const hasBounceConfig = bounceOnComplete || bounceAt && bounceAt.length > 0;
|
|
420
|
+
if (hasBounceConfig) {
|
|
421
|
+
const prevRemaining = prevRemainingForBounceRef.current;
|
|
422
|
+
const shouldStartBounce = bounceOnComplete && rawRemainingTime <= 0 && prevRemaining > 0 || bounceAt.some(
|
|
423
|
+
(t) => prevRemaining > t && remainingTimeInt <= t
|
|
424
|
+
);
|
|
425
|
+
if (shouldStartBounce) {
|
|
426
|
+
bounceStartRef.current = timestamp;
|
|
427
|
+
}
|
|
428
|
+
if (bounceStartRef.current !== null) {
|
|
429
|
+
const bounceElapsed = timestamp - bounceStartRef.current;
|
|
430
|
+
const result = computeBounce(
|
|
431
|
+
remainingTimeInt,
|
|
432
|
+
prevRemaining,
|
|
433
|
+
bounceAt,
|
|
434
|
+
bounceOnComplete,
|
|
435
|
+
rawRemainingTime <= 0,
|
|
436
|
+
bounceElapsed
|
|
437
|
+
);
|
|
438
|
+
setAnimationScale(result.scale);
|
|
439
|
+
if (!result.active) {
|
|
440
|
+
bounceStartRef.current = null;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
prevRemainingForBounceRef.current = remainingTimeInt;
|
|
444
|
+
}
|
|
445
|
+
if (pulse) {
|
|
446
|
+
const pulseResult = computePulse(clampedElapsed, pulse);
|
|
447
|
+
setAnimationScale(
|
|
448
|
+
(prev) => bounceStartRef.current !== null ? prev : pulseResult.scale
|
|
449
|
+
);
|
|
450
|
+
setAnimationOpacity(pulseResult.opacity);
|
|
451
|
+
}
|
|
452
|
+
const shouldUpdate = updateInterval === 0 || timestamp - lastUpdateTime >= updateInterval * 1e3;
|
|
453
|
+
if (shouldUpdate) {
|
|
454
|
+
lastUpdateTime = timestamp;
|
|
455
|
+
const newDisplayTime = isCountUp ? Math.floor(clampedElapsed) : remainingTimeInt;
|
|
456
|
+
setDisplayTime((prev) => {
|
|
457
|
+
if (prev !== newDisplayTime) {
|
|
458
|
+
return newDisplayTime;
|
|
459
|
+
}
|
|
460
|
+
return prev;
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
if (clampedElapsed >= duration) {
|
|
464
|
+
dispatch({ type: "COMPLETE" });
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
return unsubscribe;
|
|
468
|
+
}, [
|
|
469
|
+
state.status,
|
|
470
|
+
state.startedAt,
|
|
471
|
+
duration,
|
|
472
|
+
updateInterval,
|
|
473
|
+
isCountUp,
|
|
474
|
+
easingFn,
|
|
475
|
+
bounceOnComplete,
|
|
476
|
+
bounceAt,
|
|
477
|
+
pulse
|
|
478
|
+
]);
|
|
479
|
+
useEffect(() => {
|
|
480
|
+
if (state.status !== "completed") return;
|
|
481
|
+
const result = onCompleteRef.current?.(state.duration);
|
|
482
|
+
if (result?.shouldRepeat) {
|
|
483
|
+
const delay = (result.delay ?? 0) * 1e3;
|
|
484
|
+
repeatTimeoutRef.current = setTimeout(() => {
|
|
485
|
+
if (result.newInitialRemainingTime !== void 0) {
|
|
486
|
+
dispatch({ type: "RESET" });
|
|
487
|
+
setDisplayTime(
|
|
488
|
+
isCountUp ? duration - result.newInitialRemainingTime : result.newInitialRemainingTime
|
|
489
|
+
);
|
|
490
|
+
} else {
|
|
491
|
+
dispatch({ type: "RESET" });
|
|
492
|
+
setDisplayTime(isCountUp ? 0 : duration);
|
|
493
|
+
}
|
|
494
|
+
setSmoothProgress(0);
|
|
495
|
+
dispatch({ type: "PLAY" });
|
|
496
|
+
}, delay);
|
|
497
|
+
}
|
|
498
|
+
return () => {
|
|
499
|
+
if (repeatTimeoutRef.current) {
|
|
500
|
+
clearTimeout(repeatTimeoutRef.current);
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
}, [state.status, state.duration, duration, isCountUp]);
|
|
504
|
+
useEffect(() => {
|
|
505
|
+
if (prevDisplayTimeRef.current !== displayTime) {
|
|
506
|
+
prevDisplayTimeRef.current = displayTime;
|
|
507
|
+
const rt = isCountUp ? duration - displayTime : displayTime;
|
|
508
|
+
onUpdateRef.current?.(rt);
|
|
509
|
+
}
|
|
510
|
+
}, [displayTime, duration, isCountUp]);
|
|
511
|
+
const path = useMemo(
|
|
512
|
+
() => getCirclePath(size, strokeWidth, rotation),
|
|
513
|
+
[size, strokeWidth, rotation]
|
|
514
|
+
);
|
|
515
|
+
const pathLength = useMemo(
|
|
516
|
+
() => getPathLength(size, strokeWidth),
|
|
517
|
+
[size, strokeWidth]
|
|
518
|
+
);
|
|
519
|
+
const elapsedTime = isCountUp ? displayTime : duration - displayTime;
|
|
520
|
+
const remainingTime = isCountUp ? duration - displayTime : displayTime;
|
|
521
|
+
const isComplete = state.status === "completed";
|
|
522
|
+
const arcProgress = isCountUp ? smoothProgress : 1 - smoothProgress;
|
|
523
|
+
const strokeDashoffset = getStrokeDashoffset(pathLength, arcProgress);
|
|
524
|
+
const color = getStrokeColor(colors, colorsTime, remainingTime, duration);
|
|
525
|
+
const progress = smoothProgress;
|
|
526
|
+
return {
|
|
527
|
+
path,
|
|
528
|
+
pathLength,
|
|
529
|
+
stroke: color,
|
|
530
|
+
strokeDashoffset,
|
|
531
|
+
remainingTime,
|
|
532
|
+
elapsedTime,
|
|
533
|
+
progress,
|
|
534
|
+
color,
|
|
535
|
+
size,
|
|
536
|
+
strokeWidth,
|
|
537
|
+
isComplete,
|
|
538
|
+
isPlaying: state.status === "playing",
|
|
539
|
+
animationScale,
|
|
540
|
+
animationOpacity,
|
|
541
|
+
_play: play,
|
|
542
|
+
_pause: pause,
|
|
543
|
+
_reset: reset,
|
|
544
|
+
_getState: () => ({
|
|
545
|
+
remainingTime,
|
|
546
|
+
elapsedTime,
|
|
547
|
+
isPlaying: state.status === "playing",
|
|
548
|
+
progress,
|
|
549
|
+
isComplete
|
|
550
|
+
})
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
var TimerGroupContext = createContext(
|
|
554
|
+
null
|
|
555
|
+
);
|
|
556
|
+
function useTimerGroupContext() {
|
|
557
|
+
return useContext(TimerGroupContext);
|
|
558
|
+
}
|
|
559
|
+
function useTimerGroup(options) {
|
|
560
|
+
const {
|
|
561
|
+
mode = "sequential",
|
|
562
|
+
staggerDelay: _staggerDelay = 0,
|
|
563
|
+
isPlaying = false,
|
|
564
|
+
timerCount,
|
|
565
|
+
onGroupComplete,
|
|
566
|
+
onTimerComplete
|
|
567
|
+
} = options;
|
|
568
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
569
|
+
const [groupState, setGroupState] = useState("idle");
|
|
570
|
+
const [completedTimers, setCompletedTimers] = useState(
|
|
571
|
+
() => /* @__PURE__ */ new Set()
|
|
572
|
+
);
|
|
573
|
+
const [timerStates, setTimerStates] = useState(
|
|
574
|
+
() => Array.from({ length: timerCount }, () => ({
|
|
575
|
+
isPlaying: false,
|
|
576
|
+
isComplete: false
|
|
577
|
+
}))
|
|
578
|
+
);
|
|
579
|
+
const onGroupCompleteRef = useRef(onGroupComplete);
|
|
580
|
+
const onTimerCompleteRef = useRef(onTimerComplete);
|
|
581
|
+
onGroupCompleteRef.current = onGroupComplete;
|
|
582
|
+
onTimerCompleteRef.current = onTimerComplete;
|
|
583
|
+
useEffect(() => {
|
|
584
|
+
if (isPlaying) {
|
|
585
|
+
setGroupState("playing");
|
|
586
|
+
} else if (groupState === "playing") {
|
|
587
|
+
setGroupState("paused");
|
|
588
|
+
}
|
|
589
|
+
}, [isPlaying, groupState]);
|
|
590
|
+
useEffect(() => {
|
|
591
|
+
if (groupState !== "playing") {
|
|
592
|
+
setTimerStates(
|
|
593
|
+
(prev) => prev.map((t) => ({ ...t, isPlaying: false }))
|
|
594
|
+
);
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
setTimerStates(
|
|
598
|
+
(prev) => prev.map((t, i) => {
|
|
599
|
+
if (completedTimers.has(i)) {
|
|
600
|
+
return { ...t, isPlaying: false, isComplete: true };
|
|
601
|
+
}
|
|
602
|
+
switch (mode) {
|
|
603
|
+
case "sequential":
|
|
604
|
+
return { ...t, isPlaying: i === activeIndex };
|
|
605
|
+
case "parallel":
|
|
606
|
+
return { ...t, isPlaying: true };
|
|
607
|
+
case "staggered":
|
|
608
|
+
return { ...t, isPlaying: true };
|
|
609
|
+
default:
|
|
610
|
+
return t;
|
|
611
|
+
}
|
|
612
|
+
})
|
|
613
|
+
);
|
|
614
|
+
}, [groupState, mode, activeIndex, completedTimers, timerCount]);
|
|
615
|
+
const handleTimerComplete = useCallback(
|
|
616
|
+
(index) => {
|
|
617
|
+
onTimerCompleteRef.current?.(index);
|
|
618
|
+
setCompletedTimers((prev) => {
|
|
619
|
+
const next = new Set(prev);
|
|
620
|
+
next.add(index);
|
|
621
|
+
if (next.size >= timerCount) {
|
|
622
|
+
setGroupState("completed");
|
|
623
|
+
onGroupCompleteRef.current?.();
|
|
624
|
+
}
|
|
625
|
+
return next;
|
|
626
|
+
});
|
|
627
|
+
if (mode === "sequential") {
|
|
628
|
+
setActiveIndex((prev) => Math.min(prev + 1, timerCount - 1));
|
|
629
|
+
}
|
|
630
|
+
},
|
|
631
|
+
[mode, timerCount]
|
|
632
|
+
);
|
|
633
|
+
const playAll = useCallback(() => {
|
|
634
|
+
setGroupState("playing");
|
|
635
|
+
}, []);
|
|
636
|
+
const pauseAll = useCallback(() => {
|
|
637
|
+
setGroupState("paused");
|
|
638
|
+
}, []);
|
|
639
|
+
const resetAll = useCallback(() => {
|
|
640
|
+
setActiveIndex(0);
|
|
641
|
+
setGroupState("idle");
|
|
642
|
+
setCompletedTimers(/* @__PURE__ */ new Set());
|
|
643
|
+
setTimerStates(
|
|
644
|
+
Array.from({ length: timerCount }, () => ({
|
|
645
|
+
isPlaying: false,
|
|
646
|
+
isComplete: false
|
|
647
|
+
}))
|
|
648
|
+
);
|
|
649
|
+
}, [timerCount]);
|
|
650
|
+
return {
|
|
651
|
+
timers: timerStates,
|
|
652
|
+
activeIndex,
|
|
653
|
+
playAll,
|
|
654
|
+
pauseAll,
|
|
655
|
+
resetAll,
|
|
656
|
+
groupState,
|
|
657
|
+
_handleTimerComplete: handleTimerComplete
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// src/accessibility/ariaLabels.ts
|
|
662
|
+
function generateAriaLabel(remainingTime, isCountUp) {
|
|
663
|
+
const minutes = Math.floor(remainingTime / 60);
|
|
664
|
+
const seconds = remainingTime % 60;
|
|
665
|
+
const timeStr = minutes > 0 ? `${minutes} minute${minutes !== 1 ? "s" : ""} and ${seconds} second${seconds !== 1 ? "s" : ""}` : `${seconds} second${seconds !== 1 ? "s" : ""}`;
|
|
666
|
+
return isCountUp ? `${timeStr} elapsed` : `${timeStr} remaining`;
|
|
667
|
+
}
|
|
668
|
+
function resolveAriaLabel(ariaLabel, info, isCountUp) {
|
|
669
|
+
if (typeof ariaLabel === "function") {
|
|
670
|
+
return ariaLabel(info);
|
|
671
|
+
}
|
|
672
|
+
if (typeof ariaLabel === "string") {
|
|
673
|
+
return ariaLabel;
|
|
674
|
+
}
|
|
675
|
+
return generateAriaLabel(info.remainingTime, isCountUp);
|
|
676
|
+
}
|
|
677
|
+
var DEFAULT_THRESHOLDS_PERCENT = [50, 25, 10];
|
|
678
|
+
var DEFAULT_THRESHOLDS_SECONDS = [5];
|
|
679
|
+
function getAnnouncementText(remainingTime, isCountUp) {
|
|
680
|
+
if (remainingTime <= 0) {
|
|
681
|
+
return isCountUp ? "Timer complete" : "Time is up";
|
|
682
|
+
}
|
|
683
|
+
const minutes = Math.floor(remainingTime / 60);
|
|
684
|
+
const seconds = remainingTime % 60;
|
|
685
|
+
if (minutes > 0) {
|
|
686
|
+
return isCountUp ? `${minutes} minutes and ${seconds} seconds elapsed` : `${minutes} minutes and ${seconds} seconds remaining`;
|
|
687
|
+
}
|
|
688
|
+
return isCountUp ? `${seconds} seconds elapsed` : `${seconds} seconds remaining`;
|
|
689
|
+
}
|
|
690
|
+
function shouldAnnounce(remainingTime, prevRemainingTime, duration, announceInterval) {
|
|
691
|
+
if (remainingTime === prevRemainingTime) return false;
|
|
692
|
+
if (remainingTime <= 0 && prevRemainingTime > 0) return true;
|
|
693
|
+
if (announceInterval > 0) {
|
|
694
|
+
const prevBucket = Math.floor(prevRemainingTime / announceInterval);
|
|
695
|
+
const currentBucket = Math.floor(remainingTime / announceInterval);
|
|
696
|
+
if (prevBucket !== currentBucket) return true;
|
|
697
|
+
}
|
|
698
|
+
for (const percent of DEFAULT_THRESHOLDS_PERCENT) {
|
|
699
|
+
const threshold = Math.floor(duration * (percent / 100));
|
|
700
|
+
if (prevRemainingTime > threshold && remainingTime <= threshold) {
|
|
701
|
+
return true;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
for (const threshold of DEFAULT_THRESHOLDS_SECONDS) {
|
|
705
|
+
if (prevRemainingTime > threshold && remainingTime <= threshold) {
|
|
706
|
+
return true;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
return false;
|
|
710
|
+
}
|
|
711
|
+
function useAnnouncer(remainingTime, duration, announceInterval, isCountUp) {
|
|
712
|
+
const prevTimeRef = useRef(remainingTime);
|
|
713
|
+
const announcementRef = useRef("");
|
|
714
|
+
useEffect(() => {
|
|
715
|
+
if (shouldAnnounce(
|
|
716
|
+
remainingTime,
|
|
717
|
+
prevTimeRef.current,
|
|
718
|
+
duration,
|
|
719
|
+
announceInterval
|
|
720
|
+
)) {
|
|
721
|
+
announcementRef.current = getAnnouncementText(
|
|
722
|
+
remainingTime,
|
|
723
|
+
isCountUp
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
prevTimeRef.current = remainingTime;
|
|
727
|
+
}, [remainingTime, duration, announceInterval, isCountUp]);
|
|
728
|
+
return {
|
|
729
|
+
announcement: announcementRef.current,
|
|
730
|
+
announcerProps: {
|
|
731
|
+
role: "status",
|
|
732
|
+
"aria-live": "polite",
|
|
733
|
+
"aria-atomic": true,
|
|
734
|
+
style: {
|
|
735
|
+
position: "absolute",
|
|
736
|
+
width: 1,
|
|
737
|
+
height: 1,
|
|
738
|
+
padding: 0,
|
|
739
|
+
margin: -1,
|
|
740
|
+
overflow: "hidden",
|
|
741
|
+
clip: "rect(0, 0, 0, 0)",
|
|
742
|
+
whiteSpace: "nowrap",
|
|
743
|
+
borderWidth: 0
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
var QUERY = "(prefers-reduced-motion: reduce)";
|
|
749
|
+
function getInitialState() {
|
|
750
|
+
if (typeof window === "undefined") return false;
|
|
751
|
+
if (typeof window.matchMedia !== "function") return false;
|
|
752
|
+
return window.matchMedia(QUERY).matches;
|
|
753
|
+
}
|
|
754
|
+
function useReducedMotion() {
|
|
755
|
+
const [prefersReducedMotion, setPrefersReducedMotion] = useState(getInitialState);
|
|
756
|
+
useEffect(() => {
|
|
757
|
+
if (typeof window === "undefined") return;
|
|
758
|
+
if (typeof window.matchMedia !== "function") return;
|
|
759
|
+
const mediaQuery = window.matchMedia(QUERY);
|
|
760
|
+
const handler = (event) => {
|
|
761
|
+
setPrefersReducedMotion(event.matches);
|
|
762
|
+
};
|
|
763
|
+
mediaQuery.addEventListener("change", handler);
|
|
764
|
+
return () => {
|
|
765
|
+
mediaQuery.removeEventListener("change", handler);
|
|
766
|
+
};
|
|
767
|
+
}, []);
|
|
768
|
+
return prefersReducedMotion;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
export { TimerGroupContext, _resetForTesting, computeBounce, computePulse, createInitialState, createSpringEasing, easings, generateAriaLabel, getAnnouncementText, getCirclePath, getEasingNames, getPathLength, getStrokeColor, getStrokeDashoffset, hexToRgb, interpolateColor, resolveAriaLabel, resolveEasing, rgbToHex, shouldAnnounce, subscribe, timerReducer, useAnnouncer, useCountdown, useReducedMotion, useTimerGroup, useTimerGroupContext };
|
|
772
|
+
//# sourceMappingURL=index.js.map
|
|
773
|
+
//# sourceMappingURL=index.js.map
|