1/** 2 * Editorial detail interop -- progress bar, share, lightbox, back-to-top, gallery. 3 */ 4window.articleInterop = (function () { 5 let _lightboxImages = []; 6 let _lightboxIndex = 0; 7 let _savedOverflow = ''; 8 let _progressStartElement = null; 9 let _progressEndElement = null; 10 let _progressChrome = null; 11 let _progressBar = null; 12 let _progressFrameId = null; 13 let _progressUpdateScheduled = false; 14 let _progressScrollHandler = null; 15 let _progressResizeHandler = null; 16 let _progressResizeObserver = null; 17 let _editorialDetailObserver = null; 18 let _carouselAnimationFrames = new WeakMap(); 19 let _carouselAnimatingTracks = new WeakSet(); 20 21 function getEditorialDetailOptions(options) { 22 options = options || {}; 23 24 return { 25 progressRootSelector: options.progressRootSelector || '[data-editorial-detail-root]', 26 progressSelector: options.progressSelector || '[data-editorial-progress]', 27 chromeSelector: options.chromeSelector || '[data-editorial-detail-chrome]' 28 }; 29 } 30 31 function stopEditorialDetailObserver() { 32 if (_editorialDetailObserver) { 33 _editorialDetailObserver.disconnect(); 34 _editorialDetailObserver = null; 35 } 36 } 37 38 function observeEditorialDetailWhenPresent(options) { 39 if (_editorialDetailObserver || typeof MutationObserver !== 'function') { 40 return; 41 } 42 43 var detailOptions = getEditorialDetailOptions(options); 44 45 var target = document.body || document.documentElement; 46 if (!(target instanceof HTMLElement)) { 47 return; 48 } 49 50 _editorialDetailObserver = new MutationObserver(function () { 51 var progressRoot = document.querySelector(detailOptions.progressRootSelector); 52 var progress = document.querySelector(detailOptions.progressSelector); 53 if (progressRoot instanceof HTMLElement && progress instanceof HTMLElement) { 54 initEditorialDetail(detailOptions); 55 } 56 }); 57 _editorialDetailObserver.observe(target, { childList: true, subtree: true }); 58 } 59 60 function destroyProgressBar() { 61 stopEditorialDetailObserver(); 62 63 if (_progressScrollHandler) { 64 window.removeEventListener('scroll', _progressScrollHandler); 65 _progressScrollHandler = null; 66 } 67 68 if (_progressResizeHandler) { 69 window.removeEventListener('resize', _progressResizeHandler); 70 _progressResizeHandler = null; 71 } 72 73 if (_progressResizeObserver) { 74 _progressResizeObserver.disconnect(); 75 _progressResizeObserver = null; 76 } 77 78 if (_progressFrameId !== null) { 79 cancelAnimationFrame(_progressFrameId); 80 _progressFrameId = null; 81 } 82 83 if (_progressBar instanceof HTMLElement && _progressBar.isConnected) { 84 _progressBar.style.removeProperty('--article-progress-scale'); 85 } 86 87 _progressStartElement = null; 88 _progressEndElement = null; 89 _progressChrome = null; 90 _progressBar = null; 91 _progressUpdateScheduled = false; 92 } 93 94 function updateProgressBar() { 95 _progressUpdateScheduled = false; 96 _progressFrameId = null; 97 98 if ( 99 !(_progressStartElement instanceof HTMLElement) || 100 !(_progressEndElement instanceof HTMLElement) || 101 !(_progressBar instanceof HTMLElement) || 102 !_progressStartElement.isConnected || 103 !_progressEndElement.isConnected || 104 !_progressBar.isConnected 105 ) { 106 destroyProgressBar(); 107 return; 108 } 109 110 var startRect = _progressStartElement.getBoundingClientRect(); 111 var endRect = _progressEndElement.getBoundingClientRect(); 112 var start = startRect.top + window.scrollY; 113 var end = endRect.bottom + window.scrollY; 114 115 // Tie progress to the visual reading line beneath the sticky chrome. 116 var anchor = window.scrollY; 117 if (_progressChrome instanceof HTMLElement && _progressChrome.isConnected) { 118 anchor += _progressChrome.getBoundingClientRect().bottom; 119 } 120 121 var startScrollY = start - anchor; 122 var endScrollY = end - window.innerHeight; 123 124 if (endScrollY <= startScrollY) { 125 var immediateProgress = window.scrollY >= endScrollY ? 1 : 0; 126 _progressBar.style.setProperty('--article-progress-scale', immediateProgress.toString()); 127 return; 128 } 129 130 var progress = Math.min(Math.max((window.scrollY - startScrollY) / (endScrollY - startScrollY), 0), 1); 131 _progressBar.style.setProperty('--article-progress-scale', progress.toString()); 132 } 133 134 function scheduleProgressBarUpdate() { 135 if (_progressUpdateScheduled) { 136 return; 137 } 138 139 _progressUpdateScheduled = true; 140 _progressFrameId = requestAnimationFrame(updateProgressBar); 141 } 142 143 // ââ Reading progress bar ââââââââââââââââââââââââââââââââââââââââââ 144 function initProgressBar(startSelector, endSelector, progressSelector, chromeSelector) { 145 destroyProgressBar(); 146 147 var start = document.querySelector(startSelector); 148 var end = document.querySelector(endSelector); 149 var progress = document.querySelector(progressSelector); 150 var chrome = chromeSelector ? document.querySelector(chromeSelector) : null; 151 152 if ( 153 !(start instanceof HTMLElement) || 154 !(end instanceof HTMLElement) || 155 !(progress instanceof HTMLElement) 156 ) { 157 return; 158 } 159 160 _progressStartElement = start; 161 _progressEndElement = end; 162 _progressChrome = chrome instanceof HTMLElement ? chrome : null; 163 _progressBar = progress; 164 _progressScrollHandler = scheduleProgressBarUpdate; 165 _progressResizeHandler = scheduleProgressBarUpdate; 166 167 window.addEventListener('scroll', _progressScrollHandler, { passive: true }); 168 window.addEventListener('resize', _progressResizeHandler, { passive: true }); 169 170 if (typeof ResizeObserver === 'function') { 171 _progressResizeObserver = new ResizeObserver(scheduleProgressBarUpdate); 172 _progressResizeObserver.observe(_progressStartElement); 173 if (_progressEndElement !== _progressStartElement) { 174 _progressResizeObserver.observe(_progressEndElement); 175 } 176 if (_progressChrome) { 177 _progressResizeObserver.observe(_progressChrome); 178 } 179 } 180 181 scheduleProgressBarUpdate(); 182 } 183 184 function initEditorialDetail(options) { 185 var detailOptions = getEditorialDetailOptions(options); 186 var progressRoot = document.querySelector(detailOptions.progressRootSelector); 187 var progress = document.querySelector(detailOptions.progressSelector); 188 189 if (progressRoot instanceof HTMLElement && progress instanceof HTMLElement) { 190 stopEditorialDetailObserver(); 191 var rootSelector = detailOptions.progressRootSelector; 192 initProgressBar(rootSelector, rootSelector, detailOptions.progressSelector, detailOptions.chromeSelector); 193 } else { 194 destroyProgressBar(); 195 observeEditorialDetailWhenPresent(detailOptions); 196 } 197
198 initCarousel('#article-carousel'); 199 initInlineCarousels(); 200 } 201 202 function initArticlePage() { 203 initEditorialDetail(); 204 } 205 206 function initInlineCarousels() { 207 document.querySelectorAll('.article-carousel[data-inline-carousel]').forEach(function (carousel) { 208 if (carousel.id) initCarousel('#' + carousel.id); 209 }); 210 } 211 212 // ââ Copy article link âââââââââââââââââââââââââââââââââââââââââââââ 213 async function copyLink(button) { 214 var url = window.location.href; 215 var success = false; 216 217 try { 218 if (window.clipboardInterop && typeof window.clipboardInterop.copyText === 'function') { 219 success = await window.clipboardInterop.copyText(url); 220 } else if (navigator.clipboard && navigator.clipboard.writeText) { 221 await navigator.clipboard.writeText(url); 222 success = true; 223 } else { 224 // Fallback for older browsers 225 var ta = document.createElement('textarea'); 226 ta.value = url; 227 ta.style.position = 'fixed'; 228 ta.style.left = '-9999px'; 229 document.body.appendChild(ta); 230 ta.select(); 231 success = document.execCommand('copy'); 232 document.body.removeChild(ta); 233 } 234 } catch (_) { 235 success = false; 236 } 237 238 if (success && button) { 239 var original = button.getAttribute('aria-label'); 240 button.setAttribute('aria-label', 'Copied page link'); 241 button.classList.add('share-btn-copied'); 242 setTimeout(function () { 243 button.setAttribute('aria-label', original || ''); 244 button.classList.remove('share-btn-copied'); 245 }, 2000); 246 } 247 } 248 249 // ââ Native share ââââââââââââââââââââââââââââââââââââââââââââââââââ 250 async function nativeShare(title, text) { 251 if (!navigator.share) return; 252 try { 253 await navigator.share({ title: title, text: text, url: window.location.href }); 254 } catch (_) { 255 // User cancelled -- silently ignore 256 } 257 } 258 259 function canNativeShare() { 260 return !!navigator.share; 261 } 262 263 // ââ Lightbox ââââââââââââââââââââââââââââââââââââââââââââââââââââââ 264 var _previousFocus = null; 265 var _focusTrapHandler = null; 266 var _touchStartHandler = null; 267 var _touchEndHandler = null; 268 var _backdropClickHandler = null; 269 270 function openLightbox(images, startIndex) { 271 _lightboxImages = typeof images === 'string' ? JSON.parse(images) : images; 272 _lightboxIndex = startIndex || 0; 273 var overlay = document.getElementById('article-lightbox'); 274 if (!overlay) return; 275 276 _savedOverflow = document.body.style.overflow; 277 document.body.style.overflow = 'hidden'; 278 overlay.removeAttribute('hidden'); 279 overlay.style.display = ''; 280 _updateLightbox(); 281 282 _previousFocus = document.activeElement; 283 overlay.focus(); 284 285 // Focus trap 286 _focusTrapHandler = function (e) { 287 if (e.key !== 'Tab') return; 288 var focusable = overlay.querySelectorAll('button, [tabindex]'); 289 if (focusable.length === 0) return; 290 var first = focusable[0]; 291 var last = focusable[focusable.length - 1]; 292 if (e.shiftKey && document.activeElement === first) { 293 e.preventDefault(); 294 last.focus(); 295 } else if (!e.shiftKey && document.activeElement === last) { 296 e.preventDefault(); 297 first.focus(); 298 } 299 }; 300 overlay.addEventListener('keydown', _focusTrapHandler); 301 302 // Swipe support 303 var touchStartX = 0; 304 _touchStartHandler = function (e) { touchStartX = e.changedTouches[0].screenX; }; 305 _touchEndHandler = function (e) { 306 var diff = e.changedTouches[0].screenX - touchStartX; 307 if (Math.abs(diff) > 50) { 308 if (diff > 0) lightboxPrev();
309 else lightboxNext(); 310 } 311 }; 312 overlay.addEventListener('touchstart', _touchStartHandler, { passive: true }); 313 overlay.addEventListener('touchend', _touchEndHandler, { passive: true }); 314 315 // Close when clicking the dark backdrop (not nav buttons, close button, or image) 316 _backdropClickHandler = function (e) { 317 if (e.target === overlay) closeLightbox(); 318 }; 319 overlay.addEventListener('click', _backdropClickHandler); 320 } 321 322 function closeLightbox() { 323 var overlay = document.getElementById('article-lightbox'); 324 if (overlay) { 325 if (_focusTrapHandler) overlay.removeEventListener('keydown', _focusTrapHandler); 326 if (_touchStartHandler) overlay.removeEventListener('touchstart', _touchStartHandler); 327 if (_touchEndHandler) overlay.removeEventListener('touchend', _touchEndHandler); 328 if (_backdropClickHandler) overlay.removeEventListener('click', _backdropClickHandler); 329 } 330 331 if (_previousFocus) _previousFocus.focus(); 332 333 if (!overlay) return; 334 335 document.body.style.overflow = _savedOverflow; 336 overlay.setAttribute('hidden', ''); 337 _lightboxImages = []; 338 _lightboxIndex = 0; 339 _previousFocus = null; 340 _focusTrapHandler = null; 341 _touchStartHandler = null; 342 _touchEndHandler = null; 343 _backdropClickHandler = null; 344 } 345 346 function lightboxPrev() { 347 if (_lightboxImages.length === 0) return; 348 _lightboxIndex = (_lightboxIndex - 1 + _lightboxImages.length) % _lightboxImages.length; 349 _updateLightbox(); 350 } 351 352 function lightboxNext() { 353 if (_lightboxImages.length === 0) return; 354 _lightboxIndex = (_lightboxIndex + 1) % _lightboxImages.length; 355 _updateLightbox(); 356 } 357 358 function _updateLightbox() { 359 var overlay = document.getElementById('article-lightbox'); 360 if (!overlay || _lightboxImages.length === 0) return; 361 362 var current = _lightboxImages[_lightboxIndex]; 363 var img = overlay.querySelector('.article-lightbox img'); 364 if (img) { 365 img.src = current.url; 366 img.alt = current.alt || ''; 367 } 368 369 var counter = overlay.querySelector('.article-lightbox-counter'); 370 if (counter) { 371 counter.textContent = (_lightboxIndex + 1) + ' / ' + _lightboxImages.length; 372 } 373 374 var caption = overlay.querySelector('.article-lightbox-caption'); 375 if (caption) { 376 caption.textContent = current.caption || ''; 377 } 378 } 379 380 function initLightboxKeyboard() { 381 document.addEventListener('keydown', function (e) { 382 var overlay = document.getElementById('article-lightbox'); 383 if (!overlay || overlay.hasAttribute('hidden')) return; 384 385 if (e.key === 'Escape') { 386 closeLightbox(); 387 } else if (e.key === 'ArrowLeft') { 388 lightboxPrev(); 389 } else if (e.key === 'ArrowRight') { 390 lightboxNext(); 391 } 392 }); 393 } 394 395 // ââ Back to top âââââââââââââââââââââââââââââââââââââââââââââââââââ 396 function initBackToTop(buttonSelector) { 397 var btn = document.querySelector(buttonSelector); 398 if (!btn) return; 399 400 btn.style.display = 'none'; 401 var threshold = window.innerHeight * 2; 402 403 window.addEventListener('scroll', function () { 404 if (window.scrollY > threshold) { 405 btn.style.display = ''; 406 } else { 407 btn.style.display = 'none'; 408 } 409 }, { passive: true }); 410 } 411 412 function getCarouselElements(carouselSelector) { 413 var carousel = document.querySelector(carouselSelector); 414 if (!(carousel instanceof HTMLElement)) return null; 415 416 var track = carousel.querySelector('.article-carousel-track'); 417 if (!(track instanceof HTMLElement)) return null; 418 419 var slides = Array.from(track.querySelectorAll('.article-carou
419sel-slide')) 420 .filter(function (slide) { return slide instanceof HTMLElement; }); 421 422 if (slides.length === 0) return null; 423 424 return { carousel: carousel, track: track, slides: slides }; 425 } 426 427 function getCarouselClampedIndex(slides, index) { 428 return Math.min(Math.max(index, 0), slides.length - 1); 429 } 430 431 function getClosestCarouselIndex(track, slides) { 432 var currentLeft = track.scrollLeft; 433 var closestIndex = 0; 434 var smallestDelta = Number.POSITIVE_INFINITY; 435 436 slides.forEach(function (slide, index) { 437 var delta = Math.abs(currentLeft - slide.offsetLeft); 438 if (delta < smallestDelta) { 439 smallestDelta = delta; 440 closestIndex = index; 441 } 442 }); 443 444 return closestIndex; 445 } 446 447 function syncCarouselUi(carousel, track, slides) { 448 var idx = getClosestCarouselIndex(track, slides); 449 var counter = carousel.querySelector('.article-carousel-counter'); 450 var gallery = carousel.closest('.article-gallery'); 451 var thumbs = gallery ? gallery.querySelectorAll('.article-gallery-thumb') : []; 452 453 if (counter) { 454 counter.textContent = (idx + 1) + ' / ' + slides.length; 455 } 456 457 var captionEl = gallery ? gallery.querySelector('.article-carousel-caption') : null; 458 if (captionEl && slides[idx]) { 459 var captionText = slides[idx].getAttribute('data-caption') || ''; 460 captionEl.textContent = captionText; 461 captionEl.style.display = captionText ? '' : 'none'; 462 } 463 464 thumbs.forEach(function (thumb, thumbIndex) { 465 thumb.classList.toggle('is-active', thumbIndex === idx); 466 }); 467 } 468 469 function stopCarouselAnimation(track) { 470 var frameId = _carouselAnimationFrames.get(track); 471 if (frameId !== undefined) { 472 cancelAnimationFrame(frameId); 473 _carouselAnimationFrames.delete(track); 474 } 475 476 _carouselAnimatingTracks.delete(track); 477 track.style.scrollSnapType = ''; 478 track.style.scrollBehavior = ''; 479 } 480 481 function setCarouselIndex(carousel, track, slides, index) { 482 stopCarouselAnimation(track); 483 484 var clampedIndex = getCarouselClampedIndex(slides, index); 485 track.style.scrollBehavior = 'auto'; 486 track.scrollLeft = slides[clampedIndex].offsetLeft; 487 track.style.scrollBehavior = ''; 488 489 syncCarouselUi(carousel, track, slides); 490 } 491 492 function animateCarouselToIndex(carousel, track, slides, index) { 493 var clampedIndex = getCarouselClampedIndex(slides, index); 494 var startLeft = track.scrollLeft; 495 var targetLeft = slides[clampedIndex].offsetLeft; 496 497 if (Math.abs(targetLeft - startLeft) < 1) { 498 setCarouselIndex(carousel, track, slides, clampedIndex); 499 return; 500 } 501 502 stopCarouselAnimation(track); 503 504 _carouselAnimatingTracks.add(track); 505 track.style.scrollSnapType = 'none'; 506 track.style.scrollBehavior = 'auto'; 507 508 var startTime = performance.now(); 509 var duration = 260; 510 511 function step(timestamp) { 512 var progress = Math.min((timestamp - startTime) / duration, 1); 513 var eased = 1 - Math.pow(1 - progress, 3); 514 track.scrollLeft = startLeft + ((targetLeft - startLeft) * eased); 515 516 if (progress < 1) { 517 _carouselAnimationFrames.set(track, requestAnimationFrame(step)); 518 return; 519 } 520 521 _carouselAnimationFrames.delete(track); 522 _carouselAnimatingTracks.delete(track); 523 track.scrollLeft = targetLeft; 524 track.style.scrollSnapType = ''; 525 track.style.scrollBehavior = ''; 526 syncCarouselUi(carousel, track, slides); 527 } 528 529 _carouselAnimationFrames.set(track, requestAnimationFrame(step)); 530 } 531 532 // ââ Gallery carousel (scroll-snap) âââââââââââââââââââââââââââââââ 533 function initCarousel(carouselSelector) { 534 var elements = getCarouselElements(carouselSelector); 535 if (!elements) return; 536 537 var carousel = elements.carousel; 538 if (carousel.dataset.articleCarouselInitialized === 'true') return; 539 540 carousel.dataset.articleCarouselInitialized = 'true'; 541 542 var track = elements.track; 543 var slides = elements.slides;
544 var prevBtn = carousel.querySelector('.article-carousel-prev'); 545 var nextBtn = carousel.querySelector('.article-carousel-next'); 546 var slideCount = slides.length; 547 548 function updateUI() { 549 syncCarouselUi(carousel, track, slides); 550 } 551 552 track.addEventListener('scrollend', function () { 553 if (_carouselAnimatingTracks.has(track)) { 554 return; 555 } 556 557 updateUI(); 558 }); 559 560 var scrollTimer; 561 track.addEventListener('scroll', function () { 562 if (_carouselAnimatingTracks.has(track)) { 563 return; 564 } 565 566 clearTimeout(scrollTimer); 567 scrollTimer = setTimeout(updateUI, 100); 568 }, { passive: true }); 569 570 if (prevBtn) { 571 prevBtn.addEventListener('click', function () { 572 var currentIndex = getClosestCarouselIndex(track, slides); 573 if (currentIndex === 0) { 574 setCarouselIndex(carousel, track, slides, slideCount - 1); 575 } else { 576 animateCarouselToIndex(carousel, track, slides, currentIndex - 1); 577 } 578 }); 579 } 580 581 if (nextBtn) { 582 nextBtn.addEventListener('click', function () { 583 var currentIndex = getClosestCarouselIndex(track, slides); 584 if (currentIndex >= slideCount - 1) { 585 setCarouselIndex(carousel, track, slides, 0); 586 } else { 587 animateCarouselToIndex(carousel, track, slides, currentIndex + 1); 588 } 589 }); 590 } 591 592 updateUI(); 593 } 594 595 function carouselGoTo(carouselSelector, index) { 596 var elements = getCarouselElements(carouselSelector); 597 if (!elements) return; 598 599 animateCarouselToIndex(elements.carousel, elements.track, elements.slides, index); 600 } 601 602 return { 603 initEditorialDetail: initEditorialDetail, 604 initArticlePage: initArticlePage, 605 initProgressBar: initProgressBar, 606 destroyProgressBar: destroyProgressBar, 607 copyLink: copyLink, 608 nativeShare: nativeShare, 609 canNativeShare: canNativeShare, 610 openLightbox: openLightbox, 611 closeLightbox: closeLightbox, 612 lightboxPrev: lightboxPrev, 613 lightboxNext: lightboxNext, 614 _updateLightbox: _updateLightbox, 615 initLightboxKeyboard: initLightboxKeyboard, 616 initBackToTop: initBackToTop, 617 initCarousel: initCarousel, 618 initInlineCarousels: initInlineCarousels, 619 carouselGoTo: carouselGoTo 620 }; 621})(); 622 623window.addEventListener('pagehide', function () { 624 window.articleInterop.destroyProgressBar(); 625}); 626 627function initEditorialDetailWhenPresent() { 628 window.articleInterop.initEditorialDetail(); 629} 630 631window.Blazor?.addEventListener?.('enhancedload', initEditorialDetailWhenPresent); 632window.Blazor?.addEventListener?.('enhancednavigationstart', function () { 633 window.articleInterop.destroyProgressBar(); 634}); 635 636document.addEventListener('DOMContentLoaded', function () { 637 initEditorialDetailWhenPresent(); 638 window.articleInterop.initLightboxKeyboard(); 639});
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.