@diabolic/pointy 1.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/pointy.js ADDED
@@ -0,0 +1,2183 @@
1
+ /**
2
+ * Pointy - A lightweight tooltip library with animated pointer
3
+ * @version 1.0.0
4
+ * @license MIT
5
+ */
6
+ (function (global, factory) {
7
+ typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
8
+ typeof define === 'function' && define.amd ? define(factory) :
9
+ (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.Pointy = factory());
10
+ })(this, (function () { 'use strict';
11
+
12
+ /**
13
+ * Pointy - A lightweight tooltip library with animated pointer
14
+ *
15
+ * @description A vanilla JavaScript library for creating animated tooltips with a pointing cursor.
16
+ * Features include multi-step tours, multi-message content, autoplay, custom SVG pointers,
17
+ * and extensive customization options.
18
+ *
19
+ * @example
20
+ * const pointy = new Pointy({
21
+ * steps: [
22
+ * { target: '#element1', content: 'Welcome!' },
23
+ * { target: '#element2', content: ['Message 1', 'Message 2'] }
24
+ * ],
25
+ * autoplay: 3000,
26
+ * messageInterval: 2500
27
+ * });
28
+ * pointy.show();
29
+ *
30
+ * @options
31
+ * - steps {Array<{target, content, direction?, duration?}>} - Tour steps
32
+ * - target {string|HTMLElement} - Initial target element
33
+ * - offsetX {number} - Horizontal offset from target (default: 20)
34
+ * - offsetY {number} - Vertical offset from target (default: 16)
35
+ * - trackingFps {number} - Position update FPS, 0 = unlimited (default: 60)
36
+ * - animationDuration {number} - Move animation duration in ms (default: 1000)
37
+ * - introFadeDuration {number} - Initial fade-in duration in ms (default: 1000)
38
+ * - bubbleFadeDuration {number} - Bubble fade-in duration in ms (default: 500)
39
+ * - messageTransitionDuration {number} - Message change animation in ms (default: 500)
40
+ * - easing {string} - Easing name or CSS timing function (default: 'default')
41
+ * - messageInterval {number|null} - Auto-cycle messages interval in ms (default: null)
42
+ * - resetOnComplete {boolean} - Reset to initial position on complete (default: true)
43
+ * - floatingAnimation {boolean} - Enable floating animation (default: true)
44
+ * - initialPosition {string|HTMLElement} - Starting position preset or element (default: 'center')
45
+ * Presets: 'center', 'top-left', 'top-center', 'top-right', 'middle-left',
46
+ * 'middle-right', 'bottom-left', 'bottom-center', 'bottom-right'
47
+ * - initialPositionOffset {number} - Offset from edges for position presets (default: 32)
48
+ * - resetPositionOnHide {boolean} - Reset position when hiding (default: false)
49
+ * - autoplay {number|null} - Auto-advance interval in ms, null = manual (default: null)
50
+ * - autoplayWaitForMessages {boolean} - Wait for all messages before advancing (default: true)
51
+ * - classPrefix {string} - CSS class prefix (default: 'pointy')
52
+ * - classSuffixes {object} - Custom class suffixes
53
+ * - classNames {object} - Full override of class names
54
+ * - cssVarPrefix {string} - CSS variable prefix (default: classPrefix)
55
+ * - pointerSvg {string} - Custom SVG for pointer
56
+ * - onStepChange {function} - Callback on step change
57
+ * - onComplete {function} - Callback on tour complete
58
+ *
59
+ * @events
60
+ * Lifecycle:
61
+ * - beforeShow: Before pointer becomes visible
62
+ * - show: Pointer becomes visible
63
+ * - beforeHide: Before pointer is hidden
64
+ * - hide: Pointer is hidden
65
+ * - destroy: Instance is destroyed
66
+ * - beforeRestart: Before restart
67
+ * - restart: After restart completed
68
+ * - beforeReset: Before reset to initial position
69
+ * - reset: After reset to initial position
70
+ *
71
+ * Navigation:
72
+ * - beforeStepChange: Before step transition
73
+ * - stepChange: After step changed
74
+ * - next: Moving to next step
75
+ * - prev: Moving to previous step
76
+ * - complete: Tour completed (last step finished)
77
+ *
78
+ * Animation:
79
+ * - animationStart: Movement animation started
80
+ * - animationEnd: Movement animation completed
81
+ * - move: Position update started
82
+ * - moveComplete: Position update finished
83
+ * - introAnimationStart: Initial fade-in animation started
84
+ * - introAnimationEnd: Initial fade-in animation completed
85
+ *
86
+ * Content:
87
+ * - contentSet: Content updated via setContent()
88
+ * - messagesSet: New messages array set for step
89
+ * - messageChange: Message changed (manual or auto)
90
+ *
91
+ * Message Cycle:
92
+ * - messageCycleStart: Auto message cycling started
93
+ * - messageCycleStop: Auto message cycling stopped
94
+ * - messageCyclePause: Message cycling paused
95
+ * - messageCycleResume: Message cycling resumed
96
+ * - messageCycleComplete: All messages shown (when autoplayWaitForMessages=true)
97
+ *
98
+ * Point To:
99
+ * - beforePointTo: Before pointing to custom target
100
+ * - pointTo: Pointed to custom target
101
+ * - pointToComplete: Animation to custom target completed
102
+ *
103
+ * Target Tracking:
104
+ * - track: Target position tracked (fires at trackingFps rate when enabled)
105
+ * - targetChange: Target element changed
106
+ * - trackingChange: Tracking enabled/disabled
107
+ * - trackingFpsChange: Tracking FPS changed
108
+ *
109
+ * Autoplay:
110
+ * - autoplayStart: Autoplay started
111
+ * - autoplayStop: Autoplay stopped
112
+ * - autoplayPause: Autoplay paused
113
+ * - autoplayResume: Autoplay resumed
114
+ * - autoplayNext: Auto-advancing to next step
115
+ * - autoplayComplete: Autoplay finished all steps
116
+ * - autoHide: Auto-hide triggered after complete
117
+ * - autoplayChange: Autoplay interval changed
118
+ * - autoplayWaitForMessagesChange: Wait for messages setting changed
119
+ *
120
+ * Configuration Changes:
121
+ * - easingChange: Easing changed
122
+ * - animationDurationChange: Animation duration changed
123
+ * - introFadeDurationChange: Intro fade duration changed
124
+ * - bubbleFadeDurationChange: Bubble fade duration changed
125
+ * - messageIntervalChange: Message interval changed
126
+ * - messageTransitionDurationChange: Message transition duration changed
127
+ * - offsetChange: Offset changed
128
+ * - resetOnCompleteChange: Reset on complete setting changed
129
+ * - hideOnCompleteChange: Hide on complete setting changed
130
+ * - hideOnCompleteDelayChange: Hide on complete delay changed
131
+ * - floatingAnimationChange: Floating animation setting changed
132
+ * - initialPositionChange: Initial position changed
133
+ * - initialPositionOffsetChange: Initial position offset changed
134
+ * - pointerSvgChange: Pointer SVG changed
135
+ *
136
+ * @methods
137
+ * Core: show(), hide(), destroy()
138
+ * Navigation: next(), prev(), goToStep(index), reset(), restart()
139
+ * Custom Target: pointTo(target, content?, direction?)
140
+ * Content: setContent(content), nextMessage(), prevMessage(), goToMessage(index)
141
+ * Message Cycle: startMessageCycle(interval?), stopMessageCycle(), pauseMessageCycle(), resumeMessageCycle()
142
+ * Autoplay: startAutoplay(), stopAutoplay(), pauseAutoplay(), resumeAutoplay()
143
+ * Animation: animateToInitialPosition()
144
+ * Events: on(event, callback), off(event, callback)
145
+ * State: getCurrentStep(), getTotalSteps(), isAutoplayActive(), isAutoplayPaused()
146
+ *
147
+ * Setters (all emit change events):
148
+ * setEasing(), setAnimationDuration(), setIntroFadeDuration(), setBubbleFadeDuration(),
149
+ * setMessageInterval(), setMessageTransitionDuration(), setOffset(), setResetOnComplete(),
150
+ * setFloatingAnimation(), setInitialPosition(), setInitialPositionOffset(),
151
+ * setAutoplay(), setAutoplayWaitForMessages()
152
+ *
153
+ * Static Helpers:
154
+ * - Pointy.renderContent(element, content) - Render string/JSX to element
155
+ * - Pointy.getTargetElement(target) - Get DOM element from selector/element
156
+ * - Pointy.generateClassNames(prefix, suffixes) - Generate class name object
157
+ */
158
+ class Pointy {
159
+ // Named easing presets (only custom ones, CSS built-ins like 'ease', 'linear' work directly)
160
+ static EASINGS = {
161
+ // Default - smooth deceleration
162
+ 'default': 'cubic-bezier(0, 0.55, 0.45, 1)',
163
+ // Material Design
164
+ 'standard': 'cubic-bezier(0.4, 0, 0.2, 1)',
165
+ 'decelerate': 'cubic-bezier(0, 0, 0.2, 1)',
166
+ 'accelerate': 'cubic-bezier(0.4, 0, 1, 1)',
167
+ // Expressive
168
+ 'bounce': 'cubic-bezier(0.68, -0.55, 0.265, 1.55)',
169
+ 'elastic': 'cubic-bezier(0.68, -0.6, 0.32, 1.6)',
170
+ 'smooth': 'cubic-bezier(0.45, 0, 0.55, 1)',
171
+ 'snap': 'cubic-bezier(0.5, 0, 0.1, 1)',
172
+ // Classic easing curves
173
+ 'expo-out': 'cubic-bezier(0.19, 1, 0.22, 1)',
174
+ 'circ-out': 'cubic-bezier(0.075, 0.82, 0.165, 1)',
175
+ 'back-out': 'cubic-bezier(0.175, 0.885, 0.32, 1.275)'
176
+ };
177
+
178
+ static POINTER_SVG = `
179
+ <svg xmlns="http://www.w3.org/2000/svg" width="33" height="33" fill="none" viewBox="0 0 33 33">
180
+ <g filter="url(#pointy-shadow)">
181
+ <path fill="#0a1551" d="m18.65 24.262 6.316-14.905c.467-1.103-.645-2.215-1.748-1.747L8.313 13.925c-1.088.461-1.083 2.004.008 2.459l5.049 2.104c.325.135.583.393.718.718l2.104 5.049c.454 1.09 1.997 1.095 2.458.007"/>
182
+ </g>
183
+ <defs>
184
+ <filter id="pointy-shadow" width="32.576" height="32.575" x="0" y="0" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse">
185
+ <feFlood flood-opacity="0" result="BackgroundImageFix"/>
186
+ <feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
187
+ <feOffset/>
188
+ <feGaussianBlur stdDeviation="3.75"/>
189
+ <feComposite in2="hardAlpha" operator="out"/>
190
+ <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
191
+ <feBlend in2="BackgroundImageFix" result="effect1_dropShadow"/>
192
+ <feBlend in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
193
+ </filter>
194
+ </defs>
195
+ </svg>
196
+ `;
197
+
198
+ // Default CSS class prefix
199
+ static DEFAULT_CLASS_PREFIX = 'pointy';
200
+
201
+ // Default class name suffixes - will be combined with prefix
202
+ static DEFAULT_CLASS_SUFFIXES = {
203
+ container: 'container',
204
+ pointer: 'pointer',
205
+ bubble: 'bubble',
206
+ bubbleText: 'bubble-text',
207
+ hidden: 'hidden',
208
+ visible: 'visible',
209
+ moving: 'moving'
210
+ };
211
+
212
+ /**
213
+ * Generate class names from prefix and suffixes
214
+ * @param {string} prefix - Class prefix
215
+ * @param {object} suffixes - Custom suffixes to override defaults
216
+ * @returns {object} - Full class names
217
+ */
218
+ static generateClassNames(prefix = Pointy.DEFAULT_CLASS_PREFIX, suffixes = {}) {
219
+ const s = { ...Pointy.DEFAULT_CLASS_SUFFIXES, ...suffixes };
220
+ return {
221
+ container: `${prefix}-${s.container}`,
222
+ pointer: `${prefix}-${s.pointer}`,
223
+ bubble: `${prefix}-${s.bubble}`,
224
+ bubbleText: `${prefix}-${s.bubbleText}`,
225
+ hidden: `${prefix}-${s.hidden}`,
226
+ visible: `${prefix}-${s.visible}`,
227
+ moving: `${prefix}-${s.moving}`
228
+ };
229
+ }
230
+
231
+ // CSS variable prefix (defaults to class prefix)
232
+ static DEFAULT_CSS_VAR_PREFIX = 'pointy';
233
+
234
+ /**
235
+ * Generate CSS styles with custom class names
236
+ * @param {object} classNames - Class names object
237
+ * @param {string} cssVarPrefix - CSS variable prefix
238
+ * @returns {string} - CSS styles
239
+ */
240
+ static generateStyles(classNames, cssVarPrefix = 'pointy') {
241
+ const cn = classNames;
242
+ const vp = cssVarPrefix;
243
+
244
+ return `
245
+ @keyframes ${cn.container}-float {
246
+ 0%, 100% {
247
+ transform: translateY(0px);
248
+ }
249
+ 50% {
250
+ transform: translateY(-8px);
251
+ }
252
+ }
253
+
254
+ .${cn.container} {
255
+ position: absolute;
256
+ z-index: 9999;
257
+ font-family: 'Circular', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
258
+ --${vp}-duration: 1000ms;
259
+ --${vp}-easing: cubic-bezier(0, 0.55, 0.45, 1);
260
+ --${vp}-bubble-fade: 500ms;
261
+ transition: left var(--${vp}-duration) var(--${vp}-easing), top var(--${vp}-duration) var(--${vp}-easing), opacity 0.3s ease;
262
+ animation: ${cn.container}-float 3s ease-in-out infinite;
263
+ }
264
+
265
+ .${cn.container}.${cn.moving} {
266
+ animation-play-state: paused;
267
+ }
268
+
269
+ .${cn.container}.${cn.hidden} {
270
+ opacity: 0;
271
+ pointer-events: none;
272
+ }
273
+
274
+ .${cn.container}.${cn.visible} {
275
+ opacity: 1;
276
+ }
277
+
278
+ .${cn.pointer} {
279
+ width: 33px;
280
+ height: 33px;
281
+ transition: transform var(--${vp}-duration) var(--${vp}-easing);
282
+ }
283
+
284
+ .${cn.bubble} {
285
+ position: absolute;
286
+ right: 26px;
287
+ top: 0;
288
+ background: #0a1551;
289
+ color: white;
290
+ padding: 4px 12px;
291
+ border-radius: 14px;
292
+ font-size: 14px;
293
+ line-height: 20px;
294
+ font-weight: 400;
295
+ box-shadow: 0 4px 15px rgba(0, 0, 0, 0.25);
296
+ white-space: nowrap;
297
+ overflow: hidden;
298
+ transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1), height 0.5s cubic-bezier(0.4, 0, 0.2, 1), transform var(--${vp}-duration) var(--${vp}-easing), opacity var(--${vp}-bubble-fade) ease;
299
+ }
300
+
301
+ .${cn.bubbleText} {
302
+ display: inline-block;
303
+ }
304
+ `;
305
+ }
306
+
307
+ // Track which style sets have been injected (by stringified classNames)
308
+ static injectedStyleKeys = new Set();
309
+
310
+ static injectStyles(classNames, cssVarPrefix = 'pointy') {
311
+ const styleKey = JSON.stringify(classNames) + cssVarPrefix;
312
+ if (Pointy.injectedStyleKeys.has(styleKey)) return;
313
+
314
+ // Inject Circular font (only once)
315
+ if (!Pointy.fontInjected) {
316
+ const fontLink = document.createElement('link');
317
+ fontLink.href = 'https://cdn.jotfor.ms/fonts/?family=Circular';
318
+ fontLink.rel = 'stylesheet';
319
+ document.head.appendChild(fontLink);
320
+ Pointy.fontInjected = true;
321
+ }
322
+
323
+ const styleElement = document.createElement('style');
324
+ styleElement.id = `pointy-styles-${styleKey.length}`;
325
+ styleElement.textContent = Pointy.generateStyles(classNames, cssVarPrefix);
326
+ document.head.appendChild(styleElement);
327
+ Pointy.injectedStyleKeys.add(styleKey);
328
+ }
329
+
330
+ static fontInjected = false;
331
+
332
+ static getTargetElement(target) {
333
+ if (typeof target === 'string') {
334
+ return document.querySelector(target);
335
+ }
336
+ return target;
337
+ }
338
+
339
+ static animateText(element, newContent, duration = 500, bubble = null, onComplete = null) {
340
+ const hideTime = duration * 0.4;
341
+ const revealTime = duration * 0.6;
342
+
343
+ // Measure new content dimensions using a hidden container
344
+ let newWidth = null;
345
+ let newHeight = null;
346
+ if (bubble) {
347
+ const measureDiv = document.createElement('div');
348
+ measureDiv.style.cssText = 'visibility: hidden; position: absolute; padding: 4px 12px;';
349
+ Pointy.renderContent(measureDiv, newContent);
350
+ bubble.appendChild(measureDiv);
351
+ // Add horizontal padding (12px left + 12px right = 24px)
352
+ newWidth = measureDiv.offsetWidth;
353
+ newHeight = measureDiv.offsetHeight;
354
+ bubble.removeChild(measureDiv);
355
+
356
+ // Set current dimensions explicitly to enable transition
357
+ const currentWidth = bubble.offsetWidth;
358
+ const currentHeight = bubble.offsetHeight;
359
+ bubble.style.width = currentWidth + 'px';
360
+ bubble.style.height = currentHeight + 'px';
361
+ }
362
+
363
+ // Phase 1: Hide old text (clip from left, disappears to right)
364
+ element.style.transition = `clip-path ${hideTime}ms ease-in`;
365
+ element.style.clipPath = 'inset(0 0 0 100%)';
366
+
367
+ setTimeout(() => {
368
+ // Change content while fully clipped
369
+ Pointy.renderContent(element, newContent);
370
+
371
+ // Animate bubble to new size
372
+ if (bubble && newWidth !== null) {
373
+ bubble.style.width = newWidth + 'px';
374
+ bubble.style.height = newHeight + 'px';
375
+ }
376
+
377
+ // Prepare for reveal (start fully clipped from right)
378
+ element.style.transition = 'none';
379
+ element.style.clipPath = 'inset(0 100% 0 0)';
380
+
381
+ // Force reflow
382
+ element.offsetHeight;
383
+
384
+ // Phase 2: Reveal new text (appears from left to right)
385
+ element.style.transition = `clip-path ${revealTime}ms ease-out`;
386
+ element.style.clipPath = 'inset(0 0 0 0)';
387
+
388
+ // Clear dimensions after transition so it can auto-size
389
+ if (bubble) {
390
+ setTimeout(() => {
391
+ bubble.style.width = '';
392
+ bubble.style.height = '';
393
+ }, revealTime + 100);
394
+ }
395
+
396
+ if (onComplete) onComplete();
397
+ }, hideTime);
398
+ }
399
+
400
+ /**
401
+ * Render content to an element - supports string (HTML) and React/JSX elements
402
+ * @param {HTMLElement} element - Target element
403
+ * @param {string|object} content - String (HTML) or React element
404
+ */
405
+ static renderContent(element, content) {
406
+ // Check if it's a React element (has $$typeof symbol)
407
+ if (content && typeof content === 'object' && content.$$typeof) {
408
+ // React element - use ReactDOM if available
409
+ if (typeof ReactDOM !== 'undefined' && ReactDOM.createRoot) {
410
+ // React 18+
411
+ const root = ReactDOM.createRoot(element);
412
+ root.render(content);
413
+ element._reactRoot = root;
414
+ } else if (typeof ReactDOM !== 'undefined' && ReactDOM.render) {
415
+ // React 17 and below
416
+ ReactDOM.render(content, element);
417
+ } else {
418
+ console.warn('Pointy: React element passed but ReactDOM not found');
419
+ element.innerHTML = String(content);
420
+ }
421
+ } else {
422
+ // String content - render as HTML
423
+ element.innerHTML = content;
424
+ }
425
+ }
426
+
427
+ constructor(options = {}) {
428
+ // CSS class prefix
429
+ this.classPrefix = options.classPrefix || Pointy.DEFAULT_CLASS_PREFIX;
430
+
431
+ // Generate class names from prefix and optional custom suffixes
432
+ this.classNames = Pointy.generateClassNames(this.classPrefix, options.classSuffixes);
433
+
434
+ // Allow full override of class names if provided
435
+ if (options.classNames) {
436
+ this.classNames = { ...this.classNames, ...options.classNames };
437
+ }
438
+
439
+ // CSS variable prefix for avoiding conflicts (defaults to class prefix)
440
+ this.cssVarPrefix = options.cssVarPrefix || this.classPrefix;
441
+
442
+ // Custom SVG for pointer
443
+ this.pointerSvg = options.pointerSvg || Pointy.POINTER_SVG;
444
+
445
+ Pointy.injectStyles(this.classNames, this.cssVarPrefix);
446
+
447
+ this.steps = options.steps || [];
448
+ this.offsetX = options.offsetX !== undefined ? options.offsetX : 20;
449
+ this.offsetY = options.offsetY !== undefined ? options.offsetY : 16;
450
+ this.tracking = options.tracking !== undefined ? options.tracking : true; // Enable/disable position tracking
451
+ this.trackingFps = options.trackingFps !== undefined ? options.trackingFps : 60; // 0 = unlimited
452
+ this.animationDuration = options.animationDuration !== undefined ? options.animationDuration : 1000; // ms
453
+ this.introFadeDuration = options.introFadeDuration !== undefined ? options.introFadeDuration : 1000; // ms - pointer fade-in
454
+ this.bubbleFadeDuration = options.bubbleFadeDuration !== undefined ? options.bubbleFadeDuration : 500; // ms - bubble fade-in
455
+ this.messageTransitionDuration = options.messageTransitionDuration !== undefined ? options.messageTransitionDuration : 500; // ms - message change animation
456
+ this.easing = options.easing !== undefined ? options.easing : 'default'; // easing name or custom cubic-bezier
457
+ this.resetOnComplete = options.resetOnComplete !== undefined ? options.resetOnComplete : true; // Reset to initial position on complete
458
+ this.floatingAnimation = options.floatingAnimation !== undefined ? options.floatingAnimation : true; // Enable floating animation
459
+ this.initialPosition = options.initialPosition || 'center'; // 'center', 'top-left', 'top-center', 'top-right', 'middle-left', 'middle-right', 'bottom-left', 'bottom-center', 'bottom-right'
460
+ this.initialPositionOffset = options.initialPositionOffset !== undefined ? options.initialPositionOffset : 32; // Offset from edges for non-center positions
461
+ this.resetPositionOnHide = options.resetPositionOnHide !== undefined ? options.resetPositionOnHide : false; // Reset position when hiding
462
+ this.autoplay = options.autoplay || null; // Auto-advance steps interval in ms, null = manual
463
+ this.autoplayEnabled = options.autoplayEnabled !== undefined ? options.autoplayEnabled : false; // Whether autoplay is enabled
464
+ this.autoplayWaitForMessages = options.autoplayWaitForMessages !== undefined ? options.autoplayWaitForMessages : true; // Wait for all messages before advancing
465
+ this.hideOnComplete = options.hideOnComplete !== undefined ? options.hideOnComplete : true; // Auto-hide after tour completes
466
+ this.hideOnCompleteDelay = options.hideOnCompleteDelay !== undefined ? options.hideOnCompleteDelay : null; // Delay before hide (null = use animationDuration)
467
+ this._autoplayTimeoutId = null;
468
+ this._autoplayPaused = false;
469
+ this._messagesCompletedForStep = false; // Track if all messages have been shown
470
+ this._hideOnCompleteTimeoutId = null; // Timer for auto-hide
471
+ this.onStepChange = options.onStepChange;
472
+ this.onComplete = options.onComplete;
473
+
474
+ // Event listeners
475
+ this._eventListeners = {};
476
+
477
+ this.targetElement = options.target ? Pointy.getTargetElement(options.target) : null;
478
+ this.currentStepIndex = 0;
479
+ this.currentMessageIndex = 0;
480
+ this.currentMessages = []; // Current step's messages array
481
+ this.messageInterval = options.messageInterval || null; // Auto-cycle interval in ms, null = manual
482
+ this._messageIntervalId = null;
483
+ this.isVisible = false;
484
+ this.isPointingUp = true; // Always start pointing up
485
+ this.lastTargetY = null;
486
+ this.manualDirection = null; // 'up', 'down', or null (auto)
487
+ this.moveTimeout = null;
488
+ this._hasShownBefore = false; // For intro animation
489
+
490
+ // If steps provided, use first step
491
+ if (this.steps.length > 0) {
492
+ this.targetElement = Pointy.getTargetElement(this.steps[0].target);
493
+ }
494
+
495
+ // Create DOM elements - pointer is the anchor, bubble positioned relative to it
496
+ this.container = document.createElement('div');
497
+ this.container.className = `${this.classNames.container} ${this.classNames.hidden}`;
498
+ this.container.style.setProperty(`--${this.cssVarPrefix}-duration`, `${this.animationDuration}ms`);
499
+ this.container.style.setProperty(`--${this.cssVarPrefix}-easing`, this._resolveEasing(this.easing));
500
+ this.container.style.setProperty(`--${this.cssVarPrefix}-bubble-fade`, `${this.bubbleFadeDuration}ms`);
501
+
502
+ // Apply floating animation setting
503
+ if (!this.floatingAnimation) {
504
+ this.container.style.animationPlayState = 'paused';
505
+ }
506
+
507
+ this.pointer = document.createElement('div');
508
+ this.pointer.className = this.classNames.pointer;
509
+ Pointy.renderContent(this.pointer, this.pointerSvg);
510
+
511
+ this.bubble = document.createElement('div');
512
+ this.bubble.className = this.classNames.bubble;
513
+
514
+ this.bubbleText = document.createElement('span');
515
+ this.bubbleText.className = this.classNames.bubbleText;
516
+
517
+ // Handle initial content (string or array) - no animation on init
518
+ if (this.steps.length > 0) {
519
+ const content = this.steps[0].content;
520
+ this.currentMessages = Array.isArray(content) ? content : [content];
521
+ Pointy.renderContent(this.bubbleText, this.currentMessages[0]);
522
+ } else {
523
+ const content = options.content || '';
524
+ this.currentMessages = Array.isArray(content) ? content : [content];
525
+ Pointy.renderContent(this.bubbleText, this.currentMessages[0]);
526
+ }
527
+ this.currentMessageIndex = 0;
528
+ this.bubble.appendChild(this.bubbleText);
529
+
530
+ // Assemble - pointer first, bubble positioned via CSS
531
+ this.container.appendChild(this.pointer);
532
+ this.container.appendChild(this.bubble);
533
+
534
+ // Bind methods
535
+ this.updatePosition = this.updatePosition.bind(this);
536
+ this._trackPosition = this._trackPosition.bind(this);
537
+ this._lastTrackTime = 0;
538
+
539
+ // Listen for resize and scroll
540
+ window.addEventListener('resize', this.updatePosition);
541
+ window.addEventListener('scroll', this.updatePosition);
542
+ }
543
+
544
+ _trackPosition() {
545
+ if (!this.isVisible || !this.targetElement) {
546
+ this._rafId = null;
547
+ return;
548
+ }
549
+
550
+ // FPS limiting: 0 = unlimited (monitor refresh rate)
551
+ if (this.trackingFps > 0) {
552
+ const now = performance.now();
553
+ const interval = 1000 / this.trackingFps;
554
+ if (now - this._lastTrackTime >= interval) {
555
+ this._lastTrackTime = now;
556
+ this.updatePosition();
557
+ this._emit('track', { target: this.targetElement, timestamp: now });
558
+ }
559
+ } else {
560
+ this.updatePosition();
561
+ this._emit('track', { target: this.targetElement, timestamp: performance.now() });
562
+ }
563
+
564
+ this._rafId = requestAnimationFrame(this._trackPosition);
565
+ }
566
+
567
+ _startTracking() {
568
+ if (!this.tracking) return; // Tracking disabled
569
+ if (!this._rafId) {
570
+ this._trackPosition();
571
+ }
572
+ }
573
+
574
+ _stopTracking() {
575
+ if (this._rafId) {
576
+ cancelAnimationFrame(this._rafId);
577
+ this._rafId = null;
578
+ }
579
+ }
580
+
581
+ updatePosition() {
582
+ if (!this.targetElement) return;
583
+
584
+ const targetRect = this.targetElement.getBoundingClientRect();
585
+ const scrollX = window.scrollX;
586
+ const scrollY = window.scrollY;
587
+
588
+ // Manual direction takes priority
589
+ if (this.manualDirection !== null) {
590
+ this.isPointingUp = this.manualDirection === 'up';
591
+ } else {
592
+ // Auto: Check if target is above or below previous position
593
+ const currentTargetY = targetRect.top + scrollY;
594
+ if (this.lastTargetY !== null) {
595
+ const threshold = 50;
596
+ if (currentTargetY < this.lastTargetY - threshold) {
597
+ // Target moved UP - pointer below target, pointing up
598
+ this.isPointingUp = true;
599
+ } else if (currentTargetY > this.lastTargetY + threshold) {
600
+ // Target moved DOWN - pointer above target, pointing down
601
+ this.isPointingUp = false;
602
+ }
603
+ }
604
+ this.lastTargetY = currentTargetY;
605
+ }
606
+
607
+ // Pointer tip position varies based on rotation
608
+ // Default SVG (0deg): tip at approximately (25, 8) - points top-right
609
+ // Rotated 90deg: tip at approximately (25, 25) - points bottom-right
610
+ let left, top;
611
+
612
+ if (this.isPointingUp) {
613
+ // Pointer points up (default): pointer's top-right → target's bottom-left
614
+ this.pointer.style.transform = 'rotate(0deg)';
615
+ left = targetRect.left + scrollX - 25 + this.offsetX;
616
+ top = targetRect.bottom + scrollY - 8 - this.offsetY;
617
+
618
+ // Bubble: below pointer
619
+ this.bubble.style.transform = 'translateY(28px)';
620
+ } else {
621
+ // Pointer points down (90deg): pointer's bottom-right → target's top-left
622
+ this.pointer.style.transform = 'rotate(90deg)';
623
+ left = targetRect.left + scrollX - 25 + this.offsetX;
624
+ top = targetRect.top + scrollY - 25 + this.offsetY;
625
+
626
+ // Bubble: above pointer
627
+ const bubbleHeight = this.bubble.offsetHeight || 28;
628
+ this.bubble.style.transform = `translateY(-${bubbleHeight}px)`;
629
+ }
630
+
631
+ this.container.style.left = `${left}px`;
632
+ this.container.style.top = `${top}px`;
633
+ }
634
+
635
+ show() {
636
+ this._emit('beforeShow', { target: this.targetElement });
637
+
638
+ // Cancel any pending auto-hide
639
+ if (this._hideOnCompleteTimeoutId) {
640
+ clearTimeout(this._hideOnCompleteTimeoutId);
641
+ this._hideOnCompleteTimeoutId = null;
642
+ }
643
+
644
+ if (!document.body.contains(this.container)) {
645
+ document.body.appendChild(this.container);
646
+ }
647
+
648
+ // First time showing - start from initial position
649
+ if (!this._hasShownBefore) {
650
+ this._hasShownBefore = true;
651
+
652
+ // Check if starting at first step (no movement needed)
653
+ const isFirstStepStart = this.initialPosition === 'first-step';
654
+
655
+ // Get initial position based on configuration
656
+ const initialPos = this._getInitialPosition();
657
+ const initialX = initialPos.x;
658
+ const initialY = initialPos.y;
659
+
660
+ // Disable all movement transitions, only allow opacity fade
661
+ this.container.style.transition = `opacity ${this.introFadeDuration}ms ease`;
662
+ this.pointer.style.transition = 'none';
663
+ this.bubble.style.transition = 'none';
664
+ this.bubble.style.opacity = '0'; // Hide bubble during intro
665
+ this.container.style.left = `${initialX}px`;
666
+ this.container.style.top = `${initialY}px`;
667
+
668
+ // Set initial pointer/bubble orientation based on direction
669
+ if (isFirstStepStart && initialPos.isPointingUp !== undefined) {
670
+ // Use the direction from first step
671
+ this.isPointingUp = initialPos.isPointingUp;
672
+ if (this.isPointingUp) {
673
+ this.pointer.style.transform = 'rotate(0deg)';
674
+ this.bubble.style.transform = 'translateY(28px)';
675
+ } else {
676
+ this.pointer.style.transform = 'rotate(90deg)';
677
+ const bubbleHeight = this.bubble.offsetHeight || 28;
678
+ this.bubble.style.transform = `translateY(-${bubbleHeight}px)`;
679
+ }
680
+ } else {
681
+ // Default: pointing up
682
+ this.pointer.style.transform = 'rotate(0deg)';
683
+ this.bubble.style.transform = 'translateY(0)';
684
+ }
685
+
686
+ this.container.style.display = 'flex';
687
+ this.container.offsetHeight; // Force reflow
688
+ this.container.classList.remove(this.classNames.hidden);
689
+ this.container.classList.add(this.classNames.visible);
690
+ this.isVisible = true;
691
+
692
+ this._emit('introAnimationStart', { duration: this.introFadeDuration, initialPosition: { x: initialX, y: initialY } });
693
+
694
+ // Re-enable full transitions after fade-in completes, then animate to target
695
+ setTimeout(() => {
696
+ this._emit('introAnimationEnd', { initialPosition: { x: initialX, y: initialY } });
697
+
698
+ // If starting at first-step, no movement needed - just show bubble
699
+ if (isFirstStepStart) {
700
+ // Keep transitions disabled - we're already in the right place
701
+ this.container.style.transition = 'none';
702
+ this.pointer.style.transition = 'none';
703
+
704
+ this._startTracking();
705
+
706
+ // Show bubble immediately with fade
707
+ this.bubble.style.transition = `opacity ${this.bubbleFadeDuration}ms ease`;
708
+ this.bubble.style.opacity = '1';
709
+
710
+ // Re-enable transitions after bubble fade completes
711
+ setTimeout(() => {
712
+ this.container.style.transition = '';
713
+ this.pointer.style.transition = '';
714
+ this.bubble.style.transition = '';
715
+ }, this.bubbleFadeDuration);
716
+
717
+ // Start message cycle if multi-message
718
+ if (this.messageInterval && this.currentMessages.length > 1 && !this._messageIntervalId) {
719
+ this._startMessageCycle();
720
+ }
721
+
722
+ // Schedule autoplay if enabled
723
+ this._scheduleAutoplay();
724
+
725
+ this._emit('show', { target: this.targetElement, isIntro: true, isFirstStep: true });
726
+ } else {
727
+ // Normal flow: animate to target
728
+ this.container.style.transition = '';
729
+ this.pointer.style.transition = '';
730
+ this.bubble.style.transition = 'none'; // Keep bubble transition off during movement
731
+ this.updatePosition();
732
+
733
+ // Start tracking
734
+ this._startTracking();
735
+
736
+ // Show bubble with fade after arriving at first target
737
+ setTimeout(() => {
738
+ this.bubble.style.transition = '';
739
+ this.bubble.style.opacity = '1';
740
+
741
+ // Start message cycle if multi-message
742
+ if (this.messageInterval && this.currentMessages.length > 1 && !this._messageIntervalId) {
743
+ this._startMessageCycle();
744
+ }
745
+
746
+ // Schedule autoplay if enabled
747
+ this._scheduleAutoplay();
748
+ }, this.animationDuration);
749
+
750
+ this._emit('show', { target: this.targetElement, isIntro: true, isFirstStep: false });
751
+ }
752
+ }, this.introFadeDuration); // Wait for intro fade to complete
753
+
754
+ return;
755
+ }
756
+
757
+ this.container.style.display = 'flex';
758
+ // Force reflow before adding visible class for animation
759
+ this.container.offsetHeight;
760
+ this.container.classList.remove(this.classNames.hidden);
761
+ this.container.classList.add(this.classNames.visible);
762
+ this.isVisible = true;
763
+ this.updatePosition();
764
+
765
+ // Start tracking
766
+ this._startTracking();
767
+
768
+ // Resume message cycle if it was paused by hide
769
+ if (this._messageCyclePausedByHide && this.messageInterval && this.currentMessages.length > 1) {
770
+ this._startMessageCycle();
771
+ this._messageCyclePausedByHide = false;
772
+ } else if (this.messageInterval && this.currentMessages.length > 1 && !this._messageIntervalId) {
773
+ // Start message cycle if multi-message and not already running
774
+ this._startMessageCycle();
775
+ }
776
+
777
+ // Resume autoplay if it was active before hide
778
+ if (this._wasAutoplayActiveBeforeHide) {
779
+ this._scheduleAutoplay();
780
+ this._wasAutoplayActiveBeforeHide = false;
781
+ }
782
+
783
+ this._emit('show', { target: this.targetElement, isIntro: false });
784
+ }
785
+
786
+ hide() {
787
+ this._emit('beforeHide', { target: this.targetElement });
788
+
789
+ this.container.classList.remove(this.classNames.visible);
790
+ this.container.classList.add(this.classNames.hidden);
791
+ this.isVisible = false;
792
+
793
+ // Only reset position state if option is enabled
794
+ if (this.resetPositionOnHide) {
795
+ this._hasShownBefore = false;
796
+ }
797
+
798
+ // Stop tracking
799
+ this._stopTracking();
800
+
801
+ // Pause message cycle (don't stop - can be resumed)
802
+ if (this._messageIntervalId) {
803
+ this._stopMessageCycle();
804
+ this._messageCyclePausedByHide = true;
805
+ }
806
+
807
+ // Pause autoplay timer (don't change _autoplayPaused state - just clear timer)
808
+ // This preserves the autoplay state so it can resume on show()
809
+ this._wasAutoplayActiveBeforeHide = this.autoplay && this.autoplayEnabled && !this._autoplayPaused;
810
+ this._stopAutoplay();
811
+
812
+ this._emit('hide', { target: this.targetElement });
813
+ }
814
+
815
+ /**
816
+ * Restart the intro animation from initial position
817
+ * Useful for demoing initial position changes
818
+ */
819
+ restart() {
820
+ this._emit('beforeRestart', {});
821
+ this._hasShownBefore = false;
822
+
823
+ if (this.isVisible) {
824
+ this.container.classList.remove(this.classNames.visible);
825
+ this.container.classList.add(this.classNames.hidden);
826
+ this._stopTracking();
827
+ this._stopMessageCycle();
828
+ this.isVisible = false;
829
+
830
+ // Small delay to allow CSS transition to complete
831
+ setTimeout(() => {
832
+ this.goToStep(0);
833
+ this.show();
834
+ this._emit('restart', {});
835
+ }, 50);
836
+ } else {
837
+ this.goToStep(0);
838
+ this.show();
839
+ this._emit('restart', {});
840
+ }
841
+ }
842
+
843
+ destroy() {
844
+ this._emit('destroy', {});
845
+
846
+ if (document.body.contains(this.container)) {
847
+ document.body.removeChild(this.container);
848
+ }
849
+ window.removeEventListener('resize', this.updatePosition);
850
+ window.removeEventListener('scroll', this.updatePosition);
851
+
852
+ // Stop tracking
853
+ this._stopTracking();
854
+
855
+ // Clear auto-hide timer
856
+ if (this._hideOnCompleteTimeoutId) {
857
+ clearTimeout(this._hideOnCompleteTimeoutId);
858
+ this._hideOnCompleteTimeoutId = null;
859
+ }
860
+
861
+ // Clear all event listeners
862
+ this._eventListeners = {};
863
+ }
864
+
865
+ /**
866
+ * Reset pointer to initial position and optionally go back to first step
867
+ * @param {boolean} goToFirstStep - Whether to reset step index to 0 (default: true)
868
+ */
869
+ reset(goToFirstStep = true) {
870
+ this._emit('beforeReset', { currentStep: this.currentStepIndex });
871
+
872
+ // Stop message cycle
873
+ this._stopMessageCycle();
874
+
875
+ // Clear hide on complete timeout if exists
876
+ if (this._hideOnCompleteTimeoutId) {
877
+ clearTimeout(this._hideOnCompleteTimeoutId);
878
+ this._hideOnCompleteTimeoutId = null;
879
+ }
880
+
881
+ // Pause floating animation during movement
882
+ this.container.classList.add(this.classNames.moving);
883
+ if (this.moveTimeout) clearTimeout(this.moveTimeout);
884
+
885
+ // Animate to initial position
886
+ const { x: initialX, y: initialY } = this._getInitialPosition();
887
+
888
+ this.container.style.left = `${initialX}px`;
889
+ this.container.style.top = `${initialY}px`;
890
+
891
+ // Fade out bubble during reset
892
+ this.bubble.style.opacity = '0';
893
+
894
+ // Reset step index if requested
895
+ if (goToFirstStep && this.steps.length > 0) {
896
+ this.currentStepIndex = 0;
897
+ const firstStep = this.steps[0];
898
+ this.targetElement = Pointy.getTargetElement(firstStep.target);
899
+ this.currentMessages = Array.isArray(firstStep.content) ? firstStep.content : [firstStep.content];
900
+ this.currentMessageIndex = 0;
901
+ Pointy.renderContent(this.bubbleText, this.currentMessages[0]);
902
+ }
903
+
904
+ // After animation completes
905
+ this.moveTimeout = setTimeout(() => {
906
+ this.container.classList.remove(this.classNames.moving);
907
+ this._hasShownBefore = false; // Allow intro animation again
908
+ this._emit('reset', { stepIndex: this.currentStepIndex });
909
+ }, this.animationDuration);
910
+ }
911
+
912
+ /**
913
+ * Set resetOnComplete option
914
+ * @param {boolean} reset - Whether to reset on complete
915
+ */
916
+ setResetOnComplete(reset) {
917
+ const oldValue = this.resetOnComplete;
918
+ if (oldValue === reset) return;
919
+
920
+ this.resetOnComplete = reset;
921
+ this._emit('resetOnCompleteChange', { from: oldValue, to: reset });
922
+ }
923
+
924
+ /**
925
+ * Set floating animation enabled/disabled
926
+ * @param {boolean} enabled - Whether floating animation is enabled
927
+ */
928
+ setFloatingAnimation(enabled) {
929
+ const oldValue = this.floatingAnimation;
930
+ if (oldValue === enabled) return;
931
+
932
+ this.floatingAnimation = enabled;
933
+
934
+ if (enabled) {
935
+ this.container.style.animationPlayState = '';
936
+ } else {
937
+ this.container.style.animationPlayState = 'paused';
938
+ }
939
+
940
+ this._emit('floatingAnimationChange', { from: oldValue, to: enabled });
941
+ }
942
+
943
+ /**
944
+ * Check if floating animation is enabled
945
+ * @returns {boolean}
946
+ */
947
+ isFloatingAnimationEnabled() {
948
+ return this.floatingAnimation;
949
+ }
950
+
951
+ /**
952
+ * Set tracking enabled/disabled
953
+ * @param {boolean} enabled - Whether position tracking is enabled
954
+ */
955
+ setTracking(enabled) {
956
+ const oldValue = this.tracking;
957
+ if (oldValue === enabled) return;
958
+
959
+ this.tracking = enabled;
960
+
961
+ if (enabled && this.isVisible) {
962
+ this._startTracking();
963
+ } else if (!enabled) {
964
+ this._stopTracking();
965
+ }
966
+
967
+ this._emit('trackingChange', { from: oldValue, to: enabled });
968
+ }
969
+
970
+ /**
971
+ * Set tracking FPS
972
+ * @param {number} fps - Frames per second (0 = unlimited)
973
+ */
974
+ setTrackingFps(fps) {
975
+ const oldValue = this.trackingFps;
976
+ if (oldValue === fps) return;
977
+
978
+ this.trackingFps = fps;
979
+ this._emit('trackingFpsChange', { from: oldValue, to: fps });
980
+ }
981
+
982
+ /**
983
+ * Check if tracking is enabled
984
+ * @returns {boolean}
985
+ */
986
+ isTrackingEnabled() {
987
+ return this.tracking;
988
+ }
989
+
990
+ updateContent(newContent, animate = true) {
991
+ // Skip if content is the same (only for string content)
992
+ if (typeof newContent === 'string' && this.bubbleText.innerHTML === newContent) {
993
+ return;
994
+ }
995
+
996
+ if (animate) {
997
+ Pointy.animateText(this.bubbleText, newContent, this.messageTransitionDuration, this.bubble, () => {
998
+ this.updatePosition();
999
+ });
1000
+ } else {
1001
+ Pointy.renderContent(this.bubbleText, newContent);
1002
+ this.updatePosition();
1003
+ }
1004
+ }
1005
+
1006
+ /**
1007
+ * Set messages for current step (internal)
1008
+ * @param {string|string[]} content - Single message or array of messages
1009
+ * @param {boolean} fromStepChange - Whether this is from a step change (internal)
1010
+ * @private
1011
+ */
1012
+ _setMessages(content, fromStepChange = false) {
1013
+ // Check if cycle was running before
1014
+ const wasRunning = this._messageIntervalId !== null;
1015
+
1016
+ // Stop any existing auto-cycle
1017
+ this._stopMessageCycle();
1018
+
1019
+ // Normalize to array
1020
+ this.currentMessages = Array.isArray(content) ? content : [content];
1021
+ this.currentMessageIndex = 0;
1022
+
1023
+ // Show first message
1024
+ this.updateContent(this.currentMessages[0]);
1025
+
1026
+ // Only auto-start cycle on step changes, not on manual setContent
1027
+ // For manual setContent, user must call resumeMessageCycle()
1028
+ if (fromStepChange && this.messageInterval && this.currentMessages.length > 1) {
1029
+ this._startMessageCycle();
1030
+ } else if (wasRunning && this.currentMessages.length > 1) {
1031
+ // Mark as paused so user can resume
1032
+ this._messageCyclePaused = true;
1033
+ }
1034
+
1035
+ this._emit('messagesSet', {
1036
+ messages: this.currentMessages,
1037
+ total: this.currentMessages.length,
1038
+ cyclePaused: this._messageCyclePaused === true
1039
+ });
1040
+ }
1041
+
1042
+ /**
1043
+ * Start auto-cycling through messages
1044
+ * @private
1045
+ */
1046
+ _startMessageCycle() {
1047
+ this._messagesCompletedForStep = false;
1048
+ this._messageIntervalId = setInterval(() => {
1049
+ // Check if we're at the last message and autoplay is waiting for messages
1050
+ const isLastMessage = this.currentMessageIndex === this.currentMessages.length - 1;
1051
+
1052
+ if (isLastMessage && this.autoplay && this.autoplayWaitForMessages) {
1053
+ // Don't cycle back to first message - stop here and advance to next step
1054
+ this._stopMessageCycle();
1055
+ this._messagesCompletedForStep = true;
1056
+ this._emit('messageCycleComplete', { stepIndex: this.currentStepIndex, totalMessages: this.currentMessages.length });
1057
+ // Trigger autoplay advance after a brief pause
1058
+ this._scheduleAutoplayAfterMessages();
1059
+ } else {
1060
+ // Normal message cycling
1061
+ this.nextMessage(true); // true = isAuto
1062
+ }
1063
+ }, this.messageInterval);
1064
+ this._emit('messageCycleStart', { interval: this.messageInterval, totalMessages: this.currentMessages.length });
1065
+ }
1066
+
1067
+ /**
1068
+ * Stop auto-cycling through messages
1069
+ * @private
1070
+ */
1071
+ _stopMessageCycle() {
1072
+ if (this._messageIntervalId) {
1073
+ clearInterval(this._messageIntervalId);
1074
+ this._messageIntervalId = null;
1075
+ this._emit('messageCycleStop', { currentIndex: this.currentMessageIndex });
1076
+ }
1077
+ }
1078
+
1079
+ /**
1080
+ * Pause auto message cycling (can be resumed later)
1081
+ */
1082
+ pauseMessageCycle() {
1083
+ if (this._messageIntervalId) {
1084
+ clearInterval(this._messageIntervalId);
1085
+ this._messageIntervalId = null;
1086
+ this._messageCyclePaused = true;
1087
+ this._emit('messageCyclePause', { currentIndex: this.currentMessageIndex });
1088
+ }
1089
+ }
1090
+
1091
+ /**
1092
+ * Resume paused message cycling
1093
+ * @returns {boolean} - True if resumed, false if not paused or no interval set
1094
+ */
1095
+ resumeMessageCycle() {
1096
+ if (this._messageCyclePaused && this.messageInterval && this.currentMessages.length > 1) {
1097
+ this._messageCyclePaused = false;
1098
+ this._startMessageCycle();
1099
+ this._emit('messageCycleResume', { currentIndex: this.currentMessageIndex });
1100
+ return true;
1101
+ }
1102
+ return false;
1103
+ }
1104
+
1105
+ /**
1106
+ * Start message cycling manually (regardless of messageInterval setting)
1107
+ * @param {number} interval - Optional interval in ms, uses current messageInterval if not provided
1108
+ * @returns {boolean} - True if started, false if already running or no messages
1109
+ */
1110
+ startMessageCycle(interval) {
1111
+ if (this._messageIntervalId) return false; // Already running
1112
+ if (this.currentMessages.length <= 1) return false; // No point cycling single message
1113
+
1114
+ if (interval !== undefined) {
1115
+ this.messageInterval = interval;
1116
+ }
1117
+
1118
+ if (!this.messageInterval) return false;
1119
+
1120
+ this._messageCyclePaused = false;
1121
+ this._startMessageCycle();
1122
+ return true;
1123
+ }
1124
+
1125
+ /**
1126
+ * Stop message cycling completely
1127
+ * @returns {boolean} - True if stopped, false if not running
1128
+ */
1129
+ stopMessageCycle() {
1130
+ if (this._messageIntervalId) {
1131
+ this._stopMessageCycle();
1132
+ this._messageCyclePaused = false;
1133
+ return true;
1134
+ }
1135
+ return false;
1136
+ }
1137
+
1138
+ /**
1139
+ * Check if message cycling is currently active
1140
+ * @returns {boolean}
1141
+ */
1142
+ isMessageCycleActive() {
1143
+ return this._messageIntervalId !== null;
1144
+ }
1145
+
1146
+ /**
1147
+ * Check if message cycling is paused
1148
+ * @returns {boolean}
1149
+ */
1150
+ isMessageCyclePaused() {
1151
+ return this._messageCyclePaused === true;
1152
+ }
1153
+
1154
+ /**
1155
+ * Go to next message in current step
1156
+ * @param {boolean} isAuto - Whether this is from auto-cycle (internal use)
1157
+ * @returns {boolean} - True if moved to next, false if at end
1158
+ */
1159
+ nextMessage(isAuto = false) {
1160
+ if (this.currentMessages.length <= 1) return false;
1161
+
1162
+ const previousIndex = this.currentMessageIndex;
1163
+ this.currentMessageIndex = (this.currentMessageIndex + 1) % this.currentMessages.length;
1164
+
1165
+ this.updateContent(this.currentMessages[this.currentMessageIndex]);
1166
+
1167
+ this._emit('messageChange', {
1168
+ fromIndex: previousIndex,
1169
+ toIndex: this.currentMessageIndex,
1170
+ message: this.currentMessages[this.currentMessageIndex],
1171
+ total: this.currentMessages.length,
1172
+ isAuto: isAuto
1173
+ });
1174
+
1175
+ return true;
1176
+ }
1177
+
1178
+ /**
1179
+ * Go to previous message in current step
1180
+ * @returns {boolean} - True if moved to previous, false if at start
1181
+ */
1182
+ prevMessage() {
1183
+ if (this.currentMessages.length <= 1) return false;
1184
+
1185
+ const previousIndex = this.currentMessageIndex;
1186
+ this.currentMessageIndex = (this.currentMessageIndex - 1 + this.currentMessages.length) % this.currentMessages.length;
1187
+
1188
+ this.updateContent(this.currentMessages[this.currentMessageIndex]);
1189
+
1190
+ this._emit('messageChange', {
1191
+ fromIndex: previousIndex,
1192
+ toIndex: this.currentMessageIndex,
1193
+ message: this.currentMessages[this.currentMessageIndex],
1194
+ total: this.currentMessages.length
1195
+ });
1196
+
1197
+ return true;
1198
+ }
1199
+
1200
+ /**
1201
+ * Go to a specific message by index
1202
+ * @param {number} index - Message index
1203
+ */
1204
+ goToMessage(index) {
1205
+ if (index < 0 || index >= this.currentMessages.length) return;
1206
+
1207
+ const previousIndex = this.currentMessageIndex;
1208
+ this.currentMessageIndex = index;
1209
+
1210
+ this.updateContent(this.currentMessages[this.currentMessageIndex]);
1211
+
1212
+ this._emit('messageChange', {
1213
+ fromIndex: previousIndex,
1214
+ toIndex: this.currentMessageIndex,
1215
+ message: this.currentMessages[this.currentMessageIndex],
1216
+ total: this.currentMessages.length
1217
+ });
1218
+ }
1219
+
1220
+ /**
1221
+ * Get current message index
1222
+ * @returns {number}
1223
+ */
1224
+ getCurrentMessage() {
1225
+ return this.currentMessageIndex;
1226
+ }
1227
+
1228
+ /**
1229
+ * Get total messages in current step
1230
+ * @returns {number}
1231
+ */
1232
+ getTotalMessages() {
1233
+ return this.currentMessages.length;
1234
+ }
1235
+
1236
+ /**
1237
+ * Set content programmatically (replaces current messages)
1238
+ * @param {string|string[]} content - Single message or array of messages
1239
+ * @param {boolean} animate - Whether to animate the change (default: true)
1240
+ */
1241
+ setContent(content, animate = true) {
1242
+ // Check if cycle was running before
1243
+ const wasRunning = this._messageIntervalId !== null;
1244
+
1245
+ if (animate) {
1246
+ this._setMessages(content, false); // false = not from step change
1247
+ } else {
1248
+ // Stop any existing auto-cycle
1249
+ this._stopMessageCycle();
1250
+
1251
+ // Normalize to array
1252
+ this.currentMessages = Array.isArray(content) ? content : [content];
1253
+ this.currentMessageIndex = 0;
1254
+
1255
+ // Show first message without animation
1256
+ Pointy.renderContent(this.bubbleText, this.currentMessages[0]);
1257
+ this.updatePosition();
1258
+
1259
+ // Mark as paused if cycle was running (user must call resumeMessageCycle)
1260
+ if (wasRunning && this.currentMessages.length > 1) {
1261
+ this._messageCyclePaused = true;
1262
+ }
1263
+ }
1264
+
1265
+ this._emit('contentSet', {
1266
+ messages: this.currentMessages,
1267
+ total: this.currentMessages.length,
1268
+ animated: animate,
1269
+ cyclePaused: this._messageCyclePaused === true
1270
+ });
1271
+ }
1272
+
1273
+ /**
1274
+ * Set message auto-cycle interval
1275
+ * @param {number|null} interval - Interval in ms, null to disable
1276
+ */
1277
+ setMessageInterval(interval) {
1278
+ const oldInterval = this.messageInterval;
1279
+ if (oldInterval === interval) return;
1280
+
1281
+ this.messageInterval = interval;
1282
+ this._stopMessageCycle();
1283
+
1284
+ if (interval && this.currentMessages.length > 1) {
1285
+ this._startMessageCycle();
1286
+ }
1287
+
1288
+ this._emit('messageIntervalChange', { from: oldInterval, to: interval });
1289
+ }
1290
+
1291
+ updateTarget(newTarget) {
1292
+ const oldTarget = this.targetElement;
1293
+ this.targetElement = Pointy.getTargetElement(newTarget);
1294
+ this.updatePosition();
1295
+ this._emit('targetChange', { from: oldTarget, to: this.targetElement });
1296
+ }
1297
+
1298
+ setOffset(offsetX, offsetY) {
1299
+ const oldOffsetX = this.offsetX;
1300
+ const oldOffsetY = this.offsetY;
1301
+ if (oldOffsetX === offsetX && oldOffsetY === offsetY) return;
1302
+
1303
+ this.offsetX = offsetX;
1304
+ this.offsetY = offsetY;
1305
+ this.updatePosition();
1306
+ this._emit('offsetChange', { from: { x: oldOffsetX, y: oldOffsetY }, to: { x: offsetX, y: offsetY } });
1307
+ }
1308
+
1309
+ /**
1310
+ * Set the animation duration for transitions
1311
+ * @param {number} duration - Duration in milliseconds
1312
+ */
1313
+ setAnimationDuration(duration) {
1314
+ const oldDuration = this.animationDuration;
1315
+ if (oldDuration === duration) return;
1316
+
1317
+ this.animationDuration = duration;
1318
+ this.container.style.setProperty(`--${this.cssVarPrefix}-duration`, `${duration}ms`);
1319
+ this._emit('animationDurationChange', { from: oldDuration, to: duration });
1320
+ }
1321
+
1322
+ /**
1323
+ * Set the intro fade duration (pointer appearing from center)
1324
+ * @param {number} duration - Duration in milliseconds
1325
+ */
1326
+ setIntroFadeDuration(duration) {
1327
+ const oldDuration = this.introFadeDuration;
1328
+ if (oldDuration === duration) return;
1329
+
1330
+ this.introFadeDuration = duration;
1331
+ this._emit('introFadeDurationChange', { from: oldDuration, to: duration });
1332
+ }
1333
+
1334
+ /**
1335
+ * Set the bubble fade duration
1336
+ * @param {number} duration - Duration in milliseconds
1337
+ */
1338
+ setBubbleFadeDuration(duration) {
1339
+ const oldDuration = this.bubbleFadeDuration;
1340
+ if (oldDuration === duration) return;
1341
+
1342
+ this.bubbleFadeDuration = duration;
1343
+ this.container.style.setProperty(`--${this.cssVarPrefix}-bubble-fade`, `${duration}ms`);
1344
+ this._emit('bubbleFadeDurationChange', { from: oldDuration, to: duration });
1345
+ }
1346
+
1347
+ /**
1348
+ * Get the initial position coordinates based on initialPosition setting
1349
+ * @returns {{x: number, y: number, isPointingUp?: boolean}} - Position coordinates and optional direction
1350
+ */
1351
+ _getInitialPosition() {
1352
+ const offset = this.initialPositionOffset;
1353
+ const w = window.innerWidth;
1354
+ const h = window.innerHeight;
1355
+
1356
+ // Special preset: first-step - calculate exact position like updatePosition does
1357
+ if (this.initialPosition === 'first-step' && this.steps.length > 0) {
1358
+ const firstStep = this.steps[0];
1359
+ const firstTarget = Pointy.getTargetElement(firstStep.target);
1360
+ if (firstTarget) {
1361
+ const targetRect = firstTarget.getBoundingClientRect();
1362
+ const scrollX = window.scrollX;
1363
+ const scrollY = window.scrollY;
1364
+
1365
+ // Check step direction: 'up', 'down', or auto (default to 'up')
1366
+ const isPointingUp = firstStep.direction !== 'down';
1367
+
1368
+ let left, top;
1369
+ if (isPointingUp) {
1370
+ // Pointing up: pointer below target
1371
+ left = targetRect.left + scrollX - 25 + this.offsetX;
1372
+ top = targetRect.bottom + scrollY - 8 - this.offsetY;
1373
+ } else {
1374
+ // Pointing down: pointer above target
1375
+ left = targetRect.left + scrollX - 25 + this.offsetX;
1376
+ top = targetRect.top + scrollY - 25 + this.offsetY;
1377
+ }
1378
+
1379
+ return { x: left, y: top, isPointingUp };
1380
+ }
1381
+ }
1382
+
1383
+ // If initialPosition is an element or selector, get its position
1384
+ if (this.initialPosition && typeof this.initialPosition !== 'string') {
1385
+ // It's a DOM element
1386
+ const rect = this.initialPosition.getBoundingClientRect();
1387
+ return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
1388
+ }
1389
+
1390
+ // Check if it's a selector string (starts with # or .)
1391
+ if (typeof this.initialPosition === 'string' && (this.initialPosition.startsWith('#') || this.initialPosition.startsWith('.'))) {
1392
+ const el = document.querySelector(this.initialPosition);
1393
+ if (el) {
1394
+ const rect = el.getBoundingClientRect();
1395
+ return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
1396
+ }
1397
+ }
1398
+
1399
+ // Preset positions
1400
+ const positions = {
1401
+ 'center': { x: w / 2, y: h / 2 },
1402
+ 'top-left': { x: offset, y: offset },
1403
+ 'top-center': { x: w / 2, y: offset },
1404
+ 'top-right': { x: w - offset, y: offset },
1405
+ 'middle-left': { x: offset, y: h / 2 },
1406
+ 'middle-right': { x: w - offset, y: h / 2 },
1407
+ 'bottom-left': { x: offset, y: h - offset },
1408
+ 'bottom-center': { x: w / 2, y: h - offset },
1409
+ 'bottom-right': { x: w - offset, y: h - offset }
1410
+ };
1411
+
1412
+ return positions[this.initialPosition] || positions['center'];
1413
+ }
1414
+
1415
+ /**
1416
+ * Set the initial position
1417
+ * @param {string|HTMLElement} position - Position preset ('center', 'top-left', 'first-step', etc.), CSS selector, or DOM element
1418
+ */
1419
+ setInitialPosition(position) {
1420
+ const validPresets = ['center', 'top-left', 'top-center', 'top-right', 'middle-left', 'middle-right', 'bottom-left', 'bottom-center', 'bottom-right', 'first-step'];
1421
+
1422
+ // Validate if it's a string preset
1423
+ if (typeof position === 'string' && !position.startsWith('#') && !position.startsWith('.') && !validPresets.includes(position)) {
1424
+ console.warn(`Invalid initial position: ${position}. Valid presets: ${validPresets.join(', ')}. Or use a CSS selector or DOM element.`);
1425
+ return;
1426
+ }
1427
+
1428
+ const oldPosition = this.initialPosition;
1429
+ if (oldPosition === position) return;
1430
+
1431
+ this.initialPosition = position;
1432
+ this._emit('initialPositionChange', { from: oldPosition, to: position });
1433
+ }
1434
+
1435
+ /**
1436
+ * Animate to initial position (useful for demoing position changes)
1437
+ * Flow: teleport hidden to new position -> fade in -> move to target
1438
+ */
1439
+ animateToInitialPosition() {
1440
+ if (!this.isVisible) return;
1441
+
1442
+ const { x, y } = this._getInitialPosition();
1443
+
1444
+ // Stop tracking during animation
1445
+ this._stopTracking();
1446
+
1447
+ // Step 1: Teleport hidden to new initial position (no transition, instant)
1448
+ this.container.style.cssText = `
1449
+ position: fixed;
1450
+ left: ${x}px;
1451
+ top: ${y}px;
1452
+ opacity: 0;
1453
+ transition: none;
1454
+ `;
1455
+ this.bubble.style.opacity = '0';
1456
+ this.bubble.style.transition = 'none';
1457
+
1458
+ // Force reflow to apply instant position change
1459
+ this.container.offsetHeight;
1460
+
1461
+ // Step 2: Fade in at new initial position (only opacity animates)
1462
+ this.container.style.transition = `opacity ${this.introFadeDuration}ms ease`;
1463
+ this.container.style.opacity = '1';
1464
+
1465
+ // Step 3: After fade in completes, animate movement to target
1466
+ setTimeout(() => {
1467
+ // Restore normal transitions for movement
1468
+ this.container.style.transition = '';
1469
+ this.container.style.cssText = '';
1470
+ this.container.style.left = `${x}px`;
1471
+ this.container.style.top = `${y}px`;
1472
+
1473
+ // Force reflow
1474
+ this.container.offsetHeight;
1475
+
1476
+ // Now animate to target
1477
+ this.updatePosition();
1478
+ this._startTracking();
1479
+
1480
+ // Show bubble after arriving at target
1481
+ setTimeout(() => {
1482
+ this.bubble.style.transition = '';
1483
+ this.bubble.style.opacity = '1';
1484
+ }, this.animationDuration);
1485
+ }, this.introFadeDuration);
1486
+ }
1487
+
1488
+ /**
1489
+ * Set the initial position offset from edges (for non-center positions)
1490
+ * @param {number} offset - Offset in pixels (can be negative to go off-screen)
1491
+ */
1492
+ setInitialPositionOffset(offset) {
1493
+ const oldOffset = this.initialPositionOffset;
1494
+ if (oldOffset === offset) return;
1495
+
1496
+ this.initialPositionOffset = offset;
1497
+ this._emit('initialPositionOffsetChange', { from: oldOffset, to: offset });
1498
+ }
1499
+
1500
+ /**
1501
+ * Resolve easing name to CSS value
1502
+ * @param {string} easing - Easing name or custom cubic-bezier
1503
+ * @returns {string} - CSS easing value
1504
+ */
1505
+ _resolveEasing(easing) {
1506
+ // If it's a named preset, return the value
1507
+ if (Pointy.EASINGS[easing]) {
1508
+ return Pointy.EASINGS[easing];
1509
+ }
1510
+ // Otherwise assume it's a custom cubic-bezier or CSS easing
1511
+ return easing;
1512
+ }
1513
+
1514
+ /**
1515
+ * Set the animation easing
1516
+ * @param {string} easing - Easing name (e.g., 'bounce', 'elastic') or custom cubic-bezier
1517
+ */
1518
+ setEasing(easing) {
1519
+ const oldEasing = this.easing;
1520
+ if (oldEasing === easing) return;
1521
+
1522
+ this.easing = easing;
1523
+ this.container.style.setProperty(`--${this.cssVarPrefix}-easing`, this._resolveEasing(easing));
1524
+ this._emit('easingChange', { from: oldEasing, to: easing });
1525
+ }
1526
+
1527
+ /**
1528
+ * Set the message transition duration
1529
+ * @param {number} duration - Duration in milliseconds
1530
+ */
1531
+ setMessageTransitionDuration(duration) {
1532
+ const oldDuration = this.messageTransitionDuration;
1533
+ if (oldDuration === duration) return;
1534
+
1535
+ this.messageTransitionDuration = duration;
1536
+ this._emit('messageTransitionDurationChange', { from: oldDuration, to: duration });
1537
+ }
1538
+
1539
+ /**
1540
+ * Set custom pointer SVG
1541
+ * @param {string|React.ReactNode} svg - SVG markup string or React element
1542
+ */
1543
+ setPointerSvg(svg) {
1544
+ const oldSvg = this.pointerSvg;
1545
+ if (oldSvg === svg) return;
1546
+
1547
+ this.pointerSvg = svg;
1548
+ Pointy.renderContent(this.pointer, svg);
1549
+ this._emit('pointerSvgChange', { from: oldSvg, to: svg });
1550
+ }
1551
+
1552
+ /**
1553
+ * Get current pointer SVG
1554
+ * @returns {string|React.ReactNode} - Current SVG markup or React element
1555
+ */
1556
+ getPointerSvg() {
1557
+ return this.pointerSvg;
1558
+ }
1559
+
1560
+ /**
1561
+ * Get the class names object
1562
+ * @returns {object} - Current class names
1563
+ */
1564
+ getClassNames() {
1565
+ return { ...this.classNames };
1566
+ }
1567
+
1568
+ /**
1569
+ * Get the class prefix
1570
+ * @returns {string} - Current class prefix
1571
+ */
1572
+ getClassPrefix() {
1573
+ return this.classPrefix;
1574
+ }
1575
+
1576
+ /**
1577
+ * Get the CSS variable prefix
1578
+ * @returns {string} - Current CSS variable prefix
1579
+ */
1580
+ getCssVarPrefix() {
1581
+ return this.cssVarPrefix;
1582
+ }
1583
+
1584
+ /**
1585
+ * Get list of available easing presets
1586
+ * @returns {string[]} - Array of easing names
1587
+ */
1588
+ static getEasingPresets() {
1589
+ return Object.keys(Pointy.EASINGS);
1590
+ }
1591
+
1592
+ /**
1593
+ * Get list of available initial position presets
1594
+ * @returns {string[]} - Array of position names
1595
+ */
1596
+ static getInitialPositions() {
1597
+ return ['center', 'top-left', 'top-center', 'top-right', 'middle-left', 'middle-right', 'bottom-left', 'bottom-center', 'bottom-right', 'first-step'];
1598
+ }
1599
+
1600
+ goToStep(index) {
1601
+ if (this.steps.length === 0 || index < 0 || index >= this.steps.length) return;
1602
+
1603
+ // Stop any existing autoplay timer
1604
+ this._stopAutoplay();
1605
+
1606
+ // Reset message completion tracking for new step
1607
+ this._messagesCompletedForStep = false;
1608
+
1609
+ const previousIndex = this.currentStepIndex;
1610
+ const previousTarget = this.targetElement;
1611
+ this.currentStepIndex = index;
1612
+ const step = this.steps[this.currentStepIndex];
1613
+
1614
+ this._emit('beforeStepChange', {
1615
+ fromIndex: previousIndex,
1616
+ toIndex: index,
1617
+ step: step,
1618
+ fromTarget: previousTarget
1619
+ });
1620
+
1621
+ // Set direction: step.direction can be 'up', 'down', or undefined (auto)
1622
+ this.manualDirection = step.direction || null;
1623
+
1624
+ // Pause floating animation during movement
1625
+ this.container.classList.add(this.classNames.moving);
1626
+ if (this.moveTimeout) clearTimeout(this.moveTimeout);
1627
+
1628
+ this._emit('animationStart', {
1629
+ fromTarget: previousTarget,
1630
+ toTarget: Pointy.getTargetElement(step.target),
1631
+ type: 'step',
1632
+ stepIndex: index
1633
+ });
1634
+
1635
+ this.moveTimeout = setTimeout(() => {
1636
+ this.container.classList.remove(this.classNames.moving);
1637
+ this._emit('moveComplete', { index: index, step: step, target: this.targetElement });
1638
+ this._emit('animationEnd', {
1639
+ fromTarget: previousTarget,
1640
+ toTarget: this.targetElement,
1641
+ type: 'step',
1642
+ stepIndex: index
1643
+ });
1644
+
1645
+ // Schedule next step if autoplay is enabled
1646
+ this._scheduleAutoplay();
1647
+ }, this.animationDuration);
1648
+
1649
+ this._emit('move', { index: index, step: step });
1650
+
1651
+ this._setMessages(step.content, true); // true = from step change, auto-start cycle
1652
+ this.targetElement = Pointy.getTargetElement(step.target);
1653
+ this.updatePosition();
1654
+
1655
+ this._emit('stepChange', {
1656
+ fromIndex: previousIndex,
1657
+ toIndex: index,
1658
+ step: step,
1659
+ target: this.targetElement
1660
+ });
1661
+
1662
+ if (this.onStepChange) {
1663
+ this.onStepChange(this.currentStepIndex, step);
1664
+ }
1665
+ }
1666
+
1667
+ /**
1668
+ * Schedule the next autoplay step
1669
+ * @private
1670
+ */
1671
+ _scheduleAutoplay() {
1672
+ if (!this.autoplay || !this.autoplayEnabled || this._autoplayPaused || !this.isVisible) return;
1673
+
1674
+ const step = this.steps[this.currentStepIndex];
1675
+ const hasMultipleMessages = this.currentMessages.length > 1 && this.messageInterval;
1676
+
1677
+ // If waiting for messages and step has multiple messages with auto-cycle, don't schedule here
1678
+ // The _startMessageCycle will handle advancing after all messages are shown
1679
+ if (this.autoplayWaitForMessages && hasMultipleMessages) {
1680
+ return; // Wait for message cycle to complete
1681
+ }
1682
+
1683
+ // Use step-specific duration if provided, otherwise use global autoplay
1684
+ const duration = step.duration !== undefined ? step.duration : this.autoplay;
1685
+
1686
+ if (duration && duration > 0) {
1687
+ this._autoplayTimeoutId = setTimeout(() => {
1688
+ if (!this._autoplayPaused && this.isVisible && this.autoplayEnabled) {
1689
+ this._emit('autoplayNext', { fromIndex: this.currentStepIndex, duration });
1690
+ this.next();
1691
+ }
1692
+ }, duration);
1693
+ }
1694
+ }
1695
+
1696
+ /**
1697
+ * Schedule autoplay advance after message cycle completes
1698
+ * @private
1699
+ */
1700
+ _scheduleAutoplayAfterMessages() {
1701
+ if (!this.autoplay || !this.autoplayEnabled || this._autoplayPaused || !this.isVisible) return;
1702
+
1703
+ // Use a short delay before advancing (user can read the last message during messageInterval already)
1704
+ const delay = 300; // Brief pause after last message before advancing
1705
+
1706
+ this._autoplayTimeoutId = setTimeout(() => {
1707
+ if (!this._autoplayPaused && this.isVisible && this._messagesCompletedForStep) {
1708
+ this._emit('autoplayNext', { fromIndex: this.currentStepIndex, afterMessages: true });
1709
+ this.next();
1710
+ }
1711
+ }, delay);
1712
+ }
1713
+
1714
+ /**
1715
+ * Stop autoplay timer
1716
+ * @private
1717
+ */
1718
+ _stopAutoplay() {
1719
+ if (this._autoplayTimeoutId) {
1720
+ clearTimeout(this._autoplayTimeoutId);
1721
+ this._autoplayTimeoutId = null;
1722
+ }
1723
+ }
1724
+
1725
+ /**
1726
+ * Start autoplay
1727
+ */
1728
+ startAutoplay() {
1729
+ if (!this.autoplay) return;
1730
+ this.autoplayEnabled = true;
1731
+ this._autoplayPaused = false;
1732
+ this._emit('autoplayStart', {});
1733
+ this._scheduleAutoplay();
1734
+ }
1735
+
1736
+ /**
1737
+ * Stop autoplay
1738
+ */
1739
+ stopAutoplay() {
1740
+ this._stopAutoplay();
1741
+ this.autoplayEnabled = false;
1742
+ this._autoplayPaused = false;
1743
+ this._emit('autoplayStop', {});
1744
+ }
1745
+
1746
+ /**
1747
+ * Pause autoplay (can be resumed)
1748
+ */
1749
+ pauseAutoplay() {
1750
+ this._stopAutoplay();
1751
+ this._autoplayPaused = true;
1752
+ this._emit('autoplayPause', {});
1753
+ }
1754
+
1755
+ /**
1756
+ * Resume autoplay after pause
1757
+ */
1758
+ resumeAutoplay() {
1759
+ if (!this._autoplayPaused) return;
1760
+ this._autoplayPaused = false;
1761
+ this._emit('autoplayResume', {});
1762
+ this._scheduleAutoplay();
1763
+ }
1764
+
1765
+ /**
1766
+ * Check if autoplay is currently active
1767
+ * @returns {boolean}
1768
+ */
1769
+ isAutoplayActive() {
1770
+ return this.autoplay && this.autoplayEnabled && !this._autoplayPaused;
1771
+ }
1772
+
1773
+ /**
1774
+ * Check if autoplay is paused
1775
+ * @returns {boolean}
1776
+ */
1777
+ isAutoplayPaused() {
1778
+ return this._autoplayPaused;
1779
+ }
1780
+
1781
+ /**
1782
+ * Set autoplay interval (does not start autoplay, use startAutoplay() for that)
1783
+ * @param {number|null} interval - Interval in ms, or null to disable
1784
+ */
1785
+ setAutoplayInterval(interval) {
1786
+ const oldInterval = this.autoplay;
1787
+ if (oldInterval === interval) return;
1788
+
1789
+ this.autoplay = interval;
1790
+ this._emit('autoplayChange', { from: oldInterval, to: interval });
1791
+
1792
+ // If autoplay is enabled and running, restart with new interval
1793
+ if (this.autoplayEnabled && interval && this.isVisible) {
1794
+ this._stopAutoplay();
1795
+ this._scheduleAutoplay();
1796
+ } else if (!interval) {
1797
+ this._stopAutoplay();
1798
+ this.autoplayEnabled = false;
1799
+ }
1800
+ }
1801
+
1802
+ /**
1803
+ * Set autoplay interval and start autoplay (legacy method, use setAutoplayInterval + startAutoplay instead)
1804
+ * @param {number|null} interval - Interval in ms, or null to disable
1805
+ */
1806
+ setAutoplay(interval) {
1807
+ this.setAutoplayInterval(interval);
1808
+
1809
+ // Start autoplay if interval is set
1810
+ if (interval && this.isVisible) {
1811
+ this.autoplayEnabled = true;
1812
+ this._autoplayPaused = false;
1813
+ this.restart(); // Full restart from initial position
1814
+ }
1815
+ }
1816
+
1817
+ /**
1818
+ * Set whether autoplay should wait for all messages to complete before advancing
1819
+ * @param {boolean} wait - True to wait for messages, false to use normal duration
1820
+ */
1821
+ setAutoplayWaitForMessages(wait) {
1822
+ const oldValue = this.autoplayWaitForMessages;
1823
+ if (oldValue === wait) return;
1824
+
1825
+ this.autoplayWaitForMessages = wait;
1826
+ this._emit('autoplayWaitForMessagesChange', { from: oldValue, to: wait });
1827
+ }
1828
+
1829
+ /**
1830
+ * Set whether to auto-hide after tour completes
1831
+ * @param {boolean} hide - True to auto-hide, false to stay visible
1832
+ */
1833
+ setHideOnComplete(hide) {
1834
+ const oldValue = this.hideOnComplete;
1835
+ if (oldValue === hide) return;
1836
+
1837
+ this.hideOnComplete = hide;
1838
+ this._emit('hideOnCompleteChange', { from: oldValue, to: hide });
1839
+ }
1840
+
1841
+ /**
1842
+ * Set the delay before auto-hiding after complete
1843
+ * @param {number|null} delay - Delay in ms, or null to use animationDuration
1844
+ */
1845
+ setHideOnCompleteDelay(delay) {
1846
+ const oldValue = this.hideOnCompleteDelay;
1847
+ if (oldValue === delay) return;
1848
+
1849
+ this.hideOnCompleteDelay = delay;
1850
+ this._emit('hideOnCompleteDelayChange', { from: oldValue, to: delay });
1851
+ }
1852
+
1853
+ next() {
1854
+ if (this.steps.length === 0) return;
1855
+
1856
+ if (this.currentStepIndex < this.steps.length - 1) {
1857
+ this._emit('next', { fromIndex: this.currentStepIndex, toIndex: this.currentStepIndex + 1 });
1858
+ this.goToStep(this.currentStepIndex + 1);
1859
+ } else {
1860
+ // Check if autoplay was active before emitting complete
1861
+ const wasAutoplayActive = this.autoplay && this.autoplayEnabled && !this._autoplayPaused;
1862
+
1863
+ this._emit('complete', { totalSteps: this.steps.length, source: wasAutoplayActive ? 'autoplay' : 'manual' });
1864
+
1865
+ // Emit autoplayComplete if autoplay was running
1866
+ if (wasAutoplayActive) {
1867
+ this._stopAutoplay();
1868
+ this.autoplayEnabled = false;
1869
+ this._emit('autoplayComplete', { totalSteps: this.steps.length });
1870
+ }
1871
+
1872
+ // Handle post-completion behavior based on settings
1873
+ // Note: hideOnComplete is respected for both autoplay and manual navigation
1874
+ if (this.resetOnComplete) {
1875
+ this.reset();
1876
+
1877
+ // Schedule hide after reset animation completes (only if hideOnComplete is true)
1878
+ if (this.hideOnComplete) {
1879
+ const hideDelay = this.hideOnCompleteDelay !== null ? this.hideOnCompleteDelay : this.animationDuration;
1880
+ const source = wasAutoplayActive ? 'autoplay' : 'manual';
1881
+ this._hideOnCompleteTimeoutId = setTimeout(() => {
1882
+ this.hide();
1883
+ this._emit('autoHide', { delay: hideDelay, source: source });
1884
+ }, this.animationDuration + hideDelay);
1885
+ }
1886
+ } else {
1887
+ // No reset: hide immediately after delay (only if hideOnComplete is true)
1888
+ if (this.hideOnComplete) {
1889
+ const delay = this.hideOnCompleteDelay !== null ? this.hideOnCompleteDelay : this.animationDuration;
1890
+ const source = wasAutoplayActive ? 'autoplay' : 'manual';
1891
+ this._hideOnCompleteTimeoutId = setTimeout(() => {
1892
+ this.hide();
1893
+ this._emit('autoHide', { delay: delay, source: source });
1894
+ }, delay);
1895
+ }
1896
+ }
1897
+
1898
+ if (this.onComplete) {
1899
+ this.onComplete();
1900
+ }
1901
+ }
1902
+ }
1903
+
1904
+ prev() {
1905
+ if (this.steps.length === 0) return;
1906
+
1907
+ if (this.currentStepIndex > 0) {
1908
+ this._emit('prev', { fromIndex: this.currentStepIndex, toIndex: this.currentStepIndex - 1 });
1909
+ this.goToStep(this.currentStepIndex - 1);
1910
+ }
1911
+ }
1912
+
1913
+ getCurrentStep() {
1914
+ return this.currentStepIndex;
1915
+ }
1916
+
1917
+ getTotalSteps() {
1918
+ return this.steps.length;
1919
+ }
1920
+
1921
+ /**
1922
+ * Temporarily point to a target without changing the current step.
1923
+ * When next() is called, it will continue from where it left off.
1924
+ * @param {string|HTMLElement} target - The target element or selector
1925
+ * @param {string} content - Optional content to show
1926
+ * @param {string} direction - Optional direction: 'up', 'down', or null for auto
1927
+ */
1928
+ pointTo(target, content, direction) {
1929
+ const previousTarget = this.targetElement;
1930
+
1931
+ this._emit('beforePointTo', {
1932
+ target: Pointy.getTargetElement(target),
1933
+ content: content,
1934
+ direction: direction,
1935
+ fromTarget: previousTarget
1936
+ });
1937
+
1938
+ // Set manual direction
1939
+ this.manualDirection = direction || null;
1940
+
1941
+ // Pause floating animation during movement
1942
+ this.container.classList.add(this.classNames.moving);
1943
+ if (this.moveTimeout) clearTimeout(this.moveTimeout);
1944
+
1945
+ const toTarget = Pointy.getTargetElement(target);
1946
+
1947
+ this._emit('animationStart', {
1948
+ fromTarget: previousTarget,
1949
+ toTarget: toTarget,
1950
+ type: 'pointTo',
1951
+ content: content
1952
+ });
1953
+
1954
+ this.moveTimeout = setTimeout(() => {
1955
+ this.container.classList.remove(this.classNames.moving);
1956
+ this._emit('pointToComplete', { target: this.targetElement, content: content });
1957
+ this._emit('animationEnd', {
1958
+ fromTarget: previousTarget,
1959
+ toTarget: this.targetElement,
1960
+ type: 'pointTo',
1961
+ content: content
1962
+ });
1963
+ }, this.animationDuration);
1964
+
1965
+ this.targetElement = toTarget;
1966
+
1967
+ if (content !== undefined) {
1968
+ this._setMessages(content, false); // false = not from step change, don't auto-start cycle
1969
+ }
1970
+
1971
+ this.updatePosition();
1972
+
1973
+ this._emit('pointTo', { target: this.targetElement, content: content, direction: direction });
1974
+
1975
+ // Make sure it's visible
1976
+ if (!this.isVisible) {
1977
+ this.show();
1978
+ }
1979
+ }
1980
+
1981
+ /**
1982
+ * Subscribe to an event
1983
+ * @param {string} event - Event name
1984
+ * @param {function} callback - Callback function
1985
+ * @returns {Pointy} - Returns this for chaining
1986
+ *
1987
+ * Available events:
1988
+ *
1989
+ * Lifecycle:
1990
+ * - beforeShow: Before pointy becomes visible
1991
+ * - show: After pointy becomes visible
1992
+ * - beforeHide: Before pointy hides
1993
+ * - hide: After pointy hides
1994
+ * - destroy: When pointy is destroyed
1995
+ * - beforeReset: Before reset() is called
1996
+ * - reset: After reset completes
1997
+ * - beforeRestart: Before restart() is called
1998
+ * - restart: After restart completes
1999
+ *
2000
+ * Navigation:
2001
+ * - beforeStepChange: Before changing to a new step
2002
+ * - stepChange: After step has changed
2003
+ * - next: When next() is called
2004
+ * - prev: When prev() is called
2005
+ * - complete: When tour reaches the end
2006
+ * - beforePointTo: Before pointTo() moves to target
2007
+ * - pointTo: When pointTo() is called
2008
+ * - pointToComplete: When pointTo animation completes
2009
+ *
2010
+ * Animation:
2011
+ * - move: When pointy starts moving to a target
2012
+ * - moveComplete: When pointy finishes moving (after animation)
2013
+ * - animationStart: When any movement animation starts (step or pointTo)
2014
+ * - animationEnd: When any movement animation ends (step or pointTo)
2015
+ * - track: Each time position is updated during tracking
2016
+ *
2017
+ * Content:
2018
+ * - messagesSet: When messages array is set for a step
2019
+ * - messageChange: When current message changes (next/prev message) - includes isAuto flag
2020
+ * - contentSet: When setContent() is called
2021
+ * - messageCycleStart: When auto message cycling starts
2022
+ * - messageCycleStop: When auto message cycling stops
2023
+ * - messageCyclePause: When message cycling is paused
2024
+ * - messageCycleResume: When message cycling is resumed
2025
+ * - messageCycleComplete: When all messages have been shown (autoplayWaitForMessages)
2026
+ *
2027
+ * Autoplay:
2028
+ * - autoplayStart: When autoplay starts
2029
+ * - autoplayStop: When autoplay stops
2030
+ * - autoplayPause: When autoplay is paused
2031
+ * - autoplayResume: When autoplay is resumed
2032
+ * - autoplayNext: When autoplay triggers next step
2033
+ * - autoplayComplete: When autoplay finishes all steps
2034
+ * - autoplayChange: When autoplay interval changes
2035
+ * - autoplayWaitForMessagesChange: When autoplayWaitForMessages option changes
2036
+ *
2037
+ * Configuration changes:
2038
+ * - targetChange: When target element changes
2039
+ * - offsetChange: When offset values change
2040
+ * - animationDurationChange: When animation duration changes
2041
+ * - introFadeDurationChange: When intro fade duration changes
2042
+ * - bubbleFadeDurationChange: When bubble fade duration changes
2043
+ * - easingChange: When easing changes
2044
+ * - messageIntervalChange: When message interval changes
2045
+ * - messageTransitionDurationChange: When message transition duration changes
2046
+ * - pointerSvgChange: When pointer SVG changes
2047
+ * - resetOnCompleteChange: When resetOnComplete option changes
2048
+ * - floatingAnimationChange: When floating animation is toggled
2049
+ * - initialPositionChange: When initial position preset changes
2050
+ * - initialPositionOffsetChange: When initial position offset changes
2051
+ *
2052
+ * Generic Event Groups (listen to multiple events with one handler):
2053
+ * - 'lifecycle': beforeShow, show, beforeHide, hide, destroy, beforeRestart, restart, beforeReset, reset
2054
+ * - 'navigation': beforeStepChange, stepChange, next, prev, complete
2055
+ * - 'animation': animationStart, animationEnd, move, moveComplete, introAnimationStart, introAnimationEnd
2056
+ * - 'content': contentSet, messagesSet, messageChange
2057
+ * - 'messageCycle': messageCycleStart, messageCycleStop, messageCyclePause, messageCycleResume, messageCycleComplete
2058
+ * - 'pointing': beforePointTo, pointTo, pointToComplete
2059
+ * - 'tracking': track, targetChange, trackingChange, trackingFpsChange
2060
+ * - 'autoplay': autoplayStart, autoplayStop, autoplayPause, autoplayResume, autoplayNext, autoplayComplete, autoHide, autoplayChange, autoplayWaitForMessagesChange
2061
+ * - 'config': all *Change events
2062
+ * - '*' or 'all': all events
2063
+ *
2064
+ * Example: pointy.on('lifecycle', (data) => console.log(data.type, data));
2065
+ */
2066
+ on(event, callback) {
2067
+ if (!this._eventListeners[event]) {
2068
+ this._eventListeners[event] = [];
2069
+ }
2070
+ this._eventListeners[event].push(callback);
2071
+ return this;
2072
+ }
2073
+
2074
+ /**
2075
+ * Unsubscribe from an event
2076
+ * @param {string} event - Event name
2077
+ * @param {function} callback - Callback function to remove
2078
+ * @returns {Pointy} - Returns this for chaining
2079
+ */
2080
+ off(event, callback) {
2081
+ if (!this._eventListeners[event]) return this;
2082
+
2083
+ if (callback) {
2084
+ this._eventListeners[event] = this._eventListeners[event].filter(cb => cb !== callback);
2085
+ } else {
2086
+ // Remove all listeners for this event
2087
+ delete this._eventListeners[event];
2088
+ }
2089
+ return this;
2090
+ }
2091
+
2092
+ /**
2093
+ * Emit an event
2094
+ * @param {string} event - Event name
2095
+ * @param {object} data - Event data
2096
+ * @private
2097
+ */
2098
+ _emit(event, data) {
2099
+ const eventData = { ...data, type: event, pointy: this };
2100
+
2101
+ // Emit to specific event listeners
2102
+ if (this._eventListeners[event]) {
2103
+ this._eventListeners[event].forEach(callback => {
2104
+ try {
2105
+ callback(eventData);
2106
+ } catch (e) {
2107
+ console.error(`Pointy: Error in ${event} event handler:`, e);
2108
+ }
2109
+ });
2110
+ }
2111
+
2112
+ // Find which group this event belongs to and emit to group listeners
2113
+ const group = Pointy.getEventGroup(event);
2114
+ if (group && this._eventListeners[group]) {
2115
+ this._eventListeners[group].forEach(callback => {
2116
+ try {
2117
+ callback(eventData);
2118
+ } catch (e) {
2119
+ console.error(`Pointy: Error in ${group} group handler for ${event}:`, e);
2120
+ }
2121
+ });
2122
+ }
2123
+
2124
+ // Emit to wildcard listeners ('*' or 'all')
2125
+ ['*', 'all'].forEach(wildcard => {
2126
+ if (this._eventListeners[wildcard]) {
2127
+ this._eventListeners[wildcard].forEach(callback => {
2128
+ try {
2129
+ callback(eventData);
2130
+ } catch (e) {
2131
+ console.error(`Pointy: Error in wildcard handler for ${event}:`, e);
2132
+ }
2133
+ });
2134
+ }
2135
+ });
2136
+ }
2137
+
2138
+ /**
2139
+ * Get the group name for an event
2140
+ * @param {string} event - Event name
2141
+ * @returns {string|null} - Group name or null if not in a group
2142
+ */
2143
+ static getEventGroup(event) {
2144
+ for (const [group, events] of Object.entries(Pointy.EVENT_GROUPS)) {
2145
+ if (events.includes(event)) {
2146
+ return group;
2147
+ }
2148
+ }
2149
+ // Check if it's a config change event
2150
+ if (event.endsWith('Change')) {
2151
+ return 'config';
2152
+ }
2153
+ return null;
2154
+ }
2155
+
2156
+ /**
2157
+ * Get all events in a group
2158
+ * @param {string} group - Group name
2159
+ * @returns {string[]} - Array of event names
2160
+ */
2161
+ static getEventsInGroup(group) {
2162
+ return Pointy.EVENT_GROUPS[group] || [];
2163
+ }
2164
+ }
2165
+
2166
+ /**
2167
+ * Event groups for generic listeners
2168
+ */
2169
+ Pointy.EVENT_GROUPS = {
2170
+ lifecycle: ['beforeShow', 'show', 'beforeHide', 'hide', 'destroy', 'beforeRestart', 'restart', 'beforeReset', 'reset'],
2171
+ navigation: ['beforeStepChange', 'stepChange', 'next', 'prev', 'complete'],
2172
+ animation: ['animationStart', 'animationEnd', 'move', 'moveComplete', 'introAnimationStart', 'introAnimationEnd'],
2173
+ content: ['contentSet', 'messagesSet', 'messageChange'],
2174
+ messageCycle: ['messageCycleStart', 'messageCycleStop', 'messageCyclePause', 'messageCycleResume', 'messageCycleComplete'],
2175
+ pointing: ['beforePointTo', 'pointTo', 'pointToComplete'],
2176
+ tracking: ['track', 'targetChange', 'trackingChange', 'trackingFpsChange'],
2177
+ autoplay: ['autoplayStart', 'autoplayStop', 'autoplayPause', 'autoplayResume', 'autoplayNext', 'autoplayComplete', 'autoHide', 'autoplayChange', 'autoplayWaitForMessagesChange'],
2178
+ // 'config' is handled dynamically for all *Change events
2179
+ };
2180
+
2181
+ return Pointy;
2182
+
2183
+ }));