/** * TimedEvent is a lightweight timer-like class that emits lifecycle events * and progress notifications. It supports starting, pausing, resuming, * cancelling and one-shot or duration-based progress tracking. * * Events dispatched (CustomEvent with detail.timer = this): * - START, PROGRESS, PAUSE, RESUME, COMPLETE, CANCEL * * duration is in milliseconds. Use -1 for an infinite/looping timer. */ export default class TimedEvent extends EventTarget { duration: number; // 0 - unknown | -1 - loop infinitely elapsedTime: number; started: boolean; paused: boolean; cancelled: boolean; nextTick: number | ReturnType | null; lastTick: number; milestones: Set; constructor(duration: number = 0) { super(); this.duration = duration; this.started = false; this.paused = false; this.cancelled = false; this.lastTick = -1; this.elapsedTime = 0; this.milestones = new Set(); this.nextTick = null; } public start() { if (this.started) return; this.started = true; this.lastTick = performance.now(); this.dispatchEvent(this.makeEvent('START')); this.forceTick(); } public pause() { if (this.paused) return; this.forceTick(); this.paused = true; this.dispatchEvent(this.makeEvent('PAUSE')); } public resume() { if (!this.paused) return; this.lastTick = performance.now(); this.paused = false; this.forceTick(); this.dispatchEvent(this.makeEvent('RESUME')); } public cancel() { this.cancelled = true; this.dispatchEvent(this.makeEvent('CANCEL')); } public atPercent(percent: number, context: string = ''): boolean { if (this.duration <= 0) return false; if (this.elapsedTime / this.duration >= percent) { const milestoneName = 'percent-' + percent + '-' + context; if (!this.milestones.has(milestoneName)) { this.milestones.add(milestoneName); return true; } } return false; } public atProgress(time: number, context: string = ''): boolean { if (this.elapsedTime >= time) { const milestoneName = 'elapsed-' + time + '-' + context; if (!this.milestones.has(milestoneName)) { this.milestones.add(milestoneName); return true; } } return false; } public atRemaining(time: number, context: string = ''): boolean { if (this.duration - this.elapsedTime <= time) { const milestoneName = 'remaining-' + time + '-' + context; if (!this.milestones.has(milestoneName)) { this.milestones.add(milestoneName); return true; } } return false; } protected forceTick() { this.cancelNextTick(); this.tick(); } protected tick() { if (!this.started || this.paused || this.cancelled) return; if (this.duration === -1) return; const now = performance.now(); this.elapsedTime += now - this.lastTick; this.lastTick = now; this.dispatchEvent(this.makeEvent('PROGRESS')); if (this.elapsedTime < this.duration) { if (typeof window !== 'undefined' && typeof window.requestAnimationFrame !== 'undefined') { this.nextTick = requestAnimationFrame(() => { this.tick(); }); } else { this.nextTick = setTimeout(() => { this.tick(); }, 1000 / 60); } } else { this.dispatchEvent(this.makeEvent('COMPLETE')); } } protected cancelNextTick() { if (!this.nextTick) return; // nextTick may be an animation frame id (number) or a timer id if (typeof window !== 'undefined' && typeof window.requestAnimationFrame !== 'undefined') { cancelAnimationFrame(this.nextTick as number); } else { clearTimeout(this.nextTick as ReturnType); } this.nextTick = null; } public makeEvent(name: string): CustomEvent { return new CustomEvent(name, { detail: { timer: this } }); } }