1 2var growler = { 3 debug: true, 4 timeoutId: null, 5 queue: [], 6 isShowing: false, 7 time: 4000, // Default timeout (4 seconds) 8 timeouts: { 9 success: 4000, // 4 seconds 10 error: 5000, // 5 seconds 11 warning: 5000, // 5 seconds 12 info: 4000, // 4 seconds 13 loading: 0 // Indefinite (until task completes) 14 }, 15 richColors: true, // Enable rich colors by default 16 showCloseButton: true, // Show close button by default 17 activeToasts: [], // Track active toast elements 18 VISIBLE_TOASTS: 4, // Maximum number of visible toasts (from Sonner pattern) 19 20 // Swipe constants (from Sonner) 21 SWIPE_THRESHOLD: 45, // pixels to trigger dismiss 22 VELOCITY_THRESHOLD: 0.11, // velocity in px/ms to trigger dismiss 23 24 icons: { 25 success: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" width="20" height="20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" /></svg>', 26 error: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" width="20" height="20"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" /></svg>', 27 warning: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" width="20" height="20"><path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" /></svg>', 28 info: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" width="20" height="20"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clip-rule="evenodd" /></svg>', 29 loading: '<div class="sonner-loading-wrapper" data-visible="true"><div class="sonner-spinner"><div class="sonner-loading-bar"></div><div class="sonner-loading-bar"></div><div class="sonner-loading-bar"></div><div class="sonner-loading-bar"></div><div class="sonner-loading-bar"></div><div class="sonner-loading-bar"></div><div class="sonner-loading-bar"></div><div class="sonner-loading-bar"></div><div class="sonner-loading-bar"></div><div class="sonner-loading-bar"></div><div class="sonner-loading-bar"></div><div class="sonner-loading-bar"></div></div></div>' 30 }, 31 32 init: function() { 33 const html = '<div id="GrowlerContainer"></div>'; 34 $("body").append(html); 35 36 // Add close button handler for dynamically created toasts 37 if (growler.showCloseButton) { 38 $(document).on('click', '.growler-toast .growler-close', function() { 39 const toastId = $(this).closest('.growler-toast').data('toast-id'); 40 growler.dismissToast(toastId); 41 }); 42 } 43 }, 44 45 attachSwipeHandlers: function($toast) { 46 const toastElement = $toast[0]; 47 48 // Attach pointer event handlers directly to the DOM element (like Sonner does) 49 toastElement.addEventListener('pointerdown', (e) => growler.handleSwipeStart(e, $toast), false); 50 toastElement.addEventListener('pointermove', (e) => growler.handleSwipeMove(e, $toast), false); 51 toastElement.addEventListener('pointerup', (e) => growler.handleSwipeEnd(e, $toast), false); 52 toastElement.addEventListener('dragend', (e) => growler.handleSwipeEnd(e, $toast), false); 53 }, 54 55 handleSwipeStart: function(e, $toast) { 56 // Don't start swipe on right click or buttons 57 if (e.button === 2) return; // Right click 58 if ($(e.target).closest('.growler-close').length) return; 59 if ($(e.target).is('button')) return; 60 61 // Store swipe data on the toast element 62 const swipeData = { 63 startX: e.clientX, 64 startY: e.clientY, 65 startTime: Date.now(), 66 swipeDirection: null, 67 isSwiping: false 68 }; 69 70 $toast.data('swipe', swipeData); 71 $toast.attr('data-swiping', 'false'); 72 73 // Critical: Set pointer capture to track movement even outside toast 74 e.target.setPointerCapture(e.pointerId); 75 }, 76 77 handleSwipeMove: function(e, $toast) { 78 const swipeData = $toast.data('swipe'); 79 if (!swipeData) return; 80 81 // Ignore if text is selected 82 const selection = window.getSelection(); 83 if (selection && selection.toString().length > 0) return; 84 85 const deltaX = e.clientX - swipeData.startX; 86 const deltaY = e.clientY - swipeData.startY; 87 88 // Determine swipe direction if not already locked 89 if (!swipeData.swipeDirection && (Math.abs(deltaX) > 1 || Math.abs(deltaY) > 1)) { 90 swipeData.swipeDirection = Math.abs(deltaX) > Math.abs(deltaY) ? 'x' : 'y'; 91 $toast.data('swipe', swipeData); 92 } 93 94 let swipeAmount = { x: 0, y: 0 }; 95 96 // Dampening function for wrong-direction movement 97 const getDampening = (delta) => { 98 const factor = Math.abs(delta) / 20; 99 return 1 / (1.5 + factor); 100 }; 101 102 // Apply swipe only in locked direction 103 if (swipeData.swipeDirection === 'y') { 104 if (deltaY > 0) { 105 swipeAmount.y = deltaY; 106 } else { 107 const dampenedDelta = deltaY * getDampening(deltaY); 108 swipeAmount.y = Math.abs(dampenedDelta) < Math.abs(deltaY) ? dampenedDelta : deltaY; 109 } 110 } else if (swipeData.swipeDirection === 'x') { 111 swipeAmount.x = deltaX; 112 } 113 114 // Mark as swiping if movement detected 115 if (Math.abs(swipeAmount.x) > 0 || Math.abs(swipeAmount.y) > 0) { 116 if (!swipeData.isSwiping) { 117 swipeData.isSwiping = true; 118 $toast.data('swipe', swipeData); 119 $toast.attr('data-swiping', 'true'); 120 } 121 } 122 123 // Apply transform using CSS variables 124 $toast.css({ 125 '--swipe-amount-x': `${swipeAmount.x}px`, 126 '--swipe-amount-y': `${swipeAmount.y}px` 127 }); 128 }, 129 130 handleSwipeEnd: function(e, $toast) { 131 const swipeData = $toast.data('swipe'); 132 if (!swipeData) return; 133 134 // Calculate final swipe amount and velocity 135 const swipeAmountX = parseFloat($toast.css('--swipe-amount-x')) || 0; 136 const swipeAmountY = parseFloat($toast.css('--swipe-amount-y')) || 0; 137 const timeTaken = Date.now() - swipeData.startTime; 138 139 const swipeAmount = swipeData.swipeDirection === 'x' ? swipeAmountX : swipeAmountY; 140 const velocity = Math.abs(swipeAmount) / timeTaken; 141 142 // Dismiss if threshold met or velocity high enough 143 if (Math.abs(swipeAmount) >= growler.SWIPE_THRESHOLD || velocity > growler.VELOCITY_THRESHOLD) { 144 // Determine swipe-out direction 145 let swipeOutDirection; 146 if (swipeData.swipeDirection === 'x') { 147 swipeOutDirection = swipeAmountX > 0 ? 'right' : 'left'; 148 } else { 149 swipeOutDirection = swipeAmountY > 0 ? 'down' : 'up'; 150 } 151 152 $toast.attr('data-swipe-out', 'true'); 153 $toast.attr('data-swipe-direction', swipeOutDirection); 154 155 // Dismiss after animation 156 const toastId = $toast.data('toast-id'); 157 growler.dismissToast(toastId); 158 } else { 159 // Snap back - reset transform 160 $toast.css({
161 '--swipe-amount-x': '0px', 162 '--swipe-amount-y': '0px' 163 }); 164 } 165 166 // Clean up 167 $toast.attr('data-swiping', 'false'); 168 $toast.removeData('swipe'); 169 }, 170 171 showNext: function() { 172 if (growler.queue.length === 0) return; 173 174 let {type, title, message, action, promiseId, uploadId, progress} = growler.queue.shift(); 175 176 // Create unique ID for this toast 177 const toastId = 'toast-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9); 178 growler.activeToasts.push(toastId); 179 180 // Build action button HTML if provided 181 let actionHtml = ''; 182 if (action && action.label) { 183 actionHtml = `<button class="growler-action" data-toast-id="${toastId}">${action.label}</button>`; 184 } 185 186 // Create toast element (force loading type for promise toasts) 187 const toastType = promiseId ? 'loading' : type; 188 189 // Build progress bar HTML for upload toasts 190 let progressHtml = ''; 191 if (uploadId !== undefined) { 192 const currentProgress = progress || 0; 193 progressHtml = ` 194 <div class="growler-progress-container"> 195 <div class="growler-progress-header"> 196 <span class="growler-progress-status">${message}</span> 197 <span class="growler-progress-text">${currentProgress}%</span> 198 </div> 199 <div class="growler-progress-bar"> 200 <div class="growler-progress-fill" style="width: ${currentProgress}%"></div> 201 </div> 202 </div> 203 `; 204 } 205 206 const toastHtml = ` 207 <div class="growler-toast ${toastType}" 208 data-toast-id="${toastId}" 209 data-rich-colors="${growler.richColors}" 210 ${promiseId ? `data-promise-id="${promiseId}" data-loading="true"` : ''} 211 ${uploadId ? `data-upload-id="${uploadId}"` : ''}> 212 <div class="growler-content"> 213 <div class="growler-icon">${promiseId ? growler.icons.loading : (growler.icons[toastType] || '')}</div> 214 <div class="growler-text"> 215 <div class="title">${title}</div> 216 ${uploadId ? progressHtml : `<div class="message">${message}</div>`} 217 ${actionHtml ? `<div class="growler-actions">${actionHtml}</div>` : ''} 218 </div> 219 ${growler.showCloseButton ? '<button class="growler-close" aria-label="Close">×</button>' : ''} 220 </div> 221 </div> 222 `; 223 224 $("#GrowlerContainer").append(toastHtml); 225 226 // Get the toast element and attach swipe handlers directly 227 const $toast = $(`.growler-toast[data-toast-id="${toastId}"]`); 228 growler.attachSwipeHandlers($toast); 229 230 // Attach action button handler if provided 231 if (action && action.label && action.onClick) { 232 $toast.find('.growler-action').on('click', function() { 233 action.onClick(); 234 if (action.dismissOnClick !== false) { 235 growler.dismissToast(toastId); 236 } 237 }); 238 } 239 240 // Trigger animation and update indices after DOM settles 241 setTimeout(() => { 242 $toast.addClass("open"); 243 // Update indices after toast is added and sized 244 setTimeout(() => { 245 growler.updateIndices(); 246 }, 50); 247 }, 10); 248 249 // Auto-dismiss based on type (skip for loading/promise toasts and action toasts) 250 if (!promiseId && !uploadId && !action) { 251 const timeout = growler.timeouts[toastType] || growler.time; 252 if (timeout > 0) { 253 const timeoutId = setTimeout(function() { 254 growler.dismissToast(toastId); 255 }, timeout); 256 257 // Store timeout ID on the toast element 258 $toast.data('timeoutId', timeoutId); 259 } 260 } 261 262 return toastId; 263 }, 264 265 updateIndices: function() { 266 // Update all toast data attributes when stack changes 267 // With column-reverse, the LAST element (highest index) is visually on top (front) 268 const toasts = $('.growler-toast'); 269 const totalToasts = toasts.length; 270 271 let frontToastHeight = 0; 272 273 toasts.each(function(index) { 274 // Reverse index so the last toast (visually on top) has index 0 275 const reverseIndex = totalToasts - 1 - index; 276 const isFront = reverseIndex === 0; 277 // Sonner pattern: isVisible = index + 1 <= visibleToasts (but we use 0-based index) 278 const isVisible = reverseIndex < growler.VISIBLE_TOASTS; 279 280 $(this).attr('data-index', reverseIndex); 281 $(this).attr('data-front', isFront); 282 $(this).attr('data-visible', isVisible); 283 $(this).css('--index', reverseIndex); // Update CSS variable for calc() 284 285 // Get height of front toast (index 0) 286 if (isFront) { 287 frontToastHeight = $(this).outerHeight(); 288 } 289 }); 290 291 // Update CSS variable for front toast height 292 $('#GrowlerContainer').css('--front-toast-height', frontToastHeight + 'px'); 293 }, 294 295 dismissToast: function(toastId) { 296 const $toast = $(`.growler-toast[data-toast-id="${toastId}"]`); 297 if ($toast.length === 0) return; 298
299 // Clear any pending timeout 300 const timeoutId = $toast.data('timeoutId'); 301 if (timeoutId) clearTimeout(timeoutId); 302 303 // Remove from active toasts 304 growler.activeToasts = growler.activeToasts.filter(id => id !== toastId); 305 306 // Animate out 307 $toast.removeClass('open'); 308 309 setTimeout(() => { 310 $toast.remove(); 311 // Update indices for remaining toasts 312 growler.updateIndices(); 313 }, 300); 314 }, 315 316 enqueue: function(type, title, message, options = {}) { 317 growler.queue.push({ 318 type, 319 title, 320 message, 321 action: options.action, 322 promiseId: options.promiseId, 323 uploadId: options.uploadId, 324 progress: options.progress 325 }); 326 return growler.showNext(); 327 }, 328 329 upload: function(title, filename = '') { 330 const uploadId = 'upload-' + Date.now(); 331 const message = filename ? `Uploading ${filename}...` : 'Uploading...'; 332 growler.enqueue('loading', title, message, { uploadId, progress: 0 }); 333 return uploadId; 334 }, 335 336 updateUpload: function(uploadId, progress, statusText = '') { 337 const $toast = $(`.growler-toast[data-upload-id="${uploadId}"]`); 338 if (!$toast.length) return; 339 340 // Update progress bar 341 $toast.find('.growler-progress-fill').css('width', `${progress}%`); 342 $toast.find('.growler-progress-text').text(`${progress}%`); 343 344 // Update status text if provided 345 if (statusText) { 346 $toast.find('.growler-progress-status').text(statusText); 347 } 348 349 // Auto-transition to success at 100% 350 if (progress >= 100) { 351 setTimeout(() => { 352 if (!$toast.length || $toast.hasClass('removing')) return; 353 354 // Get toast ID before removing attributes 355 const toastId = $toast.data('toast-id'); 356 357 $toast.removeClass('loading').addClass('success'); 358 $toast.find('.title').text('Upload Complete'); 359 $toast.find('.growler-icon').html(growler.icons.success); 360 $toast.find('.growler-progress-container').remove(); 361 $toast.find('.growler-text').append(`<div class="message">All set!</div>`); 362 $toast.removeAttr('data-upload-id'); 363 364 // Auto-dismiss after 4 seconds (success timeout) 365 const timeoutId = setTimeout(() => { 366 growler.dismissToast(toastId); 367 }, 4000); 368 $toast.data('timeoutId', timeoutId); 369 }, 500); 370 } 371 }, 372 373 failUpload: function(uploadId, errorMessage = 'Upload failed') { 374 const $toast = $(`.growler-toast[data-upload-id="${uploadId}"]`); 375 if ($toast.length === 0) return; 376 377 const toastId = $toast.data('toast-id'); 378 379 // Transition to error state 380 $toast.removeClass('loading').addClass('error'); 381 $toast.find('.title').text('Upload Failed'); 382 $toast.find('.growler-icon').html(growler.icons.error); 383 $toast.find('.growler-progress-container').remove(); 384 $toast.find('.growler-text').append(`<div class="message">${errorMessage}</div>`); 385 $toast.removeAttr('data-upload-id'); 386 387 // Auto-dismiss after 6 seconds (error timeout) 388 const timeoutId = setTimeout(() => { 389 growler.dismissToast(toastId); 390 }, 6000); 391 $toast.data('timeoutId', timeoutId); 392 }, 393 394 error: function(message) { 395 growler.enqueue("error","An Error Occurred",message); 396 }, 397 398 success: function(message) { 399 growler.enqueue("success","Success",message); 400 }, 401 402 info: function(message) { 403 growler.enqueue("info","Information",message); 404 }, 405 406 warning: function(message) { 407 growler.enqueue("warning","Warning",message); 408 }, 409 410 action: function(message, action) { 411 growler.enqueue("info", "Action Required", message, { action }); 412 }, 413 414 promise: function(promise, { loading, success, error }) { 415 // Create unique promise ID 416 const promiseId = 'promise-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9); 417 418 // Show loading toast 419 const toastId = growler.enqueue( 420 "loading", 421 loading.title || "Loading", 422 loading.message || "Please wait...", 423 { promiseId } 424 ); 425 426 // Handle promise resolution 427 promise 428 .then((data) => { 429 // Update toast to success 430 growler.updatePromiseToast(toastId, { 431 type: "success", 432 title: success.title || "Success", 433 message: typeof success.message === 'function' ? success.message(data) : success.message || "Operation completed" 434 }); 435 }) 436 .catch((err) => { 437 // Update toast to error 438 growler.updatePromiseToast(toastId, { 439 type: "error", 440 title: error.title || "Error", 441 message: typeof error.message === 'function' ? error.message(err) : error.message || "Operation failed" 442 }); 443 }); 444 445 return promise; 446 }, 447 448 updatePromiseToast: function(toastId, { type, title, message }) { 449 const $toast = $(`.growler-toast[data-toast-id="${toastId}"]`); 450 if ($toast.length === 0) return; 451 452 // Update class and icon 453 $toast.removeClass('loading success error warning info') 454 .addClass(type); 455 $toast.find('.growler-icon').html(growler.icons[type] || ''); 456 457 // Update text 458 $toast.find('.title').text(title); 459 $toast.find('.message').text(message); 460 461 // Remove promise ID 462 $toast.removeAttr('data-promise-id'); 463 464 // Set auto-dismiss timeout 465 const timeoutId = setTimeout(function() { 466 growler.dismissToast(toastId); 467 }, growler.time); 468 469 $toast.data('timeoutId', timeoutId); 470 } 471};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.