@@ -33,15 +33,17 @@ export default class TooltipManager {
3333 * @param {HTMLElement } element - The target element
3434 * @param {string } text - The tooltip text
3535 * @param {string } [position] - 'top'|'bottom'|'left'|'right' - Optional. Preferred position for the element. If not specified, the position will be determined in the following order of preference: 'bottom', 'left', 'right', 'top'.
36- * @param {string } [classes] - Optional. Additional classes to add to the tooltip.
36+ * @param {string } [classes] - Optional. Additional classes to add to the tooltip.
37+ * @param {boolean } [isHTML] - Optional. If true, the tooltip text will be treated as HTML. ⚠️ SECURITY: This can lead to XSS vulnerabilities if the text is not sanitized or injected by users
3738 */
38- setTooltip ( element , text , position , classes = null ) {
39+ setTooltip ( element , text , position , classes = null , isHTML = false ) {
40+ // ⚠️ isHTML is used to display HTML content in the tooltip. It should be used with caution, especially if the text content is user-generated (translation, ...).
3941 if ( ! element ) {
4042 return ;
4143 }
4244
4345 this . removeTooltip ( element ) ; // Clean up first if already present
44- const mouseEnter = ( ) => this . showTooltip ( element , text , position , classes ) ;
46+ const mouseEnter = ( ) => this . showTooltip ( element , text , position , classes , isHTML ) ;
4547 const mouseLeave = ( ) => this . hideTooltip ( classes ) ;
4648
4749 element . gmTooltipListeners = { mouseEnter, mouseLeave} ;
@@ -68,14 +70,19 @@ export default class TooltipManager {
6870 * @param {string } [preferredPosition] - Preferred position ('top', 'bottom', 'left', 'right')
6971 */
7072
71- showTooltip ( target , text , preferredPosition , classes ) {
73+ showTooltip ( target , text , preferredPosition , classes , isHTML ) {
7274 if ( classes ) {
7375 classes . split ( ' ' ) . forEach ( ( cl ) => {
7476 this . tooltipElement . classList . add ( cl ) ;
7577 } ) ;
7678 }
77- this . tooltipElement . querySelector ( '.gm-tooltip-body' ) . textContent = text ;
79+ if ( isHTML ) {
80+ this . tooltipElement . querySelector ( '.gm-tooltip-body' ) . innerHTML = text ;
81+ } else {
82+ this . tooltipElement . querySelector ( '.gm-tooltip-body' ) . textContent = text ;
83+ }
7884 this . tooltipElement . classList . remove ( 'top' , 'bottom' , 'left' , 'right' ) ;
85+ this . resetArrowPosition ( ) ;
7986
8087 const pos = this . computePosition ( target , preferredPosition ) ;
8188 this . tooltipElement . style . left = pos . left + 'px' ;
@@ -119,39 +126,70 @@ export default class TooltipManager {
119126 const rect = target . getBoundingClientRect ( ) ;
120127 const tooltipRect = this . tooltipElement . getBoundingClientRect ( ) ;
121128 const spacing = 10 ;
129+ const viewportWidth = window . innerWidth ;
130+ const viewportHeight = window . innerHeight ;
131+ const padding = 8 ;
132+ const maxLeft = Math . max ( padding , viewportWidth - tooltipRect . width - padding ) ;
133+ const maxTop = Math . max ( padding , viewportHeight - tooltipRect . height - padding ) ;
134+ // These functions ensure the tooltip stays within the viewport and adjust the arrow position accordingly
135+ const clampLeft = ( left , position , top ) => {
136+ const clamped = Math . min ( Math . max ( padding , left ) , maxLeft ) ;
137+ if ( clamped !== left ) {
138+ this . updateArrowPosition ( target , position , clamped , top ) ;
139+ }
140+ return clamped ;
141+ } ;
142+ const clampTop = ( top , position , left ) => {
143+ const clamped = Math . min ( Math . max ( padding , top ) , maxTop ) ;
144+ if ( clamped !== top ) {
145+ this . updateArrowPosition ( target , position , left , clamped ) ;
146+ }
147+ return clamped ;
148+ } ;
149+ // These functions check if the tooltip fits within the viewport
150+ const fitsHorizontally = ( left ) => left >= padding && left + tooltipRect . width <= viewportWidth - padding ;
151+ const fitsVertically = ( top ) => top >= padding && top + tooltipRect . height <= viewportHeight - padding ;
122152 const positions = preferred ? [ preferred ] : [ 'bottom' , 'left' , 'right' , 'top' ] ;
123153 for ( const pos of positions ) {
124154 let left , top ;
125155 switch ( pos ) {
126156 case 'top' : {
127157 left = rect . left + ( rect . width - tooltipRect . width ) / 2 ;
128158 top = rect . top - tooltipRect . height - spacing ;
129- if ( top > 0 ) {
130- return { left : Math . max ( 8 , left ) , top, position : 'top' } ;
159+ if ( fitsVertically ( top ) ) {
160+ return { left : clampLeft ( left , 'top' , top ) , top : clampTop ( top , 'top' , left ) , position : 'top' } ;
131161 }
132162 break ;
133163 }
134164 case 'left' : {
135165 left = rect . left - tooltipRect . width - spacing ;
136166 top = rect . top + ( rect . height - tooltipRect . height ) / 2 ;
137- if ( left > 0 ) {
138- return { left, top : Math . max ( 8 , top ) , position : 'left' } ;
167+ if ( fitsHorizontally ( left ) ) {
168+ return { left : clampLeft ( left , 'left' , top ) , top : clampTop ( top , 'left' , left ) , position : 'left' } ;
139169 }
140170 break ;
141171 }
142172 case 'right' : {
143173 left = rect . right + spacing ;
144174 top = rect . top + ( rect . height - tooltipRect . height ) / 2 ;
145- if ( left + tooltipRect . width < window . innerWidth ) {
146- return { left, top : Math . max ( 8 , top ) , position : 'right' } ;
175+ if ( fitsHorizontally ( left ) ) {
176+ return {
177+ left : clampLeft ( left , 'right' , top ) ,
178+ top : clampTop ( top , 'right' , left ) ,
179+ position : 'right' ,
180+ } ;
147181 }
148182 break ;
149183 }
150184 case 'bottom' : {
151185 left = rect . left + ( rect . width - tooltipRect . width ) / 2 ;
152186 top = rect . bottom + spacing ;
153- if ( top + tooltipRect . height < window . innerHeight ) {
154- return { left : Math . max ( 8 , left ) , top, position : 'bottom' } ;
187+ if ( fitsVertically ( top ) ) {
188+ return {
189+ left : clampLeft ( left , 'bottom' , top ) ,
190+ top : clampTop ( top , 'bottom' , left ) ,
191+ position : 'bottom' ,
192+ } ;
155193 }
156194 break ;
157195 }
@@ -162,6 +200,42 @@ export default class TooltipManager {
162200 }
163201 const left = rect . left + ( rect . width - tooltipRect . width ) / 2 ;
164202 const top = rect . bottom + spacing ;
165- return { left : Math . max ( 8 , left ) , top, position : 'bottom' } ;
203+ return { left : clampLeft ( left , 'bottom' , top ) , top : clampTop ( top , 'bottom' , left ) , position : 'bottom' } ;
204+ }
205+
206+ resetArrowPosition ( ) {
207+ const arrow = this . tooltipElement . querySelector ( '.gm-tooltip-arrow' ) ;
208+ if ( ! arrow ) {
209+ return ;
210+ }
211+ arrow . style . removeProperty ( '--gm-tooltip-arrow-left' ) ;
212+ arrow . style . removeProperty ( '--gm-tooltip-arrow-top' ) ;
213+ }
214+
215+ updateArrowPosition ( target , position , tooltipLeft , tooltipTop ) {
216+ const arrow = this . tooltipElement . querySelector ( '.gm-tooltip-arrow' ) ;
217+ if ( ! arrow ) {
218+ return ;
219+ }
220+
221+ const targetRect = target . getBoundingClientRect ( ) ;
222+ const tooltipRect = this . tooltipElement . getBoundingClientRect ( ) ;
223+ const padding = 12 ;
224+
225+ if ( position === 'top' || position === 'bottom' ) {
226+ const targetCenterX = targetRect . left + targetRect . width / 2 ;
227+ const tooltipLeftValue = typeof tooltipLeft === 'number' ? tooltipLeft : tooltipRect . left ;
228+ const arrowLeft = targetCenterX - tooltipLeftValue ;
229+ const clampedLeft = Math . min ( Math . max ( padding , arrowLeft ) , tooltipRect . width - padding ) ;
230+ arrow . style . setProperty ( '--gm-tooltip-arrow-left' , `${ clampedLeft } px` ) ;
231+ }
232+
233+ if ( position === 'left' || position === 'right' ) {
234+ const targetCenterY = targetRect . top + targetRect . height / 2 ;
235+ const tooltipTopValue = typeof tooltipTop === 'number' ? tooltipTop : tooltipRect . top ;
236+ const arrowTop = targetCenterY - tooltipTopValue ;
237+ const clampedTop = Math . min ( Math . max ( padding , arrowTop ) , tooltipRect . height - padding ) ;
238+ arrow . style . setProperty ( '--gm-tooltip-arrow-top' , `${ clampedTop } px` ) ;
239+ }
166240 }
167241}
0 commit comments