|
1 | 1 | /** |
2 | | - * Universal Toaster (v2.0 - Stability Fixes) |
3 | | - * ------------------------------------------------------------------ |
4 | | - * Fixed: Tooltips sticking when scrolling, clicking, or changing tabs. |
5 | | - * ------------------------------------------------------------------ |
6 | | - */ |
7 | | - |
8 | | -/** |
9 | | - * Universal Toaster (v3.0 - Rich Text & Styles) |
| 2 | + * Universal Toaster (v3.1 - Mobile Viewport Fix) |
10 | 3 | * ------------------------------------------------------------------ |
| 4 | + * Fixes: Tooltips getting cut off on small mobile screens. |
11 | 5 | * Features: |
12 | | - * - HTML Safe (Prevents XSS while allowing custom styling) |
13 | | - * - Custom Shortcodes: Color, Bg, Font, Weight |
14 | | - * - Dynamic Google Fonts Loader |
15 | | - * - Character Truncation Logic |
| 6 | + * - New Mobile-Aware Positioning: Centers the tooltip on mobile and |
| 7 | + * clamps it to the screen edges for full visibility. |
16 | 8 | * ------------------------------------------------------------------ |
17 | 9 | */ |
18 | 10 |
|
19 | 11 | (function () { |
| 12 | + // --- 1. SETUP (No changes here) --- |
20 | 13 | const userConfig = window.UniversalToasterConfig || {}; |
21 | 14 |
|
22 | | - // --- 1. SETUP STYLES --- |
23 | 15 | const settings = { |
24 | 16 | bg: userConfig.backgroundColor || null, |
25 | 17 | text: userConfig.textColor || null, |
|
36 | 28 | z-index: 2147483647; |
37 | 29 | pointer-events: none; |
38 | 30 | opacity: 0; |
39 | | - transition: opacity 0.1s ease-out; |
| 31 | + transition: opacity 0.1s ease-out, transform 0.1s ease-out; /* Added transform for smoother mobile positioning */ |
40 | 32 | visibility: hidden; |
41 | | - |
42 | | - /* Visuals */ |
43 | 33 | border-radius: ${settings.radius}; |
44 | 34 | font-size: ${settings.size}; |
45 | 35 | font-family: ${settings.font}; |
46 | 36 | padding: ${settings.padding}; |
47 | 37 | box-shadow: ${settings.shadow}; |
48 | 38 | border: 1px solid rgba(255,255,255,0.1); |
49 | | - |
50 | | - /* Layout */ |
51 | | - white-space: pre-wrap; /* Changed to allow flexibility with formatting */ |
52 | | - max-width: 90vw; |
| 39 | + white-space: pre-wrap; |
| 40 | + max-width: 95vw; |
53 | 41 | line-height: 1.4; |
54 | 42 | } |
55 | 43 | .universal-toaster-popup.visible { |
56 | 44 | opacity: 1; |
57 | 45 | visibility: visible; |
58 | 46 | } |
59 | | - /* Inner spans for custom styling */ |
60 | 47 | .universal-toaster-popup span { |
61 | 48 | display: inline-block; |
62 | 49 | } |
|
70 | 57 | tooltip.className = 'universal-toaster-popup'; |
71 | 58 | document.body.appendChild(tooltip); |
72 | 59 |
|
73 | | - // --- 2. HELPERS & PARSERS --- |
74 | | - |
| 60 | + // --- 2. HELPERS & PARSERS (No changes here) --- |
75 | 61 | let activeElement = null; |
76 | | - const loadedFonts = new Set(); // Track loaded fonts to avoid duplicates |
77 | | - |
78 | | - // A. Sanitize HTML (Security First) |
79 | | - function escapeHtml(text) { |
80 | | - if (!text) return ""; |
81 | | - return text |
82 | | - .replace(/&/g, "&") |
83 | | - .replace(/</g, "<") |
84 | | - .replace(/>/g, ">") |
85 | | - .replace(/"/g, """) |
86 | | - .replace(/'/g, "'"); |
87 | | - } |
88 | | - |
89 | | - // B. Google Fonts Loader |
90 | | - function loadGoogleFont(fontName) { |
91 | | - if (!fontName || loadedFonts.has(fontName)) return; |
92 | | - |
93 | | - const link = document.createElement('link'); |
94 | | - link.href = `https://fonts.googleapis.com/css?family=${fontName.replace(/\s+/g, '+')}&display=swap`; |
95 | | - link.rel = 'stylesheet'; |
96 | | - document.head.appendChild(link); |
97 | | - |
98 | | - loadedFonts.add(fontName); |
99 | | - } |
100 | | - |
101 | | - // C. The Magic Parser (Converts &cmd=val; into HTML) |
102 | | - function parseCustomSyntax(rawText) { |
103 | | - // 1. First, Escape HTML to prevent script injection |
104 | | - // note: We temporarily unescape ampersands used in OUR commands afterward |
105 | | - let text = escapeHtml(rawText); |
106 | | - |
107 | | - // 2. Decode our specific command ampersands so regex works |
108 | | - // (Because escapeHtml turns '&cl=' into '&cl=') |
109 | | - text = text.replace(/&(fz|cl|bgcl|fw|fn|chr)=/g, "&$1="); |
110 | | - text = text.replace(/&(fz|cl|bgcl|fw|fn|chr);/g, "&$1;"); |
111 | | - |
112 | | - // 3. Process Character Limits (&chr=10; text &chr;) |
113 | | - text = text.replace(/&chr=(\d+);(.*?)&chr;/g, (match, limit, content) => { |
114 | | - if (content.length > parseInt(limit)) { |
115 | | - return content.substring(0, parseInt(limit)) + '...'; |
116 | | - } |
117 | | - return content; |
118 | | - }); |
119 | | - |
120 | | - // 4. Process Google Fonts (&fn=Roboto; text &fn;) |
121 | | - text = text.replace(/&fn=(.*?);/g, (match, fontName) => { |
122 | | - loadGoogleFont(fontName); |
123 | | - return `<span style="font-family:'${fontName}', sans-serif">`; |
124 | | - }); |
125 | | - text = text.replace(/&fn;/g, '</span>'); |
126 | | - |
127 | | - // 5. Process Colors & Weights |
128 | | - // Replaces &tag=value; with <span style="..."> |
129 | | - const replacers = [ |
130 | | - { tag: 'fz', css: 'font-size' }, |
131 | | - { tag: 'cl', css: 'color' }, |
132 | | - { tag: 'bgcl', css: 'background-color' }, |
133 | | - { tag: 'fw', css: 'font-weight' } |
134 | | - ]; |
135 | | - |
136 | | - replacers.forEach(item => { |
137 | | - // Replace Opener: &cl=red; -> <span style="color:red"> |
138 | | - const openerRegex = new RegExp(`&${item.tag}=(.*?);`, 'g'); |
139 | | - text = text.replace(openerRegex, `<span style="${item.css}:$1">`); |
140 | | - |
141 | | - // Replace Closer: &cl; -> </span> |
142 | | - const closerRegex = new RegExp(`&${item.tag};`, 'g'); |
143 | | - text = text.replace(closerRegex, '</span>'); |
144 | | - }); |
145 | | - |
146 | | - return text; |
147 | | - } |
148 | | - |
149 | | - // D. Auto Contrast Theme |
150 | | - function applyTheme() { |
151 | | - // User overrides |
152 | | - if (settings.bg && settings.text) { |
153 | | - tooltip.style.backgroundColor = settings.bg; |
154 | | - tooltip.style.color = settings.text; |
155 | | - return; |
156 | | - } |
| 62 | + const loadedFonts = new Set(); |
157 | 63 |
|
158 | | - // Auto-detect |
159 | | - let computedStyle = window.getComputedStyle(document.body); |
160 | | - let bgColor = computedStyle.backgroundColor; |
161 | | - if (bgColor === 'rgba(0, 0, 0, 0)' || bgColor === 'transparent') bgColor = 'rgb(255, 255, 255)'; |
162 | | - |
163 | | - const rgb = bgColor.match(/\d+/g); |
164 | | - let isLightPage = true; |
165 | | - if (rgb) { |
166 | | - const brightness = Math.round(((parseInt(rgb[0]) * 299) + (parseInt(rgb[1]) * 587) + (parseInt(rgb[2]) * 114)) / 1000); |
167 | | - if (brightness < 125) isLightPage = false; |
168 | | - } |
169 | | - |
170 | | - if (isLightPage) { |
171 | | - tooltip.style.backgroundColor = '#222222'; |
172 | | - tooltip.style.color = '#ffffff'; |
173 | | - } else { |
174 | | - tooltip.style.backgroundColor = '#ffffff'; |
175 | | - tooltip.style.color = '#000000'; |
176 | | - } |
177 | | - } |
| 64 | + function escapeHtml(text) { if (!text) return ""; return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'"); } |
| 65 | + function loadGoogleFont(fontName) { if (!fontName || loadedFonts.has(fontName)) return; const link = document.createElement('link'); link.href = `https://fonts.googleapis.com/css?family=${fontName.replace(/\s+/g, '+')}&display=swap`; link.rel = 'stylesheet'; document.head.appendChild(link); loadedFonts.add(fontName); } |
| 66 | + function parseCustomSyntax(rawText) { let text = escapeHtml(rawText); text = text.replace(/&(cl|bgcl|fw|fn|chr)=/g, "&$1=").replace(/&(cl|bgcl|fw|fn|chr);/g, "&$1;"); text = text.replace(/&chr=(\d+);(.*?)&chr;/g, (m, l, c) => (c.length > parseInt(l) ? c.substring(0, parseInt(l)) + '...' : c)); text = text.replace(/&fn=(.*?);/g, (m, f) => { loadGoogleFont(f); return `<span style="font-family:'${f}', sans-serif">`; }); text = text.replace(/&fn;/g, '</span>');[{ tag: 'cl', css: 'color' }, { tag: 'bgcl', css: 'background-color' }, { tag: 'fw', css: 'font-weight' }].forEach(i => { text = text.replace(new RegExp(`&${i.tag}=(.*?);`, 'g'), `<span style="${i.css}:$1">`).replace(new RegExp(`&${i.tag};`, 'g'), '</span>'); }); return text; } |
| 67 | + function applyTheme() { if (settings.bg && settings.text) { tooltip.style.backgroundColor = settings.bg; tooltip.style.color = settings.text; return; } let s = window.getComputedStyle(document.body); let c = s.backgroundColor; if (c === 'rgba(0, 0, 0, 0)' || c === 'transparent') c = 'rgb(255, 255, 255)'; const r = c.match(/\d+/g); let i = true; if (r) { const b = Math.round(((parseInt(r[0]) * 299) + (parseInt(r[1]) * 587) + (parseInt(r[2]) * 114)) / 1000); if (b < 125) i = false; } if (i) { tooltip.style.backgroundColor = '#222222'; tooltip.style.color = '#ffffff'; } else { tooltip.style.backgroundColor = '#ffffff'; tooltip.style.color = '#000000'; } } |
178 | 68 |
|
179 | 69 | // --- 3. EVENT LISTENERS --- |
180 | | - |
181 | | - const hideTooltip = () => { |
182 | | - if (activeElement) { |
183 | | - tooltip.classList.remove('visible'); |
184 | | - activeElement = null; |
185 | | - } |
186 | | - }; |
| 70 | + const hideTooltip = () => { if (activeElement) { tooltip.classList.remove('visible'); activeElement = null; } }; |
187 | 71 |
|
188 | 72 | document.addEventListener('mouseover', (e) => { |
189 | 73 | const target = e.target.closest('[title], [data-toaster-title]'); |
190 | | - |
191 | 74 | if (target) { |
192 | | - if (target.hasAttribute('title')) { |
193 | | - const raw = target.getAttribute('title'); |
194 | | - if (raw && raw.trim()) { |
195 | | - target.setAttribute('data-toaster-title', raw); |
196 | | - target.removeAttribute('title'); |
197 | | - } |
198 | | - } |
199 | | - |
| 75 | + if (target.hasAttribute('title')) { const raw = target.getAttribute('title'); if (raw && raw.trim()) { target.setAttribute('data-toaster-title', raw); target.removeAttribute('title'); } } |
200 | 76 | const rawText = target.getAttribute('data-toaster-title'); |
201 | | - if (rawText) { |
202 | | - activeElement = target; |
203 | | - |
204 | | - // PARSE THE TEXT HERE |
205 | | - tooltip.innerHTML = parseCustomSyntax(rawText); |
206 | | - |
207 | | - applyTheme(); |
208 | | - tooltip.classList.add('visible'); |
209 | | - } |
| 77 | + if (rawText) { activeElement = target; tooltip.innerHTML = parseCustomSyntax(rawText); applyTheme(); tooltip.classList.add('visible'); } |
210 | 78 | } |
211 | 79 | }); |
212 | 80 |
|
| 81 | + // ############################################################### |
| 82 | + // ### POSITIONING LOGIC - UPDATED FOR MOBILE AWARENESS ### |
| 83 | + // ############################################################### |
213 | 84 | document.addEventListener('mousemove', (e) => { |
214 | | - if (!activeElement || !activeElement.isConnected) { |
215 | | - hideTooltip(); |
216 | | - return; |
217 | | - } |
| 85 | + if (!activeElement || !activeElement.isConnected) { hideTooltip(); return; } |
218 | 86 | if (!tooltip.classList.contains('visible')) return; |
219 | 87 |
|
220 | 88 | const rect = tooltip.getBoundingClientRect(); |
221 | 89 | const winW = window.innerWidth; |
222 | 90 | const winH = window.innerHeight; |
223 | | - const offset = 15; |
| 91 | + const offset = 15; // Vertical offset on mobile, cursor offset on desktop |
| 92 | + const mobileBreakpoint = 768; // The screen width to switch to mobile logic |
| 93 | + const mobileEdgePadding = 10; // Space from the screen edge on mobile |
| 94 | + |
| 95 | + let x, y; |
224 | 96 |
|
225 | | - let x = e.clientX + offset; |
226 | | - let y = e.clientY + offset; |
| 97 | + // --- Vertical Positioning (same for both) --- |
| 98 | + // Position below the cursor/finger, but flip above if it overflows the bottom |
| 99 | + y = e.clientY + offset; |
| 100 | + if (y + rect.height > winH) { |
| 101 | + y = e.clientY - rect.height - offset; |
| 102 | + } |
227 | 103 |
|
228 | | - if (x + rect.width > winW) x = e.clientX - rect.width - offset; |
229 | | - if (y + rect.height > winH) y = e.clientY - rect.height - offset; |
| 104 | + // --- Horizontal Positioning (DIFFERENT for mobile vs desktop) --- |
| 105 | + if (winW <= mobileBreakpoint) { |
| 106 | + // MOBILE LOGIC: Center it, then clamp it to screen edges. |
| 107 | + x = e.clientX - (rect.width / 2); // Center under the finger |
230 | 108 |
|
231 | | - tooltip.style.left = `${x}px`; |
232 | | - tooltip.style.top = `${y}px`; |
| 109 | + // Clamp to left edge |
| 110 | + if (x < mobileEdgePadding) { |
| 111 | + x = mobileEdgePadding; |
| 112 | + } |
| 113 | + // Clamp to right edge |
| 114 | + if (x + rect.width > winW - mobileEdgePadding) { |
| 115 | + x = winW - rect.width - mobileEdgePadding; |
| 116 | + } |
| 117 | + |
| 118 | + } else { |
| 119 | + // DESKTOP LOGIC: Position to the right of cursor, but flip left if it overflows. |
| 120 | + x = e.clientX + offset; |
| 121 | + if (x + rect.width > winW) { |
| 122 | + x = e.clientX - rect.width - offset; |
| 123 | + } |
| 124 | + } |
| 125 | + |
| 126 | + tooltip.style.transform = `translate(${x}px, ${y}px)`; // Use transform for better performance |
| 127 | + // Clear old left/top styles if they exist |
| 128 | + tooltip.style.left = '0'; |
| 129 | + tooltip.style.top = '0'; |
233 | 130 | }); |
234 | 131 |
|
235 | 132 | document.addEventListener('mouseout', (e) => { |
236 | 133 | const target = e.target.closest('[data-toaster-title]'); |
237 | | - if (target && target === activeElement) { |
238 | | - hideTooltip(); |
239 | | - } |
| 134 | + if (target && target === activeElement) { hideTooltip(); } |
240 | 135 | }); |
241 | 136 |
|
242 | | - // Safety triggers |
| 137 | + // Safety Triggers (no changes) |
243 | 138 | window.addEventListener('mousedown', hideTooltip); |
244 | 139 | window.addEventListener('scroll', hideTooltip, true); |
245 | 140 | window.addEventListener('blur', hideTooltip); |
246 | | - |
247 | 141 | })(); |
0 commit comments