1/** 2 * Infinite Scroll functionality for single post pages. 3 * 4 * @package LocalNewsNow\Core 5 */ 6 7(function($) { 8 'use strict'; 9 10 let isLoading = false; 11 let hasMore = true; 12 /** Post ID reflected in URL bar / analytics (what the reader has reached). */ 13 let currentPostId = null; 14 /** 15 * Chronological anchor for the next load-more request (oldest post in the loaded river). 16 * Must not be overwritten by scroll; only by successful load-more responses. 17 */ 18 let riverAnchorPostId = null; 19 let originalPostId = null; // Track the original post ID (first post on page) 20 let loadedPosts = 1; 21 let lastUrlUpdateTime = 0; 22 let lastAuthorTriggerTime = 0; 23 /** Post ID last sent to analytics (GA4 / Clicky); avoids duplicate pageviews for the same article. */ 24 let lastAnalyticsPostId = null; 25 let originalDocumentTitleSuffix = ''; 26 /** Client-side record of every story ID appended to the river (exclude list for AJAX). */ 27 const loadedRiverPostIds = {}; 28 /** Blocks river load-more and URL churn while landing on a comments deep link. */ 29 let commentsLandingActive = false; 30 let commentsLandingReleaseTimer = null; 31 /** Fallback if scroll-to-comments never fires (slow JS, missing target, etc.). */ 32 const COMMENTS_LANDING_MAX_MS = 5000; 33 /** 34 * After lnn:comments:landed, hold the lock briefly so Disqus async embed/layout-shift 35 * cannot trigger load-more or URL churn before the reader has settled on article #1. 36 */ 37 const COMMENTS_LANDED_DISQUS_GRACE_MS = 3000; 38 39 /** 40 * Whether the reader arrived to open comments (homepage, river Show Comments, etc.). 41 * 42 * @return {boolean} 43 */ 44 function isCommentsDeepLink() { 45 try { 46 if (new URLSearchParams(window.location.search).get('comments') === '1') { 47 return true; 48 } 49 } catch (err) { 50 // ignore 51 } 52 const hash = window.location.hash || ''; 53 return hash === '#disqus_thread' || hash.indexOf('#comment-') === 0; 54 } 55 56 /** 57 * Keep URL/title on the article the reader clicked until comments landing finishes. 58 */ 59 function pinUrlToOriginalPost() { 60 if (!originalPostId) { 61 return; 62 } 63 currentPostId = originalPostId; 64 updateUrlToVisiblePost(originalPostId); 65 } 66 67 /** 68 * End comments-landing protection. Re-pin article #1 instead of recomputing from scroll 69 * position â the reader is usually still at the bottom of that article when the lock lifts. 70 */ 71 function releaseCommentsLanding() { 72 if (!commentsLandingActive) { 73 return; 74 } 75 commentsLandingActive = false; 76 commentsLandingReleaseTimer = null; 77 pinUrlToOriginalPost(); 78 } 79 80 function scheduleCommentsLandingRelease(delayMs) { 81 if (commentsLandingReleaseTimer) { 82 clearTimeout(commentsLandingReleaseTimer); 83 } 84 commentsLandingReleaseTimer = setTimeout(releaseCommentsLanding, delayMs); 85 } 86 87 function beginCommentsLanding() { 88 commentsLandingActive = true; 89 currentPostId = originalPostId; 90 scheduleCommentsLandingRelease(COMMENTS_LANDING_MAX_MS); 91 } 92 93 // Initialize infinite scroll when DOM is ready 94 $(document).ready(function() { 95 if (typeof window.lnnInfiniteScrollConfig === 'undefined') { 96 return; 97 } 98 99 const config = window.lnnInfiniteScrollConfig; 100 101 if (!config.enabled) { 102 return; 103 } 104 105 currentPostId = config.currentPostId; 106 riverAnchorPostId = config.currentPostId; 107 originalPostId = config.currentPostId; // Store the original post ID 108 rememberLoadedRiverPostId(originalPostId); 109 loadedPosts = config.loadedPosts; 110 hasMore = config.hasMore; 111 lastAnalyticsPostId = String(originalPostId); 112 // Prefer suffix from PHP (wp_get_document_title at render). Client-side parsing of 113 // document.title can treat a previous story's headline as part of the "suffix" when 114 // two titles were merged with " - " or when the site uses separators we did not match. 115 originalDocumentTitleSuffix = 116 typeof config.documentTitleSuffix === 'string' && config.documentTitleSuffix !== '' 117 ? config.documentTitleSuffix 118 : getOriginalDocumentTitleSuffix(); 119 120 // Add loading indicator 121 addLoadingIndicator(); 122 123 // Add end of content indicator (but don't show it yet) 124 addEndOfContentIndicator(null); 125 126 // Set up scroll listener 127 $(window).on('scroll', throttle(handleScroll, 200)); 128 129 // Set up scroll-based URL updating 130 $(window).on('scroll', throttle(handleScrollForUrlUpdate, 100)); 131 132 // When returning to a visible tab, sync URL once (no updates while document.hidden). 133 $(document).on('visibilitychange.lnnInfScroll', function() { 134 if (!document.hidden) { 135 handleScrollForUrlUpdate(); 136 } 137 }); 138 139 // Initialize URL cache with current post 140 initializeUrlCache(); 141
142 // Do not touch history on initial page load: the browser already has the correct post URL. 143 // Pushing it again makes Back land on the same article before returning to the referrer. 144 145 // Same-origin navigations inside the river (except Show Comments): replace the current 146 // article entry so Back from the destination leaves the river in one step. 147 setupRiverOutboundLinkReplace(); 148 149 // Comments deep links scroll to the bottom of the first article, which otherwise 150 // triggers load-more and URL updates for the next river post before Disqus renders. 151 if (isCommentsDeepLink()) { 152 beginCommentsLanding(); 153 $(document).on('lnn:comments:landed.lnnInfScroll', function() { 154 pinUrlToOriginalPost(); 155 // Primary release: shortly after comments scroll into view (Disqus grace). 156 // Replaces the longer max fallback from beginCommentsLanding(). 157 scheduleCommentsLandingRelease(COMMENTS_LANDED_DISQUS_GRACE_MS); 158 }); 159 } 160 }); 161 162 /** 163 * Update the address bar during infinite scroll without growing the history stack. 164 * 165 * @param {object} state Arbitrary History state object. 166 * @param {string} title Browser title for this state. 167 * @param {string} url Canonical post URL for the active article. 168 */ 169 function replaceRiverUrl(state, title, url) { 170 if (!window.history || !window.history.replaceState) { 171 return; 172 } 173 window.history.replaceState(state, title, url); 174 } 175 176 /** 177 * Decode titles returned by AJAX before assigning document.title. 178 * 179 * @param {string} value Raw title text, possibly containing HTML entities. 180 * @return {string} 181 */ 182 function decodeHtmlEntities(value) { 183 const textarea = document.createElement('textarea'); 184 textarea.innerHTML = String(value || ''); 185 return textarea.value; 186 } 187 188 /** 189 * Keep the original site suffix so title updates never prepend a new title onto an already-mutated title. 190 * 191 * @return {string} 192 */ 193 function getOriginalDocumentTitleSuffix() { 194 const pageTitle = decodeHtmlEntities(document.title).trim(); 195 196 // Prefer the trailing site brand (⦠| Publisher). If we strip using the H1 prefix first, 197 // a title that is already malformed ("Headline - Headline | Site") keeps the extra headline 198 // in the "suffix" and every virtual title update repeats it. 199 const pipeIndex = pageTitle.lastIndexOf(' | '); 200 if (pipeIndex !== -1) { 201 return pageTitle.slice(pipeIndex); 202 } 203 204 const enDashIndex = pageTitle.lastIndexOf(' â '); 205 if (enDashIndex !== -1) { 206 return pageTitle.slice(enDashIndex); 207 } 208 209 const currentPostTitle = decodeHtmlEntities( 210 $('#post-' + currentPostId).find('h1, h2').first().text() 211 ).trim(); 212 213 if (currentPostTitle && pageTitle.indexOf(currentPostTitle) === 0) { 214 return pageTitle.slice(currentPostTitle.length); 215 } 216 217 const dashIndex = pageTitle.lastIndexOf(' - '); 218 if (dashIndex !== -1) { 219 return pageTitle.slice(dashIndex); 220 } 221 222 return ''; 223 } 224 225 /** 226 * Build a clean document title from one decoded article title plus the original site suffix. 227 * 228 * @param {string} postTitle Article title. 229 * @return {string} 230 */ 231 function buildDocumentTitle(postTitle) { 232 const title = decodeHtmlEntities(postTitle).trim(); 233 if (!title) { 234 return document.title; 235 } 236 return title + originalDocumentTitleSuffix; 237 } 238 239 /** 240 * In-river same-origin navigations replace the river entry ([referrer, dest]). 241 * Show Comments first restores the current history entry to the original page for this 242 * river session, then loads the comments page so Back returns there. 243 */ 244 function setupRiverOutboundLinkReplace() { 245 $(document).on( 246 'click.lnnRiverNav', 247 '.infinite-scroll-container a[href]', 248 function(e) { 249 if (e.defaultPrevented || e.altKey || e.metaKey || e.ctrlKey || e.shiftKey) { 250 return; 251 } 252 253 const a = this; 254 if (a.target && String(a.target).toLowerCase() === '_blank') { 255 return; 256 } 257 if (a.hasAttribute('download')) { 258 return; 259 } 260 261 const hrefAttr = ($(a).attr('href') || '').trim(); 262 if (!hrefAttr || hrefAttr === '#') { 263 return; 264 } 265 if (/^(javascript:|mailto:|tel:|sms:)/i.test(hrefAttr)) { 266 return; 267 } 268 if ($(a).closest('.river-comments-button').length || $(a).hasClass('river-comments-button')) { 269 e.preventDefault(); 270 replaceCurrentEntryWithOriginalPost(); 271 window.location.href = a.href; 272 return; 273 } 274 275 let targetUrl; 276 try { 277 targetUrl = new URL(a.href); 278 } catch (err) { 279 return; 280 } 281 282 if (targetUrl.origin !== window.location.origin) { 283 return; 284 } 285 if (targetUrl.pathname.indexOf('/wp-admin') === 0 || targetUrl.pathname.indexOf('/wp-login.php') === 0) { 286 return; 287 } 288 289 if ( 290 targetUrl.pathname === window.location.pathname && 291 targetUrl.search === window.location.search && 292 targetUrl.hash !== window.location.hash 293 ) { 294 return; 295 } 296 297 e.preventDefault(); 298 window.location.replace(targetUrl.href); 299 } 300 ); 301 } 302 303 /** 304 * Before opening a river article's comments, make the Back destination the article page 305 * that owns this river session (for example Post B), not the virtual URL of the comments 306 * article (for example Post C). 307 */ 308 function replaceCurrentEntryWithOriginalPost() { 309 if (!originalPostId || !window.lnnInfiniteScrollPostUrls) { 310 return; 311 } 312 313 const originalPostData = window.lnnInfiniteScrollPostUrls[originalPostId]; 314 if (!originalPostData || !originalPostData.url) { 315 return; 316 } 317 318 const cleanTitle = buildDocumentTitle(originalPostData.title); 319 replaceRiverUrl( 320 { postId: originalPostId }, 321 cleanTitle, 322 originalPostData.url 323 );
324 document.title = cleanTitle; 325 } 326 327 /** 328 * Handle scroll event for infinite scroll. 329 */ 330 function handleScroll() { 331 if (commentsLandingActive || isLoading || !hasMore) { 332 return; 333 } 334 335 // Check if author section is in view 336 const authorSection = $('.pp-multiple-authors-boxes-wrapper, .author-section, [class*="author"]').last(); 337 if (authorSection.length) { 338 const authorTop = authorSection.offset().top; 339 const authorHeight = authorSection.outerHeight(); 340 const scrollTop = $(window).scrollTop(); 341 const windowHeight = $(window).height(); 342 343 // Trigger when author section is 300px from viewport top 344 if (authorTop - scrollTop < windowHeight - 300) { 345 const now = Date.now(); 346 // Prevent multiple triggers within 2 seconds 347 if (now - lastAuthorTriggerTime > 2000) { 348 console.log('Infinite scroll: Author section in view, loading next post'); 349 lastAuthorTriggerTime = now; 350 loadMorePosts(); 351 return; 352 } 353 } 354 } 355 356 // Fallback: trigger when user is 200px from bottom 357 const scrollTop = $(window).scrollTop(); 358 const windowHeight = $(window).height(); 359 const documentHeight = $(document).height(); 360 361 if (scrollTop + windowHeight >= documentHeight - 200) { 362 loadMorePosts(); 363 } 364 } 365 366 /** 367 * Handle scroll event for URL updates based on visible post. 368 */ 369 function handleScrollForUrlUpdate() { 370 if (commentsLandingActive || document.hidden) { 371 return; 372 } 373 const now = Date.now(); 374 // Debounce URL updates to prevent too frequent changes 375 if (now - lastUrlUpdateTime < 500) { 376 return; 377 } 378 379 const visiblePostId = getCurrentlyVisiblePostId(); 380 if (visiblePostId && String(visiblePostId) !== String(currentPostId)) { 381 currentPostId = visiblePostId; 382 lastUrlUpdateTime = now; 383 updateUrlToVisiblePost(visiblePostId); 384 } 385 } 386 387 /** 388 * Track post IDs already in the river so AJAX excludes stay complete even if the DOM scan misses one. 389 * 390 * @param {number|string} postId Post ID. 391 */ 392 function rememberLoadedRiverPostId(postId) { 393 const n = parseInt(postId, 10); 394 if (!isNaN(n) && n > 0) { 395 loadedRiverPostIds[n] = true; 396 } 397 } 398 399 /** 400 * @param {Array<number|string>} postIds Post IDs. 401 */ 402 function rememberLoadedRiverPostIds(postIds) { 403 if (!Array.isArray(postIds)) { 404 return; 405 } 406 postIds.forEach(function(postId) { 407 rememberLoadedRiverPostId(postId); 408 }); 409 } 410 411 /** 412 * Post IDs currently in the river (lead + AJAX-appended articles) for excluding from the next batch. 413 * 414 * @return {number[]} 415 */ 416 function getLoadedRiverPostIdsForAjax() { 417 const seen = Object.assign({}, loadedRiverPostIds); 418 419 $('.infinite-scroll-container article[id^="post-"], .site-main article[id^="post-"]').each(function() { 420 const raw = ($(this).attr('id') || '').replace(/^post-/, ''); 421 const n = parseInt(raw, 10); 422 if (!isNaN(n) && n > 0) { 423 seen[n] = true; 424 } 425 }); 426 427 if (window.lnnInfiniteScrollPostUrls && typeof window.lnnInfiniteScrollPostUrls === 'object') { 428 Object.keys(window.lnnInfiniteScrollPostUrls).forEach(function(id) { 429 const n = parseInt(id, 10); 430 if (!isNaN(n) && n > 0) { 431 seen[n] = true; 432 } 433 }); 434 } 435 436 const origInt = parseInt(originalPostId, 10); 437 if (!isNaN(origInt) && origInt > 0) { 438 seen[origInt] = true; 439 } 440 441 const out = []; 442 Object.keys(seen).forEach(function(k) { 443 out.push(parseInt(k, 10)); 444 }); 445 446 return out; 447 } 448 449 /** 450 * Load more posts via AJAX. 451 */ 452 function loadMorePosts() { 453 if (isLoading || !hasMore) { 454 return; 455 } 456 457 isLoading = true; 458 showLoadingIndicator(); 459 460 const config = window.lnnInfiniteScrollConfig; 461 const loadedPostIds = getLoadedRiverPostIdsForAjax(); 462 463 const data = { 464 action: 'load_more_posts', 465 nonce: lnnInfiniteScroll.nonce, 466 current_post_id: riverAnchorPostId, 467 loaded_posts: loadedPosts, 468 post_limit: config.postLimit, 469 original_post_id: originalPostId, 470 loaded_post_ids: loadedPostIds 471 }; 472 473 $.ajax({ 474 url: lnnInfiniteScroll.ajaxUrl, 475 type: 'POST', 476 data: data, 477 success: function(response) { 478 if (response.success) { 479 handleLoadMoreSuccess(response.data); 480 } else { 481 handleLoadMoreError(response.data); 482 } 483 }, 484 error: function(xhr, status, error) { 485 handleLoadMoreError('AJAX Error: ' + error); 486 }, 487 complete: function() { 488 isLoading = false;
489 hideLoadingIndicator(); 490 } 491 }); 492 } 493 494 /** 495 * Handle successful load more response. 496 */ 497 function handleLoadMoreSuccess(data) { 498 const incomingIds = Object.keys(data.postUrls || {}).map(function(id) { 499 return parseInt(id, 10); 500 }).filter(function(n) { 501 return !isNaN(n) && n > 0; 502 }); 503 504 const hasNewContent = Boolean(data.html && data.html.length) && incomingIds.some(function(id) { 505 return !loadedRiverPostIds[id]; 506 }); 507 508 if (!hasNewContent) { 509 loadedPosts = data.loadedPosts; 510 hasMore = false; 511 riverAnchorPostId = data.nextPostId; 512 hideLoadingIndicator(); 513 showEndOfContentIndicator(data.nextPostUrl); 514 return; 515 } 516 517 // Append new posts to the content 518 $('.site-main').append(data.html); 519 rememberLoadedRiverPostIds(incomingIds); 520 521 // Update counters 522 loadedPosts = data.loadedPosts; 523 hasMore = data.hasMore; 524 riverAnchorPostId = data.nextPostId; 525 526 // Cache post URLs for scroll-based URL updates (do not change URL/analytics here â user may 527 // still be reading higher on the page; idle tabs were also jumping to the batch tail). 528 if (data.postUrls) { 529 if (!window.lnnInfiniteScrollPostUrls) { 530 window.lnnInfiniteScrollPostUrls = {}; 531 } 532 Object.assign(window.lnnInfiniteScrollPostUrls, data.postUrls); 533 } 534 535 // Hide loading indicator 536 hideLoadingIndicator(); 537 538 // Initialize any new galleries inside newly appended content 539 try { 540 initNewGalleries(); 541 } catch (e) { 542 console.warn('Infinite scroll: gallery init failed', e); 543 } 544 545 // Show end message if no more posts 546 if (!hasMore) { 547 console.log('Infinite scroll: No more posts, showing end message'); 548 showEndOfContentIndicator(data.nextPostUrl); 549 } else { 550 console.log('Infinite scroll: More posts available, loadedPosts:', loadedPosts, 'hasMore:', hasMore); 551 } 552 553 // Trigger custom event for other scripts 554 $(document).trigger('lnn:infiniteScroll:loaded', { 555 loadedPosts: loadedPosts, 556 hasMore: hasMore, 557 currentPostId: currentPostId, 558 riverAnchorPostId: riverAnchorPostId 559 }); 560 } 561 562 /** 563 * Handle load more error. 564 */ 565 function handleLoadMoreError(error) { 566 console.error('Infinite scroll error:', error); 567 hasMore = false; 568 showEndOfContentIndicator(null); 569 } 570 571 /** 572 * Initialize URL cache with current post data. 573 */ 574 function initializeUrlCache() { 575 if (!window.lnnInfiniteScrollPostUrls) { 576 window.lnnInfiniteScrollPostUrls = {}; 577 } 578 579 // Add current post to cache 580 const currentPostElement = $('#post-' + currentPostId); 581 if (currentPostElement.length) { 582 const currentPostTitle = decodeHtmlEntities(currentPostElement.find('h1, h2').first().text()).trim(); 583 window.lnnInfiniteScrollPostUrls[currentPostId] = { 584 url: window.location.href, 585 title: currentPostTitle || decodeHtmlEntities(document.title).replace(originalDocumentTitleSuffix, '').trim() 586 }; 587 } 588 } 589 590 /** 591 * Vertical position used to decide "has the reader reached this story?" â published dateline 592 * (`header time[datetime]`) when present, else start of body, else article top. 593 * 594 * @param {JQuery} $post Article element. 595 * @return {number} 596 */ 597 function getReadingMarkerTop($post) { 598 const $time = $post.find('header time[datetime]').first(); 599 if ($time.length) { 600 return $time.offset().top; 601 } 602 const $entry = $post.find('.entry-content').first(); 603 if ($entry.length) { 604 return $entry.offset().top; 605 } 606 return $post.offset().top; 607 } 608 609 /** 610 * Legacy: pick article by viewport center + visibility (headline-heavy). 611 */ 612 function getCurrentlyVisiblePostIdLegacy() { 613 const scrollTop = $(window).scrollTop(); 614 const windowHeight = $(window).height(); 615 const viewportCenter = scrollTop + (windowHeight / 2); 616 617 let mostVisiblePost = null; 618 let bestVisibilityScore = 0; 619 620 $('.infinite-scroll-container article[id^="post-"]').each(function() { 621 const $post = $(this); 622 const postId = $post.attr('id').replace('post-', ''); 623 const postTop = $post.offset().top; 624 const postHeight = $post.outerHeight(); 625 const postBottom = postTop + postHeight; 626 627 const visibleTop = Math.max(postTop, scrollTop); 628 const visibleBottom = Math.min(postBottom, scrollTop + windowHeight); 629 const visibleHeight = Math.max(0, visibleBottom - visibleTop); 630 const visibilityRatio = visibleHeight / Math.min(postHeight, windowHeight); 631 632 const postCenter = postTop + (postHeight / 2); 633 const distanceFromCenter = Math.abs(postCenter - viewportCenter); 634 const centerScore = Math.max(0, 1 - (distanceFromCenter / windowHeight)); 635
636 const mostlyVisibleBonus = visibilityRatio > 0.5 ? 0.2 : 0; 637 638 const totalScore = (visibilityRatio * 0.6) + (centerScore * 0.3) + mostlyVisibleBonus; 639 640 if (totalScore > bestVisibilityScore) { 641 bestVisibilityScore = totalScore; 642 mostVisiblePost = postId; 643 } 644 }); 645 646 return mostVisiblePost; 647 } 648 649 /** 650 * Prefer dateline / reading marker in the upper viewport band (conservative for analytics + URL). 651 */ 652 function getCurrentlyVisiblePostIdByDateline() { 653 const scrollTop = $(window).scrollTop(); 654 const vh = $(window).height(); 655 const bandTop = scrollTop; 656 const bandBottom = scrollTop + vh * 0.42; 657 658 let best = null; 659 let bestDist = Infinity; 660 661 $('.infinite-scroll-container article[id^="post-"]').each(function() { 662 const $post = $(this); 663 const postId = $post.attr('id').replace('post-', ''); 664 const markTop = getReadingMarkerTop($post); 665 const $time = $post.find('header time[datetime]').first(); 666 const markH = Math.max($time.outerHeight() || 24, 20); 667 if (markTop > bandBottom) { 668 return; 669 } 670 if (markTop + markH < bandTop) { 671 return; 672 } 673 674 const target = scrollTop + vh * 0.22; 675 const dist = Math.abs(markTop + markH / 2 - target); 676 if (dist < bestDist) { 677 bestDist = dist; 678 best = postId; 679 } 680 }); 681 682 return best; 683 } 684 685 /** 686 * Get the active post for URL + analytics: dateline-first, legacy fallback near top of page. 687 */ 688 function getCurrentlyVisiblePostId() { 689 if (document.hidden) { 690 return null; 691 } 692 const byDateline = getCurrentlyVisiblePostIdByDateline(); 693 if (byDateline) { 694 return byDateline; 695 } 696 const scrollTop = $(window).scrollTop(); 697 if (scrollTop < 120) { 698 return String(originalPostId); 699 } 700 return getCurrentlyVisiblePostIdLegacy(); 701 } 702 703 /** 704 * Update URL to the currently visible post. 705 */ 706 function updateUrlToVisiblePost(postId) { 707 // Check if we already have the URL cached from previous load 708 if (window.lnnInfiniteScrollPostUrls && window.lnnInfiniteScrollPostUrls[postId]) { 709 const postData = window.lnnInfiniteScrollPostUrls[postId]; 710 updateUrlFromData(postData, postId); 711 return; 712 } 713 714 // Otherwise, fetch the URL via AJAX 715 updateUrl(postId); 716 } 717 718 /** 719 * Fire virtual pageviews when the active article changes (GA4, Clicky). 720 * Skips the first article: the initial HTML load is already counted by sitewide tags. 721 * 722 * @param {string|number} postId 723 * @param {{ url: string, title?: string|null }} postData Plain post headline when set; omit or leave empty to use document.title. 724 */ 725 function trackVirtualPageview(postId, postData) { 726 if (!postData || !postData.url) { 727 return; 728 } 729 const id = String(postId); 730 if (id === lastAnalyticsPostId) { 731 return; 732 } 733 lastAnalyticsPostId = id; 734 735 // Match document.title: only build from a plain post headline (cache / AJAX). Do not pass 736 // document.title through buildDocumentTitle or the site suffix is applied twice. 737 const rawTitle = postData.title; 738 const title = 739 rawTitle != null && String(rawTitle).trim() !== '' 740 ? buildDocumentTitle(rawTitle) 741 : document.title; 742 const pageLocation = postData.url; 743 744 if (typeof window.gtag === 'function') { 745 window.gtag('event', 'page_view', { 746 page_title: title, 747 page_location: pageLocation 748 }); 749 } 750 751 if (typeof window.clicky !== 'undefined' && typeof window.clicky.log === 'function') { 752 window.clicky.log(pageLocation, title, 'pageview'); 753 } 754 755 $(document).trigger('lnn:infiniteScroll:virtualPageview', { 756 postId: id, 757 title: title, 758 url: pageLocation 759 }); 760 } 761 762 /** 763 * Update browser URL using data from AJAX response. 764 */ 765 function updateUrlFromData(postData, postId) { 766 if (!postData || !postData.url || !window.history || !window.history.replaceState) { 767 return; 768 } 769 const cleanTitle = buildDocumentTitle(postData.title); 770 771 replaceRiverUrl( 772 { postId: postId }, 773 cleanTitle, 774 postData.url 775 ); 776 777 // Update page title
778 document.title = cleanTitle; 779 780 trackVirtualPageview(postId, { 781 url: postData.url, 782 title: decodeHtmlEntities(postData.title) 783 }); 784 } 785 786 /** 787 * Update browser URL without page reload. 788 */ 789 function updateUrl(postId) { 790 if (!postId) { 791 return; 792 } 793 794 // Fetch the full post URL via AJAX 795 $.ajax({ 796 url: lnnInfiniteScroll.ajaxUrl, 797 type: 'POST', 798 data: { 799 action: 'get_post_url', 800 nonce: lnnInfiniteScroll.nonce, 801 post_id: postId 802 }, 803 success: function(response) { 804 if (response.success && response.data.url && window.history && window.history.replaceState) { 805 const cleanTitle = buildDocumentTitle(response.data.title); 806 807 replaceRiverUrl( 808 { postId: postId }, 809 cleanTitle, 810 response.data.url 811 ); 812 813 // Update page title 814 document.title = cleanTitle; 815 816 trackVirtualPageview(postId, { 817 url: response.data.url, 818 title: decodeHtmlEntities(response.data.title) 819 }); 820 } 821 }, 822 error: function() { 823 // Fallback: construct URL from current structure 824 const currentUrl = window.location.href; 825 const baseUrl = currentUrl.split('/').slice(0, -1).join('/'); 826 const fallbackUrl = baseUrl + '/' + postId + '/'; 827 828 if (window.history && window.history.replaceState) { 829 replaceRiverUrl( 830 { postId: postId }, 831 document.title, 832 fallbackUrl 833 ); 834 trackVirtualPageview(postId, { url: fallbackUrl }); 835 } 836 } 837 }); 838 } 839 840 841 /** 842 * Add loading indicator to the page. 843 */ 844 function addLoadingIndicator() { 845 const config = window.lnnInfiniteScrollConfig; 846 const loadingHtml = ` 847 <div id="infinite-scroll-loading" class="infinite-scroll-loading" style="display: none;"> 848 <div class="text-center py-8"> 849 <div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div> 850 <p class="mt-2 text-gray-600">${config.loadingText}</p> 851 </div> 852 </div> 853 `; 854 $('.site-main').append(loadingHtml); 855 } 856 857 /** 858 * Show loading indicator. 859 */ 860 function showLoadingIndicator() { 861 $('#infinite-scroll-loading').show(); 862 } 863 864 /** 865 * Hide loading indicator. 866 */ 867 function hideLoadingIndicator() { 868 $('#infinite-scroll-loading').hide(); 869 } 870 871 /** 872 * Initialize newly added galleries (slick sliders) within the document. 873 * Mirrors theme's gallery.js behavior while avoiding double-initialization. 874 */ 875 function initNewGalleries() { 876 // Require slick if present (already loaded by theme bundle) 877 if (typeof $.fn.slick !== 'function') { 878 return; 879 } 880 881 const $newGalleries = $('.js-gallery').filter(function() { 882 // Mark initialized galleries to avoid re-binding 883 return !$(this).data('lnn-gallery-initialized'); 884 }); 885 886 $newGalleries.each(function() { 887 const $gallery = $(this); 888 const $slider = $gallery.find('.gallery__slider'); 889 const $footer = $gallery.find('.gallery__footer'); 890 const $footerMeta = $footer.find('.gallery__meta'); 891 const $nav = $gallery.find('.gallery__nav'); 892 const $next = $nav.find('.nav__next'); 893 const $prev = $nav.find('.nav__prev'); 894 const $count = $nav.find('.nav__count'); 895 896 if ($slider.hasClass('slick-initialized')) { 897 return; // already initialized 898 } 899 900 $slider.slick({ 901 infinite: true, 902 rows: 0, 903 slidestoshow: 1, 904 slidestoscroll: 1,
905 arrows: false, 906 draggable: true, 907 adaptiveHeight: true, 908 lazyLoad: 'progressive', 909 adapativeHeight: true, 910 }); 911 912 // Initial meta/count 913 const initialCaption = $slider.find('.slick-current .gallery__meta').html(); 914 if ($footerMeta.length) { 915 $footerMeta.html(initialCaption); 916 } 917 918 $next.on('click', function() { $slider.slick('slickNext'); }); 919 $prev.on('click', function() { $slider.slick('slickPrev'); }); 920 $slider.on('click', function() { $slider.slick('slickNext'); }); 921 922 $slider.on('afterChange', function(event, slick, currentSlide) { 923 const slideCaption = $(slick.$slides[currentSlide]).find('.gallery__meta').html(); 924 if ($footerMeta.length) { 925 $footerMeta.html(slideCaption); 926 } 927 if ($count.length) { 928 $count.text((currentSlide + 1) + '/' + slick.slideCount); 929 } 930 }); 931 932 // Aspect ratio helper (match theme) 933 $gallery.find('.gallery__img img').each(function() { 934 const img = this; 935 const width = parseInt(img.getAttribute('width') || img.naturalWidth || 0, 10); 936 const height = parseInt(img.getAttribute('height') || img.naturalHeight || 0, 10); 937 if (!width || !height) { return; } 938 function gcd(a,b){ return b === 0 ? a : gcd(b, a % b); } 939 const g = gcd(width, height); 940 const ratio = (width/g) + ':' + (height/g); 941 if (['3:2','16:9','4:3'].indexOf(ratio) === -1) { 942 img.classList.add('aspect-ratio-custom'); 943 } 944 }); 945 946 // Mark as initialized 947 $gallery.data('lnn-gallery-initialized', true); 948 }); 949 } 950 951 /** 952 * Add end of content indicator. 953 * 954 * @param {string|null} nextPostUrl URL of the next post, or null if no next post. 955 */ 956 function addEndOfContentIndicator(nextPostUrl) { 957 const config = window.lnnInfiniteScrollConfig; 958 let endContent = ''; 959 960 if (nextPostUrl) { 961 // Show button to view next post 962 const escapedUrl = $('<div>').text(nextPostUrl).html(); // Escape URL to prevent XSS 963 endContent = ` 964 <div class="text-center py-8"> 965 <a href="${escapedUrl}" class="inline-flex items-center justify-center gap-2 px-8 py-4 bg-midnight text-white rounded-md hover:bg-opacity-90 transition-all duration-150 font-sans text-lg font-semibold no-underline"> 966 <span style="color: #000">View Next Post</span> 967 <span class="inline-block size-6" style="transform: rotate(-90deg);"><svg class="text-midnight w-full h-full" width="24px" height="24px" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" color="#000000"><path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></svg> 968 </span> 969 </a> 970 </div> 971 `; 972 } else { 973 // Show default end message 974 endContent = ` 975 <div class="text-center py-8"> 976 <p class="text-gray-600">${config.endText}</p> 977 </div> 978 `; 979 } 980 981 const endHtml = ` 982 <div id="infinite-scroll-end" class="infinite-scroll-end" style="display: none;"> 983 ${endContent} 984 </div> 985 `; 986 // Always append to the container so it's after all post and sidebar content 987 const container = $('.infinite-scroll-container'); 988 if (container.length) { 989 container.append(endHtml); 990 } else { 991 // Fallback to appending to site-main if container isn't found 992 $('.site-main').append(endHtml); 993 } 994 } 995 996 /** 997 * Show end of content indicator. 998 * 999 * @param {string|null} nextPostUrl URL of the next post, or null if no next post. 1000 */ 1001 function showEndOfContentIndicator(nextPostUrl) { 1002 // Remove any existing end messages first 1003 $('#infinite-scroll-end').remove(); 1004 1005 // Add the end message after the last post 1006 addEndOfContentIndicator(nextPostUrl); 1007 1008 // Show the end message 1009 $('#infinite-scroll-end').show(); 1010 } 1011 1012 /** 1013 * Throttle function to limit scroll event frequency. 1014 */ 1015 function throttle(func, wait) { 1016 let timeout;
1017 return function executedFunction(...args) { 1018 const later = () => { 1019 clearTimeout(timeout); 1020 func(...args); 1021 }; 1022 clearTimeout(timeout); 1023 timeout = setTimeout(later, wait); 1024 }; 1025 } 1026 1027 // Handle browser back/forward buttons 1028 window.addEventListener('popstate', function(event) { 1029 if (!event.state || !event.state.postId) { 1030 return; 1031 } 1032 const pid = event.state.postId; 1033 currentPostId = pid; 1034 1035 const cached = window.lnnInfiniteScrollPostUrls && window.lnnInfiniteScrollPostUrls[pid]; 1036 const postData = cached ? cached : { url: window.location.href }; 1037 trackVirtualPageview(pid, postData); 1038 1039 if (document.hidden) { 1040 return; 1041 } 1042 const postElement = $('#post-' + pid); 1043 if (postElement.length) { 1044 $('html, body').animate({ 1045 scrollTop: postElement.offset().top - 100 1046 }, 500); 1047 } 1048 }); 1049 1050})(jQuery);
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.