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