@diabolic/pointy 1.1.0 → 1.2.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.
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Pointy - A lightweight tooltip library with animated pointer
3
- * @version 1.1.0
3
+ * @version 1.2.0
4
4
  * @license MIT
5
5
  */
6
6
  /**
@@ -26,6 +26,11 @@
26
26
  * - target {string|HTMLElement} - Initial target element
27
27
  * - content {string|string[]} - Initial content/messages (single-step use)
28
28
  * - zIndex {number} - CSS z-index for the container (default: 9999)
29
+ * - stayInViewport {boolean|object} - Auto-flip bubble to stay within viewport (default: true)
30
+ * - true/false: Enable/disable with default thresholds (x: 40, y: 60)
31
+ * - { x: number, y: number }: Enable with custom thresholds
32
+ * - { x: number }: Enable with custom horizontal threshold only
33
+ * - { y: number }: Enable with custom vertical threshold only
29
34
  * - offsetX {number} - Horizontal offset from target (default: 20)
30
35
  * - offsetY {number} - Vertical offset from target (default: 16)
31
36
  * - trackingFps {number} - Position update FPS, 0 = unlimited (default: 60)
@@ -49,6 +54,9 @@
49
54
  * - classNames {object} - Full override of class names
50
55
  * - cssVarPrefix {string} - CSS variable prefix (default: classPrefix)
51
56
  * - pointerSvg {string} - Custom SVG for pointer
57
+ * - bubbleBackgroundColor {string} - Custom bubble background color (default: '#0a1551')
58
+ * - bubbleTextColor {string} - Custom bubble text color (default: 'white')
59
+ * - bubbleMaxWidth {string} - Max width for bubble (default: '90vw')
52
60
  * - onStepChange {function} - Callback on step change
53
61
  * - onComplete {function} - Callback on tour complete
54
62
  *
@@ -78,6 +86,11 @@
78
86
  * - moveComplete: Position update finished
79
87
  * - introAnimationStart: Initial fade-in animation started
80
88
  * - introAnimationEnd: Initial fade-in animation completed
89
+ * - flipHorizontal: Bubble flipped horizontally (left/right) due to viewport bounds
90
+ * - flipVertical: Bubble flipped vertically (up/down) due to viewport bounds
91
+ * - directionChange: Manual direction changed via setDirection()
92
+ * - horizontalDirectionChange: Horizontal direction changed via setHorizontalDirection()
93
+ * - verticalDirectionChange: Vertical direction changed via setVerticalDirection()
81
94
  *
82
95
  * Content:
83
96
  * - messagesSet: Messages array replaced via setMessages() or setMessage()
@@ -142,9 +155,12 @@
142
155
  *
143
156
  * Setters (all emit change events):
144
157
  * setEasing(), setAnimationDuration(), setIntroFadeDuration(), setBubbleFadeDuration(),
158
+ * setBubbleBackgroundColor(), setBubbleTextColor(), setBubbleMaxWidth(),
145
159
  * setMessageInterval(), setMessageTransitionDuration(), setOffset(), setZIndex(),
146
- * setResetOnComplete(), setFloatingAnimation(), setInitialPosition(), setInitialPositionOffset(),
147
- * setAutoplay(), setAutoplayWaitForMessages()
160
+ * setStayInViewport(enabled, thresholds?), setDirection(direction),
161
+ * setHorizontalDirection(direction), setVerticalDirection(direction),
162
+ * setResetOnComplete(), setFloatingAnimation(),
163
+ * setInitialPosition(), setInitialPositionOffset(), setAutoplay(), setAutoplayWaitForMessages()
148
164
  *
149
165
  * Static Helpers:
150
166
  * - Pointy.renderContent(element, content) - Render string/JSX to element
@@ -174,7 +190,7 @@ class Pointy {
174
190
  static POINTER_SVG = `
175
191
  <svg xmlns="http://www.w3.org/2000/svg" width="33" height="33" fill="none" viewBox="0 0 33 33">
176
192
  <g filter="url(#pointy-shadow)">
177
- <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"/>
193
+ <path fill="currentColor" 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"/>
178
194
  </g>
179
195
  <defs>
180
196
  <filter id="pointy-shadow" width="32.576" height="32.575" x="0" y="0" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse">
@@ -253,6 +269,10 @@ class Pointy {
253
269
  --${vp}-duration: 1000ms;
254
270
  --${vp}-easing: cubic-bezier(0, 0.55, 0.45, 1);
255
271
  --${vp}-bubble-fade: 500ms;
272
+ --${vp}-bubble-bg: #0a1551;
273
+ --${vp}-bubble-color: white;
274
+ --${vp}-bubble-max-width: min(400px, 90vw);
275
+ --${vp}-pointer-color: #0a1551;
256
276
  transition: left var(--${vp}-duration) var(--${vp}-easing), top var(--${vp}-duration) var(--${vp}-easing), opacity 0.3s ease;
257
277
  animation: ${cn.container}-float 3s ease-in-out infinite;
258
278
  }
@@ -273,28 +293,32 @@ class Pointy {
273
293
  .${cn.pointer} {
274
294
  width: 33px;
275
295
  height: 33px;
276
- transition: transform var(--${vp}-duration) var(--${vp}-easing);
296
+ color: var(--${vp}-pointer-color);
297
+ transition: transform var(--${vp}-duration) var(--${vp}-easing), color 0.3s ease;
277
298
  }
278
299
 
279
300
  .${cn.bubble} {
280
301
  position: absolute;
281
302
  right: 26px;
303
+ left: auto;
282
304
  top: 0;
283
- background: #0a1551;
284
- color: white;
305
+ background: var(--${vp}-bubble-bg);
306
+ color: var(--${vp}-bubble-color);
285
307
  padding: 4px 12px;
286
308
  border-radius: 14px;
287
309
  font-size: 14px;
288
310
  line-height: 20px;
289
311
  font-weight: 400;
290
312
  box-shadow: 0 4px 15px rgba(0, 0, 0, 0.25);
291
- white-space: nowrap;
313
+ width: max-content;
314
+ max-width: var(--${vp}-bubble-max-width);
292
315
  overflow: hidden;
293
- 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;
316
+ transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1), height 0.3s cubic-bezier(0.4, 0, 0.2, 1), transform var(--${vp}-duration) var(--${vp}-easing), opacity var(--${vp}-bubble-fade) ease, left var(--${vp}-duration) var(--${vp}-easing), right var(--${vp}-duration) var(--${vp}-easing), background 0.3s ease, color 0.3s ease;
294
317
  }
295
318
 
296
319
  .${cn.bubbleText} {
297
- display: inline-block;
320
+ display: block;
321
+ word-break: break-word;
298
322
  }
299
323
  `;
300
324
  }
@@ -334,16 +358,20 @@ class Pointy {
334
358
  static animateText(element, newContent, duration = 500, bubble = null, onComplete = null) {
335
359
  const hideTime = duration * 0.4;
336
360
  const revealTime = duration * 0.6;
361
+ const resizeTime = 300; // Match CSS transition
337
362
 
338
363
  // Measure new content dimensions using a hidden container
339
364
  let newWidth = null;
340
365
  let newHeight = null;
341
366
  if (bubble) {
367
+ // Get bubble's max-width for proper measurement of multi-line content
368
+ const bubbleStyles = window.getComputedStyle(bubble);
369
+ const maxWidth = bubbleStyles.maxWidth;
370
+
342
371
  const measureDiv = document.createElement('div');
343
- measureDiv.style.cssText = 'visibility: hidden; position: absolute; padding: 4px 12px;';
372
+ measureDiv.style.cssText = `visibility: hidden; position: absolute; padding: 4px 12px; width: max-content; max-width: ${maxWidth};`;
344
373
  Pointy.renderContent(measureDiv, newContent);
345
374
  bubble.appendChild(measureDiv);
346
- // Add horizontal padding (12px left + 12px right = 24px)
347
375
  newWidth = measureDiv.offsetWidth;
348
376
  newHeight = measureDiv.offsetHeight;
349
377
  bubble.removeChild(measureDiv);
@@ -353,22 +381,26 @@ class Pointy {
353
381
  const currentHeight = bubble.offsetHeight;
354
382
  bubble.style.width = currentWidth + 'px';
355
383
  bubble.style.height = currentHeight + 'px';
384
+
385
+ // Force reflow
386
+ bubble.offsetHeight;
387
+
388
+ // Start resizing bubble to new size immediately
389
+ bubble.style.width = newWidth + 'px';
390
+ bubble.style.height = newHeight + 'px';
356
391
  }
357
392
 
358
393
  // Phase 1: Hide old text (clip from left, disappears to right)
359
394
  element.style.transition = `clip-path ${hideTime}ms ease-in`;
360
395
  element.style.clipPath = 'inset(0 0 0 100%)';
361
396
 
397
+ // Wait for bubble resize AND text hide, then change content
398
+ const contentChangeDelay = Math.max(hideTime, resizeTime);
399
+
362
400
  setTimeout(() => {
363
- // Change content while fully clipped
401
+ // Change content while fully clipped AND bubble is at new size
364
402
  Pointy.renderContent(element, newContent);
365
403
 
366
- // Animate bubble to new size
367
- if (bubble && newWidth !== null) {
368
- bubble.style.width = newWidth + 'px';
369
- bubble.style.height = newHeight + 'px';
370
- }
371
-
372
404
  // Prepare for reveal (start fully clipped from right)
373
405
  element.style.transition = 'none';
374
406
  element.style.clipPath = 'inset(0 100% 0 0)';
@@ -380,16 +412,16 @@ class Pointy {
380
412
  element.style.transition = `clip-path ${revealTime}ms ease-out`;
381
413
  element.style.clipPath = 'inset(0 0 0 0)';
382
414
 
383
- // Clear dimensions after transition so it can auto-size
415
+ // Clear explicit dimensions after reveal so bubble can auto-size
384
416
  if (bubble) {
385
417
  setTimeout(() => {
386
418
  bubble.style.width = '';
387
419
  bubble.style.height = '';
388
- }, revealTime + 100);
420
+ }, revealTime + 50);
389
421
  }
390
422
 
391
423
  if (onComplete) onComplete();
392
- }, hideTime);
424
+ }, contentChangeDelay);
393
425
  }
394
426
 
395
427
  /**
@@ -441,6 +473,18 @@ class Pointy {
441
473
 
442
474
  this.steps = options.steps || [];
443
475
  this.zIndex = options.zIndex !== undefined ? options.zIndex : 9999; // CSS z-index
476
+
477
+ // stayInViewport: boolean or { x?: number, y?: number }
478
+ this.viewportThresholdX = 40; // Default horizontal margin
479
+ this.viewportThresholdY = 60; // Default vertical margin
480
+ if (typeof options.stayInViewport === 'object' && options.stayInViewport !== null) {
481
+ this.stayInViewport = true;
482
+ if (options.stayInViewport.x !== undefined) this.viewportThresholdX = options.stayInViewport.x;
483
+ if (options.stayInViewport.y !== undefined) this.viewportThresholdY = options.stayInViewport.y;
484
+ } else {
485
+ this.stayInViewport = options.stayInViewport !== undefined ? options.stayInViewport : true;
486
+ }
487
+
444
488
  this.offsetX = options.offsetX !== undefined ? options.offsetX : 20;
445
489
  this.offsetY = options.offsetY !== undefined ? options.offsetY : 16;
446
490
  this.tracking = options.tracking !== undefined ? options.tracking : true; // Enable/disable position tracking
@@ -460,6 +504,10 @@ class Pointy {
460
504
  this.autoplayWaitForMessages = options.autoplayWaitForMessages !== undefined ? options.autoplayWaitForMessages : true; // Wait for all messages before advancing
461
505
  this.hideOnComplete = options.hideOnComplete !== undefined ? options.hideOnComplete : true; // Auto-hide after tour completes
462
506
  this.hideOnCompleteDelay = options.hideOnCompleteDelay !== undefined ? options.hideOnCompleteDelay : null; // Delay before hide (null = use animationDuration)
507
+ this.bubbleBackgroundColor = options.bubbleBackgroundColor || null; // Custom bubble background color
508
+ this.bubbleTextColor = options.bubbleTextColor || null; // Custom bubble text color
509
+ this.bubbleMaxWidth = options.bubbleMaxWidth || null; // Max width for bubble (default: min(400px, 90vw))
510
+ this.pointerColor = options.pointerColor || null; // Custom pointer/cursor color
463
511
  this._autoplayTimeoutId = null;
464
512
  this._autoplayPaused = false;
465
513
  this._messagesCompletedForStep = false; // Track if all messages have been shown
@@ -478,10 +526,16 @@ class Pointy {
478
526
  this._messageIntervalId = null;
479
527
  this.isVisible = false;
480
528
  this.isPointingUp = true; // Always start pointing up
529
+ this.isPointingLeft = true; // Pointer on left side of target (bubble on right)
481
530
  this.lastTargetY = null;
482
531
  this._targetYHistory = []; // Track Y positions for velocity detection
483
532
  this._lastDirectionChangeTime = 0; // Debounce direction changes
484
- this.manualDirection = null; // 'up', 'down', or null (auto)
533
+ this.manualHorizontalDirection = null; // 'left', 'right', or null (auto)
534
+ this.manualVerticalDirection = null; // 'up', 'down', or null (auto)
535
+ this._autoDirectionLocked = false; // Lock auto-direction after first calculation per target
536
+ this._lastTargetElement = null; // Track target changes to recalculate direction
537
+ this._bubbleConstraintApplied = false; // Track if bubble constraint has been calculated
538
+ this._cachedBubbleNaturalWidth = null; // Cache bubble's natural width before constraint
485
539
  this.moveTimeout = null;
486
540
  this._hasShownBefore = false; // For intro animation
487
541
 
@@ -498,6 +552,20 @@ class Pointy {
498
552
  this.container.style.setProperty(`--${this.cssVarPrefix}-easing`, this._resolveEasing(this.easing));
499
553
  this.container.style.setProperty(`--${this.cssVarPrefix}-bubble-fade`, `${this.bubbleFadeDuration}ms`);
500
554
 
555
+ // Apply custom bubble colors if provided
556
+ if (this.bubbleBackgroundColor) {
557
+ this.container.style.setProperty(`--${this.cssVarPrefix}-bubble-bg`, this.bubbleBackgroundColor);
558
+ }
559
+ if (this.bubbleTextColor) {
560
+ this.container.style.setProperty(`--${this.cssVarPrefix}-bubble-color`, this.bubbleTextColor);
561
+ }
562
+ if (this.bubbleMaxWidth) {
563
+ this.container.style.setProperty(`--${this.cssVarPrefix}-bubble-max-width`, this.bubbleMaxWidth);
564
+ }
565
+ if (this.pointerColor) {
566
+ this.container.style.setProperty(`--${this.cssVarPrefix}-pointer-color`, this.pointerColor);
567
+ }
568
+
501
569
  // Apply floating animation setting
502
570
  if (!this.floatingAnimation) {
503
571
  this.container.style.animationPlayState = 'paused';
@@ -585,10 +653,19 @@ class Pointy {
585
653
  const targetRect = this.targetElement.getBoundingClientRect();
586
654
  const scrollX = window.scrollX;
587
655
  const scrollY = window.scrollY;
656
+ const viewportWidth = window.innerWidth;
657
+ const viewportHeight = window.innerHeight;
658
+ this.bubble.offsetWidth || 100;
659
+ const bubbleHeight = this.bubble.offsetHeight || 28;
660
+
661
+ // Manual horizontal direction takes priority
662
+ if (this.manualHorizontalDirection !== null) {
663
+ this.isPointingLeft = this.manualHorizontalDirection === 'left';
664
+ }
588
665
 
589
- // Manual direction takes priority
590
- if (this.manualDirection !== null) {
591
- this.isPointingUp = this.manualDirection === 'up';
666
+ // Manual vertical direction takes priority
667
+ if (this.manualVerticalDirection !== null) {
668
+ this.isPointingUp = this.manualVerticalDirection === 'up';
592
669
  } else {
593
670
  // Auto: Track velocity over time to detect movement direction
594
671
  const currentTargetY = targetRect.top + scrollY;
@@ -624,32 +701,179 @@ class Pointy {
624
701
  this.lastTargetY = currentTargetY;
625
702
  }
626
703
 
704
+ // Check if target changed - if so, unlock auto direction and bubble constraint
705
+ if (this._lastTargetElement !== this.targetElement) {
706
+ this._autoDirectionLocked = false;
707
+ this._bubbleConstraintApplied = false;
708
+ this._cachedBubbleNaturalWidth = null;
709
+ this.bubble.style.maxWidth = ''; // Reset constraint for new target
710
+ this._lastTargetElement = this.targetElement;
711
+ }
712
+
713
+ // Stay in viewport: calculate optimal direction ONCE per target, then lock it
714
+ // This prevents flip-flopping during animations and tracking
715
+ if (this.stayInViewport && !this._autoDirectionLocked) {
716
+ const prevIsPointingLeft = this.isPointingLeft;
717
+ const prevIsPointingUp = this.isPointingUp;
718
+
719
+ // Horizontal direction (only if not manually set)
720
+ if (this.manualHorizontalDirection === null) {
721
+ // Calculate available space on each side
722
+ const spaceOnLeft = targetRect.left - this.viewportThresholdX;
723
+ const spaceOnRight = viewportWidth - targetRect.right - this.viewportThresholdX;
724
+
725
+ // Pick the side with more space
726
+ // Add a small preference for left (default) when spaces are similar
727
+ const leftPreference = 20;
728
+ this.isPointingLeft = spaceOnLeft + leftPreference >= spaceOnRight;
729
+ }
730
+
731
+ // Vertical direction (only if not manually set)
732
+ if (this.manualVerticalDirection === null) {
733
+ // Calculate available space above and below
734
+ const spaceBelow = viewportHeight - targetRect.bottom - this.viewportThresholdY;
735
+ const spaceAbove = targetRect.top - this.viewportThresholdY;
736
+
737
+ // Pick the side with more space, slight preference for below (pointing up)
738
+ const belowPreference = 10;
739
+ this.isPointingUp = spaceBelow + belowPreference >= spaceAbove;
740
+ }
741
+
742
+ // Lock direction - won't recalculate until target changes
743
+ this._autoDirectionLocked = true;
744
+
745
+ // Emit flip events if direction changed
746
+ if (prevIsPointingLeft !== this.isPointingLeft) {
747
+ this._emit('flipHorizontal', {
748
+ from: prevIsPointingLeft ? 'left' : 'right',
749
+ to: this.isPointingLeft ? 'left' : 'right'
750
+ });
751
+ }
752
+ if (prevIsPointingUp !== this.isPointingUp) {
753
+ this._emit('flipVertical', {
754
+ from: prevIsPointingUp ? 'up' : 'down',
755
+ to: this.isPointingUp ? 'up' : 'down'
756
+ });
757
+ }
758
+ }
759
+
627
760
  // Pointer tip position varies based on rotation
628
761
  // Default SVG (0deg): tip at approximately (25, 8) - points top-right
629
762
  // Rotated 90deg: tip at approximately (25, 25) - points bottom-right
763
+ // Rotated -90deg (270deg): points top-left (flipped horizontally via rotation)
630
764
  let left, top;
631
-
632
- if (this.isPointingUp) {
633
- // Pointer points up (default): pointer's top-right → target's bottom-left
634
- this.pointer.style.transform = 'rotate(0deg)';
635
- left = targetRect.left + scrollX - 25 + this.offsetX;
636
- top = targetRect.bottom + scrollY - 8 - this.offsetY;
637
-
638
- // Bubble: below pointer
639
- this.bubble.style.transform = 'translateY(28px)';
765
+ let pointerRotation;
766
+ let bubbleTransform;
767
+
768
+ if (this.isPointingLeft) {
769
+ // Pointer on LEFT side of target, bubble extends to the LEFT
770
+ if (this.isPointingUp) {
771
+ pointerRotation = 'rotate(0deg)';
772
+ left = targetRect.left + scrollX - 25 + this.offsetX;
773
+ top = targetRect.bottom + scrollY - 8 - this.offsetY;
774
+ bubbleTransform = 'translateY(28px)';
775
+ } else {
776
+ pointerRotation = 'rotate(90deg)';
777
+ left = targetRect.left + scrollX - 25 + this.offsetX;
778
+ top = targetRect.top + scrollY - 25 + this.offsetY;
779
+ bubbleTransform = `translateY(-${bubbleHeight}px)`;
780
+ }
781
+ // Bubble position: right of pointer (default CSS)
782
+ this.bubble.style.right = '26px';
783
+ this.bubble.style.left = 'auto';
640
784
  } else {
641
- // Pointer points down (90deg): pointer's bottom-right target's top-left
642
- this.pointer.style.transform = 'rotate(90deg)';
643
- left = targetRect.left + scrollX - 25 + this.offsetX;
644
- top = targetRect.top + scrollY - 25 + this.offsetY;
645
-
646
- // Bubble: above pointer
647
- const bubbleHeight = this.bubble.offsetHeight || 28;
648
- this.bubble.style.transform = `translateY(-${bubbleHeight}px)`;
785
+ // Pointer on RIGHT side of target, bubble extends to the RIGHT
786
+ if (this.isPointingUp) {
787
+ // Rotate -90deg to point top-left instead of top-right
788
+ pointerRotation = 'rotate(-90deg)';
789
+ left = targetRect.right + scrollX - 8 - this.offsetX;
790
+ top = targetRect.bottom + scrollY - 25 - this.offsetY;
791
+ bubbleTransform = 'translateY(28px)';
792
+ } else {
793
+ // Rotate 180deg to point bottom-left
794
+ pointerRotation = 'rotate(180deg)';
795
+ left = targetRect.right + scrollX - 8 - this.offsetX;
796
+ top = targetRect.top + scrollY - 8 + this.offsetY;
797
+ bubbleTransform = `translateY(-${bubbleHeight}px)`;
798
+ }
799
+ // Bubble position: left of pointer (flipped)
800
+ this.bubble.style.left = '26px';
801
+ this.bubble.style.right = 'auto';
649
802
  }
650
803
 
804
+ this.pointer.style.transform = pointerRotation;
805
+ this.bubble.style.transform = bubbleTransform;
806
+
807
+ // Clamp container position to keep pointer visible in viewport
808
+ const minLeft = scrollX + 8;
809
+ const maxLeft = scrollX + viewportWidth - 40;
810
+ left = Math.max(minLeft, Math.min(left, maxLeft));
811
+
651
812
  this.container.style.left = `${left}px`;
652
813
  this.container.style.top = `${top}px`;
814
+
815
+ // Ensure bubble doesn't overflow viewport horizontally
816
+ // Pass the viewport-relative position for constraint calculation
817
+ const viewportLeft = left - scrollX;
818
+ this._constrainBubbleToViewport(viewportLeft, viewportWidth);
819
+ }
820
+
821
+ /**
822
+ * Constrain bubble width and position to prevent horizontal viewport overflow
823
+ * @private
824
+ */
825
+ _constrainBubbleToViewport(containerLeft, viewportWidth) {
826
+ // Only calculate constraint once per target to prevent oscillation
827
+ if (this._bubbleConstraintApplied) return;
828
+
829
+ const padding = 8; // Minimum padding from viewport edge
830
+
831
+ // Temporarily remove any existing constraint to measure natural width
832
+ const previousMaxWidth = this.bubble.style.maxWidth;
833
+ this.bubble.style.maxWidth = '';
834
+
835
+ // Force layout reflow to get accurate measurement
836
+ const bubbleNaturalWidth = this.bubble.offsetWidth || 100;
837
+
838
+ // Cache the natural width
839
+ this._cachedBubbleNaturalWidth = bubbleNaturalWidth;
840
+
841
+ let needsConstraint = false;
842
+ let constrainedWidth = 0;
843
+
844
+ if (this.isPointingLeft) {
845
+ // Bubble extends to the left of pointer
846
+ // Bubble's left edge = containerLeft - bubbleWidth + 26 (right offset of bubble)
847
+ const bubbleLeftEdge = containerLeft - bubbleNaturalWidth + 26;
848
+
849
+ if (bubbleLeftEdge < padding) {
850
+ // Bubble would overflow left edge - constrain its width
851
+ const availableWidth = containerLeft + 26 - padding;
852
+ constrainedWidth = Math.max(availableWidth, 80);
853
+ needsConstraint = true;
854
+ }
855
+ } else {
856
+ // Bubble extends to the right of pointer
857
+ // Bubble's right edge = containerLeft + 26 + bubbleWidth
858
+ const bubbleRightEdge = containerLeft + 26 + bubbleNaturalWidth;
859
+
860
+ if (bubbleRightEdge > viewportWidth - padding) {
861
+ // Bubble would overflow right edge - constrain its width
862
+ const availableWidth = viewportWidth - containerLeft - 26 - padding;
863
+ constrainedWidth = Math.max(availableWidth, 80);
864
+ needsConstraint = true;
865
+ }
866
+ }
867
+
868
+ if (needsConstraint) {
869
+ this.bubble.style.maxWidth = `${constrainedWidth}px`;
870
+ } else {
871
+ // Restore previous or let CSS variable handle it
872
+ this.bubble.style.maxWidth = previousMaxWidth || '';
873
+ }
874
+
875
+ // Mark constraint as applied for this target
876
+ this._bubbleConstraintApplied = true;
653
877
  }
654
878
 
655
879
  show() {
@@ -936,6 +1160,10 @@ class Pointy {
936
1160
  this.currentMessages = Array.isArray(firstStep.content) ? firstStep.content : [firstStep.content];
937
1161
  this.currentMessageIndex = 0;
938
1162
  Pointy.renderContent(this.bubbleText, this.currentMessages[0]);
1163
+ this._autoDirectionLocked = false; // Unlock direction for recalculation
1164
+ this._bubbleConstraintApplied = false;
1165
+ this._cachedBubbleNaturalWidth = null;
1166
+ this.bubble.style.maxWidth = '';
939
1167
  }
940
1168
 
941
1169
  // After animation completes
@@ -1421,6 +1649,113 @@ class Pointy {
1421
1649
  this._emit('zIndexChange', { from: oldValue, to: zIndex });
1422
1650
  }
1423
1651
 
1652
+ /**
1653
+ * Set stayInViewport enabled/disabled with optional thresholds
1654
+ * @param {boolean} enabled - Whether stayInViewport is enabled
1655
+ * @param {object} [thresholds] - Optional threshold values { x?: number, y?: number }
1656
+ */
1657
+ setStayInViewport(enabled, thresholds) {
1658
+ const oldEnabled = this.stayInViewport;
1659
+ const oldX = this.viewportThresholdX;
1660
+ const oldY = this.viewportThresholdY;
1661
+
1662
+ this.stayInViewport = enabled;
1663
+
1664
+ // Update thresholds if provided
1665
+ if (thresholds && typeof thresholds === 'object') {
1666
+ if (thresholds.x !== undefined) this.viewportThresholdX = thresholds.x;
1667
+ if (thresholds.y !== undefined) this.viewportThresholdY = thresholds.y;
1668
+ }
1669
+
1670
+ // Reset to default positions if disabling
1671
+ if (!enabled) {
1672
+ this.isPointingLeft = true;
1673
+ this.isPointingUp = true;
1674
+ }
1675
+
1676
+ this.updatePosition();
1677
+ this._emit('stayInViewportChange', {
1678
+ from: { enabled: oldEnabled, x: oldX, y: oldY },
1679
+ to: { enabled, x: this.viewportThresholdX, y: this.viewportThresholdY }
1680
+ });
1681
+ }
1682
+
1683
+ /**
1684
+ * Parse direction string into horizontal and vertical components
1685
+ * @private
1686
+ * @param {string|null} direction - Direction string like 'up', 'left', 'up-left', 'down-right', etc.
1687
+ */
1688
+ _parseDirection(direction) {
1689
+ if (!direction) {
1690
+ this.manualHorizontalDirection = null;
1691
+ this.manualVerticalDirection = null;
1692
+ return;
1693
+ }
1694
+
1695
+ const dir = direction.toLowerCase();
1696
+
1697
+ // Parse horizontal component
1698
+ if (dir.includes('left')) {
1699
+ this.manualHorizontalDirection = 'left';
1700
+ } else if (dir.includes('right')) {
1701
+ this.manualHorizontalDirection = 'right';
1702
+ } else {
1703
+ this.manualHorizontalDirection = null;
1704
+ }
1705
+
1706
+ // Parse vertical component
1707
+ if (dir.includes('up')) {
1708
+ this.manualVerticalDirection = 'up';
1709
+ } else if (dir.includes('down')) {
1710
+ this.manualVerticalDirection = 'down';
1711
+ } else {
1712
+ this.manualVerticalDirection = null;
1713
+ }
1714
+ }
1715
+
1716
+ /**
1717
+ * Set the pointer direction manually
1718
+ * @param {string|null} direction - Direction: 'up', 'down', 'left', 'right', 'up-left', 'up-right', 'down-left', 'down-right', or null for auto
1719
+ */
1720
+ setDirection(direction) {
1721
+ const oldH = this.manualHorizontalDirection;
1722
+ const oldV = this.manualVerticalDirection;
1723
+
1724
+ this._parseDirection(direction);
1725
+
1726
+ this.updatePosition();
1727
+ this._emit('directionChange', {
1728
+ from: { horizontal: oldH, vertical: oldV },
1729
+ to: { horizontal: this.manualHorizontalDirection, vertical: this.manualVerticalDirection }
1730
+ });
1731
+ }
1732
+
1733
+ /**
1734
+ * Set only the horizontal direction
1735
+ * @param {string|null} direction - 'left', 'right', or null for auto
1736
+ */
1737
+ setHorizontalDirection(direction) {
1738
+ const oldValue = this.manualHorizontalDirection;
1739
+ if (oldValue === direction) return;
1740
+
1741
+ this.manualHorizontalDirection = direction;
1742
+ this.updatePosition();
1743
+ this._emit('horizontalDirectionChange', { from: oldValue, to: direction });
1744
+ }
1745
+
1746
+ /**
1747
+ * Set only the vertical direction
1748
+ * @param {string|null} direction - 'up', 'down', or null for auto
1749
+ */
1750
+ setVerticalDirection(direction) {
1751
+ const oldValue = this.manualVerticalDirection;
1752
+ if (oldValue === direction) return;
1753
+
1754
+ this.manualVerticalDirection = direction;
1755
+ this.updatePosition();
1756
+ this._emit('verticalDirectionChange', { from: oldValue, to: direction });
1757
+ }
1758
+
1424
1759
  setOffset(offsetX, offsetY) {
1425
1760
  const oldOffsetX = this.offsetX;
1426
1761
  const oldOffsetY = this.offsetY;
@@ -1470,6 +1805,74 @@ class Pointy {
1470
1805
  this._emit('bubbleFadeDurationChange', { from: oldDuration, to: duration });
1471
1806
  }
1472
1807
 
1808
+ /**
1809
+ * Set the bubble background color
1810
+ * @param {string} color - CSS color value (hex, rgb, etc.)
1811
+ */
1812
+ setBubbleBackgroundColor(color) {
1813
+ const oldColor = this.bubbleBackgroundColor;
1814
+ if (oldColor === color) return;
1815
+
1816
+ this.bubbleBackgroundColor = color;
1817
+ if (color) {
1818
+ this.container.style.setProperty(`--${this.cssVarPrefix}-bubble-bg`, color);
1819
+ } else {
1820
+ this.container.style.removeProperty(`--${this.cssVarPrefix}-bubble-bg`);
1821
+ }
1822
+ this._emit('bubbleBackgroundColorChange', { from: oldColor, to: color });
1823
+ }
1824
+
1825
+ /**
1826
+ * Set the bubble text color
1827
+ * @param {string} color - CSS color value (hex, rgb, etc.)
1828
+ */
1829
+ setBubbleTextColor(color) {
1830
+ const oldColor = this.bubbleTextColor;
1831
+ if (oldColor === color) return;
1832
+
1833
+ this.bubbleTextColor = color;
1834
+ if (color) {
1835
+ this.container.style.setProperty(`--${this.cssVarPrefix}-bubble-color`, color);
1836
+ } else {
1837
+ this.container.style.removeProperty(`--${this.cssVarPrefix}-bubble-color`);
1838
+ }
1839
+ this._emit('bubbleTextColorChange', { from: oldColor, to: color });
1840
+ }
1841
+
1842
+ /**
1843
+ * Set the bubble max width
1844
+ * @param {string} width - CSS width value (e.g., '90vw', '300px')
1845
+ */
1846
+ setBubbleMaxWidth(width) {
1847
+ const oldWidth = this.bubbleMaxWidth;
1848
+ if (oldWidth === width) return;
1849
+
1850
+ this.bubbleMaxWidth = width;
1851
+ if (width) {
1852
+ this.container.style.setProperty(`--${this.cssVarPrefix}-bubble-max-width`, width);
1853
+ } else {
1854
+ this.container.style.removeProperty(`--${this.cssVarPrefix}-bubble-max-width`);
1855
+ }
1856
+ this._emit('bubbleMaxWidthChange', { from: oldWidth, to: width });
1857
+ }
1858
+
1859
+ /**
1860
+ * Set the pointer/cursor color
1861
+ * @param {string} color - CSS color value (hex, rgb, etc.)
1862
+ */
1863
+ setPointerColor(color) {
1864
+ const oldColor = this.pointerColor;
1865
+ if (oldColor === color) return;
1866
+
1867
+ this.pointerColor = color;
1868
+ if (color) {
1869
+ this.container.style.setProperty(`--${this.cssVarPrefix}-pointer-color`, color);
1870
+ } else {
1871
+ this.container.style.removeProperty(`--${this.cssVarPrefix}-pointer-color`);
1872
+ }
1873
+ this._emit('pointerColorChange', { from: oldColor, to: color });
1874
+ }
1875
+
1473
1876
  /**
1474
1877
  * Get the initial position coordinates based on initialPosition setting
1475
1878
  * @returns {{x: number, y: number, isPointingUp?: boolean}} - Position coordinates and optional direction
@@ -1744,12 +2147,16 @@ class Pointy {
1744
2147
  fromTarget: previousTarget
1745
2148
  });
1746
2149
 
1747
- // Set direction: step.direction can be 'up', 'down', or undefined (auto)
1748
- this.manualDirection = step.direction || null;
2150
+ // Parse direction: can be 'up', 'down', 'left', 'right', 'up-left', 'down-right', etc.
2151
+ this._parseDirection(step.direction);
1749
2152
 
1750
- // Reset velocity tracking for new target
2153
+ // Reset velocity tracking and direction lock for new target
1751
2154
  this._targetYHistory = [];
1752
2155
  this.lastTargetY = null;
2156
+ this._autoDirectionLocked = false;
2157
+ this._bubbleConstraintApplied = false;
2158
+ this._cachedBubbleNaturalWidth = null;
2159
+ this.bubble.style.maxWidth = '';
1753
2160
 
1754
2161
  // Pause floating animation during movement
1755
2162
  this.container.classList.add(this.classNames.moving);
@@ -2053,7 +2460,7 @@ class Pointy {
2053
2460
  * When next() is called, it will continue from where it left off.
2054
2461
  * @param {string|HTMLElement} target - The target element or selector
2055
2462
  * @param {string} content - Optional content to show
2056
- * @param {string} direction - Optional direction: 'up', 'down', or null for auto
2463
+ * @param {string} direction - Optional direction: 'up', 'down', 'left', 'right', 'up-left', 'down-right', etc. or null for auto
2057
2464
  */
2058
2465
  pointTo(target, content, direction) {
2059
2466
  const previousTarget = this.targetElement;
@@ -2068,12 +2475,16 @@ class Pointy {
2068
2475
  fromTarget: previousTarget
2069
2476
  });
2070
2477
 
2071
- // Set manual direction (null means auto)
2072
- this.manualDirection = direction || null;
2478
+ // Parse direction (null means auto)
2479
+ this._parseDirection(direction);
2073
2480
 
2074
- // Reset velocity tracking for new target
2481
+ // Reset velocity tracking and direction lock for new target
2075
2482
  this._targetYHistory = [];
2076
2483
  this.lastTargetY = null;
2484
+ this._autoDirectionLocked = false;
2485
+ this._bubbleConstraintApplied = false;
2486
+ this._cachedBubbleNaturalWidth = null;
2487
+ this.bubble.style.maxWidth = '';
2077
2488
 
2078
2489
  // Pause floating animation during movement
2079
2490
  this.container.classList.add(this.classNames.moving);