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