1document.addEventListener("DOMContentLoaded", function () { 2 "use strict"; 3 4 // The stylesheet's desktop split, so the phone-only ads cannot drift from it. 5 function isDesktopLayout() { 6 return window.matchMedia( 7 "(min-width: 900px), (min-width: 600px) and (orientation: landscape)", 8 ).matches; 9 } 10 11 // --- Dark mode --- 12 var root = document.documentElement; 13 var toggles = document.querySelectorAll( 14 "#mmoc-theme-toggle, #mmoc-theme-toggle-mobile", 15 ); 16 17 // Dark is the default, so the attribute is only ever present to mark light â 18 // its absence means dark, not the other way round. 19 function currentTheme() { 20 return root.getAttribute("data-theme") === "light" ? "light" : "dark"; 21 } 22 23 function applyToggleLabels() { 24 var isDark = currentTheme() === "dark"; 25 toggles.forEach(function (btn) { 26 btn.setAttribute("aria-pressed", isDark ? "true" : "false"); 27 var label = btn.querySelector(".mmoc-theme-toggle__label"); 28 if (label) { 29 label.textContent = isDark ? "Light mode" : "Dark mode"; 30 } 31 }); 32 } 33 34 function setTheme(theme) { 35 if (theme === "light") { 36 root.setAttribute("data-theme", "light"); 37 } else { 38 root.removeAttribute("data-theme"); 39 } 40 try { 41 localStorage.setItem("mmoc-theme", theme); 42 } catch (e) {} 43 applyToggleLabels(); 44 } 45 46 toggles.forEach(function (btn) { 47 btn.addEventListener("click", function () { 48 setTheme(currentTheme() === "dark" ? "light" : "dark"); 49 }); 50 }); 51 52 applyToggleLabels(); 53 54 // --- Scrollbar, shown only while scrolling --- 55 // CSS keeps the thumb transparent; this marks the window of time it is 56 // visible. Idle-based rather than scrollend, which Safari does not fire. 57 var scrollingTimer = null; 58 59 window.addEventListener( 60 "scroll", 61 function () { 62 root.setAttribute("data-scrolling", ""); 63 if (scrollingTimer) { 64 clearTimeout(scrollingTimer); 65 } 66 scrollingTimer = setTimeout(function () { 67 root.removeAttribute("data-scrolling"); 68 scrollingTimer = null; 69 }, 900); 70 }, 71 { passive: true }, 72 ); 73 74 // Same reveal for the panes that scroll inside the page; their bars are 75 // zero-width until this marks them, so nothing reflows when one appears. 76 var markWhileScrolling = function (el) { 77 if (!el) { 78 return; 79 } 80 81 var timer = null; 82 83 el.addEventListener( 84 "scroll", 85 function () { 86 el.setAttribute("data-scrolling", ""); 87 if (timer) { 88 clearTimeout(timer); 89 } 90 timer = setTimeout(function () { 91 el.removeAttribute("data-scrolling"); 92 timer = null; 93 }, 900); 94 }, 95 { passive: true }, 96 ); 97 }; 98 99 markWhileScrolling(document.querySelector(".mmoc-sidebar")); 100 markWhileScrolling(document.querySelector(".mmoc-coderail__list")); 101 102 // --- Consent recall --- 103 // The CMP exposes a U.S. privacy UI only when a U.S. GPP section applies; 104 // every other visitor reopens the TCF consent UI. All calls are guarded so 105 // the footer action stays inert if the third-party CMP failed to load. 106 var reopenConsent = function () { 107 var openTcfUi = function () { 108 if (typeof window.__tcfapi === "function") { 109 window.__tcfapi("displayConsentUi", 2, function () {}); 110 } 111 }; 112 113 if (typeof window.__gpp !== "function") { 114 openTcfUi(); 115 return; 116 } 117 118 try { 119 window.__gpp("ping", function (pingData, success) { 120 var sections = 121 pingData && Array.isArray(pingData.applicableSections) 122 ? pingData.applicableSections 123 : []; 124 var hasUsSection = 125 success && 126 sections.some(function (section) { 127 return section >= 6 && section <= 12; 128 }); 129 130 if (hasUsSection && typeof window.__uspapi === "function") { 131 window.__uspapi("displayUspUi", 1, function () {}); 132 return; 133 } 134 135 openTcfUi(); 136 }); 137 } catch (e) { 138 openTcfUi(); 139 } 140 }; 141 142 document 143 .querySelectorAll("[data-mmoc-consent-reopen]") 144 .forEach(function (button) { 145 button.addEventListener("click", reopenConsent); 146 }); 147 148 // --- Bottom sheet (More nav) --- 149 // Kept as a registry of one: the machinery is generic, and whatever sheet 150 // comes next inherits the same chrome, exits, and one-at-a-time rule. 151 var sheets = []; 152 153 // Assigned further down, once the header search exists. Declared here because 154 // a sheet opening has to be able to close it, and it has to be able to close 155 // the sheets â without this the two could both be open at once, which reads 156 // as two menus over one bar. 157 var closeHeaderSearch = function () {}; 158 159 var closeSheets = function (except) {
160 sheets.forEach(function (sheet) { 161 if (sheet !== except) { 162 sheet.setOpen(false); 163 } 164 }); 165 }; 166 167 // A collapsed field is hidden â with visibility on a sheet, display on the 168 // header's inline bar â and focus() called before the browser 169 // has laid it out tests focusability against the hidden state and is 170 // silently dropped. How many frames that takes is not fixed â the first open 171 // after a page load needs more than a later one â so rather than guess a 172 // number, try, check whether it took, and retry for a few frames. Bounded 173 // well past the sheet's 240ms slide. 174 var focusWhenReady = function (el, tries) { 175 el.focus(); 176 177 if (document.activeElement !== el && tries > 0) { 178 window.requestAnimationFrame(function () { 179 focusWhenReady(el, tries - 1); 180 }); 181 } 182 }; 183 184 var setupSheet = function (toggleId, panelId, closeId, focusSelector) { 185 var toggle = document.getElementById(toggleId); 186 var panel = document.getElementById(panelId); 187 188 if (!toggle || !panel) { 189 return; 190 } 191 192 var sheet = { 193 panel: panel, 194 toggle: toggle, 195 isOpen: function () { 196 return panel.classList.contains("is-open"); 197 }, 198 setOpen: function (open) { 199 panel.classList.toggle("is-open", open); 200 toggle.setAttribute("aria-expanded", open ? "true" : "false"); 201 202 var target = 203 open && focusSelector ? panel.querySelector(focusSelector) : null; 204 205 if (target) { 206 focusWhenReady(target, 20); 207 } 208 }, 209 }; 210 211 toggle.addEventListener("click", function (event) { 212 // Without this the document listener below sees the same click and 213 // closes the sheet the button just opened. 214 event.stopPropagation(); 215 var open = !sheet.isOpen(); 216 closeSheets(sheet); 217 218 if (open) { 219 closeHeaderSearch(); 220 } 221 222 sheet.setOpen(open); 223 }); 224 225 var close = closeId ? document.getElementById(closeId) : null; 226 227 if (close) { 228 close.addEventListener("click", function () { 229 sheet.setOpen(false); 230 // Focus would otherwise land on a button that just left the 231 // screen, leaving the next Tab starting from nowhere. 232 toggle.focus(); 233 }); 234 } 235 236 sheets.push(sheet); 237 }; 238 239 // No focus target for More: focusing a link would give a tap nothing and 240 // risks painting a focus ring on touch, and the sheet follows the nav in the 241 // DOM so Tab reaches it from the trigger anyway. 242 setupSheet("mmoc-more-toggle", "mmoc-more-sheet", "mmoc-more-close", null); 243 244 // Same reasoning as More: no focus target, since a tap has nowhere useful 245 // to send focus and would only risk a visible ring. One call per copy of 246 // the switcher â masthead and mobile header each render their own toggle 247 // and panel, with ids namespaced by mmoculture_modern_render_language_links(). 248 setupSheet( 249 "mmoc-masthead__lang-toggle", 250 "mmoc-masthead__lang-menu", 251 null, 252 null 253 ); 254 setupSheet( 255 "mmoc-mobile-header__lang-toggle", 256 "mmoc-mobile-header__lang-menu", 257 null, 258 null 259 ); 260 261 // --- Mobile header search, expanded in place --- 262 // The same control opens and closes it: aria-expanded drives both the icon 263 // swap and the header's layout, so this only has to move the flag. 264 var headerSearchToggle = document.getElementById("mmoc-mobile-search-toggle"); 265 var mobileHeader = document.querySelector(".mmoc-mobile-header"); 266 267 if (headerSearchToggle && mobileHeader) { 268 var headerSearchField = document.getElementById("mmoc-mobile-search-field"); 269 270 // returnFocus only when the reader closed the bar themselves, via the
271 // control or Escape: focus would otherwise follow the collapsing field 272 // back to a button they did not touch â a tap on More would end up 273 // focusing the search control it just replaced. 274 var setHeaderSearchOpen = function (open, returnFocus) { 275 mobileHeader.classList.toggle("is-searching", open); 276 headerSearchToggle.setAttribute("aria-expanded", open ? "true" : "false"); 277 278 if (open) { 279 if (headerSearchField) { 280 focusWhenReady(headerSearchField, 20); 281 } 282 } else if (returnFocus) { 283 headerSearchToggle.focus(); 284 } 285 }; 286 287 closeHeaderSearch = function () { 288 if (mobileHeader.classList.contains("is-searching")) { 289 setHeaderSearchOpen(false, false); 290 } 291 }; 292 293 headerSearchToggle.addEventListener("click", function () { 294 var open = !mobileHeader.classList.contains("is-searching"); 295 // The bar and the More sheet both belong to the same chrome; leaving 296 // the sheet up under an open search field reads as two menus at once. 297 closeSheets(null); 298 setHeaderSearchOpen(open, true); 299 }); 300 301 document.addEventListener("keydown", function (event) { 302 if ( 303 "Escape" === event.key && 304 mobileHeader.classList.contains("is-searching") 305 ) { 306 setHeaderSearchOpen(false, true); 307 } 308 }); 309 } 310 311 if (sheets.length) { 312 // A sheet covers the page it was opened over, so give it the two exits a 313 // sheet is expected to have instead of only its own trigger again. 314 document.addEventListener("click", function (event) { 315 sheets.forEach(function (sheet) { 316 if (sheet.isOpen() && !sheet.panel.contains(event.target)) { 317 sheet.setOpen(false); 318 } 319 }); 320 }); 321 322 document.addEventListener("keydown", function (event) { 323 if ("Escape" !== event.key) { 324 return; 325 } 326 sheets.forEach(function (sheet) { 327 if (sheet.isOpen()) { 328 sheet.setOpen(false); 329 sheet.toggle.focus(); 330 } 331 }); 332 }); 333 } 334 335 // --- Sticky anchor ad dismiss (session-persisted) --- 336 var anchorAd = document.getElementById("mmoc-anchor-ad"); 337 var anchorDismiss = document.getElementById("mmoc-anchor-dismiss"); 338 339 if (anchorAd) { 340 var dismissed = false; 341 try { 342 dismissed = sessionStorage.getItem("mmoc-anchor-dismissed") === "1"; 343 } catch (e) {} 344 345 if (!dismissed && !isDesktopLayout()) { 346 anchorAd.classList.add("is-visible"); 347 } 348 349 if (anchorDismiss) { 350 anchorDismiss.addEventListener("click", function () { 351 anchorAd.classList.remove("is-visible"); 352 try { 353 sessionStorage.setItem("mmoc-anchor-dismissed", "1"); 354 } catch (e) {} 355 }); 356 } 357 } 358 359 // --- Sliding pill rows: show the selected pill on load ---
360 // The search filters mark their active pill server-side and the row opens at 361 // scrollLeft 0, so a reader who picked the last filter could not see their own 362 // selection. Centre it instead, clamped to the ends so the first and last sit 363 // flush rather than leaving a gap the row cannot scroll into. 364 // 365 // The home row needs nothing from this â its scroll spy reveals the active 366 // pill itself â but running here is harmless: its first pill clamps to 0. 367 document.querySelectorAll(".mmoc-pills--slide").forEach(function (row) { 368 var active = row.querySelector(".is-active"); 369 var overflow = row.scrollWidth - row.clientWidth; 370 371 // Nothing to do where the row wraps instead of scrolling (desktop). 372 if (!active || overflow <= 0) { 373 return; 374 } 375 376 var target = active.offsetLeft - (row.clientWidth - active.offsetWidth) / 2; 377 row.scrollLeft = Math.max(0, Math.min(target, overflow)); 378 }); 379 380 // The homepage section pills and their scroll spy lived here. The home 381 // titlebar that carried that row is gone â the masthead's nav covers the 382 // categories and the sections are reached by scrolling â so the smooth 383 // scroll, the spy and revealPill() went with it. The generic pill-row 384 // centring above still serves the search page's filter row, which is the 385 // other .mmoc-pills--slide on the site. 386 387 // --- Deferred ad units --- 388 // Slots rendered with $defer carry their mapping as data-base-div instead of 389 // an inline refresh call, so nothing is requested until this decides it is 390 // worth requesting. That is what keeps the site's two ad sets from charging 391 // each other: the phone's units and the desktop rail's are each hidden at the 392 // other's widths, and a hidden unit would otherwise fetch an ad no one can 393 // see, which counts against viewability. 394 // 395 // offsetParent is null for anything with display: none anywhere up its 396 // ancestry, which is exactly the test needed here. 397 var activateDeferredAds = function (scope) { 398 var units = (scope || document).querySelectorAll("[data-base-div]"); 399 var mappings = []; 400 401 units.forEach(function (unit) { 402 if (unit.offsetParent === null) { 403 return; 404 } 405 406 mappings.push({ 407 divId: unit.id, 408 baseDivId: unit.getAttribute("data-base-div"), 409 }); 410 // Claimed, so a later pass cannot request the same unit twice. 411 unit.removeAttribute("data-base-div"); 412 }); 413 414 if (!mappings.length) { 415 return; 416 } 417 418 window.tude = window.tude || { cmd: [] }; 419 window.tude.cmd.push(function () { 420 window.tude.refreshAdsViaDivMappings(mappings); 421 }); 422 }; 423 424 activateDeferredAds(document); 425 426 // --- Article interstitial --- 427 // Shown every time the reader opens an article, at phone widths only. Uncapped 428 // by decision, not by omission: it was capped at once per session, then gated 429 // to `?ad_slots=true` QA runs, and both limits were lifted deliberately â do 430 // not reintroduce one without asking. The tradeoff is recorded at the slot 431 // definition in inc/ads.php. 432 // 433 // The width gate is new: the desktop layout carries its advertising in the 434 // left rail and takes no in-content units at all, so an overlay between a 435 // desktop reader and the article they clicked would be the one ad on that 436 // page and the most intrusive thing on the site. Read per click rather than 437 // once at load, so a window resized across the breakpoint behaves as the 438 // width it currently is. 439 var interstitial = document.getElementById("mmoc-interstitial"); 440 441 if (interstitial && document.body.classList) { 442 // Every <a> in the theme that opens a post. 443 var ARTICLE_LINKS = 444 ".mmoc-featured-card, .mmoc-card, .mmoc-list-row, .mmoc-adjacent__card"; 445 446 var interstitialClose = document.getElementById("mmoc-interstitial-close"); 447 var interstitialContinue = document.getElementById( 448 "mmoc-interstitial-continue", 449 ); 450 var pendingHref = ""; 451 var returnFocusTo = null; 452 453 var closeInterstitial = function (goToArticle) { 454 interstitial.hidden = true; 455 document.body.classList.remove("is-interstitial-open"); 456 457 if (goToArticle && pendingHref) { 458 window.location.href = pendingHref; 459 return; 460 } 461 462 if (returnFocusTo) { 463 returnFocusTo.focus(); 464 } 465 }; 466 467 document.addEventListener("click", function (event) { 468 if (!event.target.closest) { 469 return; 470 } 471 472 var link = event.target.closest(ARTICLE_LINKS); 473 474 if (!link || !link.getAttribute("href")) { 475 return; 476 } 477 478 // Leave the browser's own behaviour alone for anything that is not a 479 // plain left click: a modified click opens a tab the reader is not 480 // looking at, and interrupting it would strand the ad in the 481 // background and lose them the article. 482 if ( 483 event.button !== 0 || 484 event.metaKey || 485 event.ctrlKey || 486 event.shiftKey || 487 event.altKey 488 ) { 489 return; 490 } 491 492 // Phone widths only â see the note at the top of this block.
493 if (isDesktopLayout()) { 494 return; 495 } 496 497 event.preventDefault(); 498 pendingHref = link.href; 499 returnFocusTo = link; 500 501 // Whatever happens to the ad, this link reaches the article. 502 interstitialContinue.setAttribute("href", pendingHref); 503 504 interstitial.hidden = false; 505 document.body.classList.add("is-interstitial-open"); 506 // Same reason as the sheets: hidden until now, so not yet focusable. 507 focusWhenReady(interstitialClose, 20); 508 }); 509 510 if (interstitialClose) { 511 interstitialClose.addEventListener("click", function () { 512 closeInterstitial(true); 513 }); 514 } 515 516 // Clicking the backdrop dismisses without going anywhere â the reader 517 // changed their mind about the article, not just the ad. 518 interstitial.addEventListener("click", function (event) { 519 if (event.target === interstitial) { 520 closeInterstitial(false); 521 } 522 }); 523 524 document.addEventListener("keydown", function (event) { 525 if ("Escape" === event.key && !interstitial.hidden) { 526 closeInterstitial(false); 527 } 528 }); 529 } 530 531 // --- Masthead search --- 532 // The mobile bar's expanding field at desktop widths, with the exits a 533 // pointer surface needs on top of the ones the bar has. The bar is a phone 534 // header where the only ways out are its own control and Escape; here the 535 // reader can also click elsewhere or tab away, and a field left open behind 536 // them would sit in the row holding 240px it is no longer using. 537 // 538 // Focus-out rather than mouseleave. Collapsing when the pointer leaves would 539 // close the field mid-sentence the moment the reader's hand drifted, so what 540 // closes it is the reader going somewhere else: focus leaving the group, a 541 // click outside it, Escape, or the control again. Whatever was typed stays in 542 // the input either way â it is collapsed, not cleared, so reopening resumes. 543 var mastheadSearch = document.querySelector(".mmoc-masthead__search"); 544 var mastheadSearchToggle = document.getElementById( 545 "mmoc-masthead-search-toggle", 546 ); 547 548 if (mastheadSearch && mastheadSearchToggle) { 549 var mastheadSearchField = document.getElementById( 550 "mmoc-masthead-search-field", 551 ); 552 553 var isMastheadSearchOpen = function () { 554 return "true" === mastheadSearchToggle.getAttribute("aria-expanded"); 555 }; 556 557 // returnFocus only when the reader closed it deliberately â via the 558 // control or Escape. On a click elsewhere focus belongs to whatever they 559 // clicked, and on a tab-out to wherever they tabbed. 560 var setMastheadSearchOpen = function (open, returnFocus) { 561 mastheadSearchToggle.setAttribute( 562 "aria-expanded", 563 open ? "true" : "false", 564 ); 565 // The attribute is the state assistive tech reads; the class is what 566 // the stylesheet draws from. Set together here so they cannot drift â 567 // see the note by .mmoc-masthead__search.is-open for why the CSS reads 568 // a class rather than :has() on the attribute. 569 mastheadSearch.classList.toggle("is-open", open); 570 571 if (open) { 572 if (mastheadSearchField) { 573 focusWhenReady(mastheadSearchField, 20); 574 } 575 } else if (returnFocus) { 576 mastheadSearchToggle.focus(); 577 } 578 }; 579 580 mastheadSearchToggle.addEventListener("click", function () { 581 setMastheadSearchOpen(!isMastheadSearchOpen(), true); 582 }); 583 584 // Read after the move settles rather than from event.relatedTarget, which 585 // is where focus is *going* and is not always populated â a focus moved by 586 // script reported null and left the field open behind a reader who had 587 // tabbed away. One tick later document.activeElement is authoritative for 588 // every route out, script included. 589 // 590 // Two things it must not treat as leaving. Focus moving between the field 591 // and its own control is a move inside the group â hence the containment 592 // test. And focus landing on <body> is focus that went nowhere nameable: 593 // the open itself produces exactly that, because focusing an input that is 594 // still zero-wide takes focus off the control without putting it in the 595 // field, so a bare close here collapsed the field on the very click that 596 // opened it. A real departure always names an element; the two routes that 597 // legitimately end on <body> â a click on empty page, a click on chrome â 598 // are covered by the document click handler below and by nothing needing 599 // to happen at all. 600 mastheadSearch.addEventListener("focusout", function () { 601 if (!isMastheadSearchOpen()) { 602 return; 603 } 604 605 window.setTimeout(function () { 606 var active = document.activeElement; 607 608 if (!active || active === document.body) { 609 return; 610 } 611 612 if (isMastheadSearchOpen() && !mastheadSearch.contains(active)) { 613 setMastheadSearchOpen(false, false); 614 } 615 }, 0); 616 }); 617 618 document.addEventListener("click", function (event) { 619 if (isMastheadSearchOpen() && !mastheadSearch.contains(event.target)) { 620 setMastheadSearchOpen(false, false); 621 } 622 }); 623 624 document.addEventListener("keydown", function (event) { 625 if ("Escape" === event.key && isMastheadSearchOpen()) { 626 setMastheadSearchOpen(false, true); 627 } 628 }); 629 } 630 631 // --- Sticky chrome: the masthead's scroll-up reveal --- 632 // 98px of header is worth its space when the reader is looking for it and not 633 // when they are reading, so it retreats going down the page and comes back on 634 // the first upward movement. The titlebar rides along â see the transform pair 635 // in style.css for why both move by transform rather than by their own `top`. 636 // 637 // The rail's trending card parks below both, so the titlebar's height is 638 // published as a custom property for CSS to add up. It is measured rather than 639 // declared because it is the one part of the stack that is not a fixed number: 640 // the home bar carries a pill row, the article bar a headline, and that 641 // headline condenses once the bar pins. 642 // The scroll position at which the reader has left the top of the page. Below 643 // it the header is simply part of the page and animating it away reads as a 644 // twitch; above it the header is chrome over content. Shared with the 645 // condensed article titlebar below, which needs the same distinction. 646 var CHROME_KEEP = 140; 647 648 var masthead = document.querySelector(".mmoc-masthead"); 649 var titlebar = document.querySelector(".mmoc-titlebar"); 650 651 var publishChromeHeights = function () { 652 var titlebarH = titlebar 653 ? Math.round(titlebar.getBoundingClientRect().height) 654 : 0; 655 document.documentElement.style.setProperty( 656 "--titlebar-h", 657 titlebarH + "px", 658 ); 659 660 // The masthead is measured too, not just declared. Its stylesheet value is 661 // the unwrapped height, and between 900px and roughly 1400px the nav wraps 662 // to a second row and the bar is half again as tall â a fixed value there 663 // parked the titlebar over the wrapped row. Only written while the bar is 664 // actually displayed: below 900px it is display: none and would publish 0, 665 // which the titlebar's own mobile offset does not want. 666 if (masthead && masthead.offsetParent !== null) { 667 var mastheadH = Math.round(masthead.getBoundingClientRect().height); 668 document.documentElement.style.setProperty( 669 "--masthead-h", 670 mastheadH + "px", 671 ); 672 } 673 }; 674 675 publishChromeHeights(); 676 window.addEventListener("resize", publishChromeHeights, { passive: true }); 677 // The bar can hold a lazily-loaded avatar, so its height is not final at 678 // DOMContentLoaded. 679 window.addEventListener("load", publishChromeHeights); 680 681 if (masthead) { 682 // Movement in one direction needed before the header changes state, and 683 // the reason it is 32 rather than a pixel or two of deadband: crossing 684 // CHROME_KEEP condenses the article titlebar, which shortens that bar by 685 // 18px, which shortens the document, which makes the browser nudge 686 // scrollY back by the same amount. A frame-to-frame comparison read that 687 // nudge as the reader scrolling up and re-showed the header halfway 688 // through a downward scroll. Accumulating instead means a one-off shift 689 // smaller than this cannot flip the state on its own, while any real 690 // gesture clears it immediately. It absorbs trackpad and momentum jitter 691 // on the same principle. 692 var CHROME_TRIGGER = 32; 693 694 var lastChromeY = 695 window.pageYOffset || document.documentElement.scrollTop || 0; 696 var chromeTravel = 0; 697 var chromeHidden = false;
698 699 var setChromeHidden = function (hidden) { 700 // Written only on a change: this runs per scroll frame, and a class 701 // write per frame invalidates style on every one of them. 702 if (hidden === chromeHidden) { 703 return; 704 } 705 chromeHidden = hidden; 706 document.body.classList.toggle("is-chrome-hidden", hidden); 707 }; 708 709 var syncChrome = function () { 710 var y = window.pageYOffset || document.documentElement.scrollTop || 0; 711 var dy = y - lastChromeY; 712 713 lastChromeY = y; 714 715 if (y <= CHROME_KEEP) { 716 setChromeHidden(false); 717 chromeTravel = 0; 718 return; 719 } 720 721 // A change of direction starts the count again, so travel is always 722 // distance moved one way and not a net figure that could sit near 723 // zero through a long scroll back and forth. 724 if (dy > 0 !== chromeTravel > 0) { 725 chromeTravel = 0; 726 } 727 728 chromeTravel += dy; 729 730 if (chromeTravel >= CHROME_TRIGGER) { 731 setChromeHidden(true); 732 chromeTravel = 0; 733 } else if (chromeTravel <= -CHROME_TRIGGER) { 734 setChromeHidden(false); 735 chromeTravel = 0; 736 } 737 }; 738 739 window.addEventListener("scroll", syncChrome, { passive: true }); 740 syncChrome(); 741 } 742 743 // --- Condensed article titlebar --- 744 // The headline steps down once the bar is covering the article, so a long 745 // title only costs the reader viewport while it is actually in the way. 746 // 747 // The trigger is CHROME_KEEP, not "has the bar pinned". It used to be the 748 // latter â the bar stuck at top: 0 and its own layout offset was the exact 749 // scroll position where that happened. It no longer is: the bar now parks 750 // directly under the masthead, at the same offset it already sits at, so it 751 // is pinned from scroll 0 and the test was true on an unscrolled page â 752 // every article opened with its headline already condensed. What the class 753 // wants to know is whether content has begun passing under the bar, which is 754 // what leaving the top of the page means. 755 // 756 // The class is toggled at every width; below 900px the bar is static, but the 757 // rule that reads it is inside a min-width: 900px block and there is nothing 758 // to see there. 759 var articleBar = document.querySelector(".mmoc-titlebar--article"); 760 761 if (articleBar) { 762 // A constant rather than a measurement, which is also what settles the 763 // flicker the old test had to carry a deadband for: the class shrinks the 764 // headline, a shorter headline shrinks the bar, and anything derived from 765 // the bar's own geometry fed that back into the threshold. Nothing this 766 // class does can move a fixed number. 767 var barStickPoint = CHROME_KEEP; 768 var barIsStuck = null; 769 770 var measureStickPoint = function () { 771 barStickPoint = CHROME_KEEP; 772 }; 773 774 // Deadband. Measuring alone was not enough: the join still flickered 775 // across a few pixels, because the shorter bar shifts the content under 776 // it and the stick point is not perfectly stable while that settles. The 777 // bar engages exactly at its own offset and only lets go once the reader 778 // is a clear 24px above it, so the two thresholds can never trade on 779 // consecutive frames however the layout moves between them. 780 var BAR_RELEASE = 24; 781 782 var syncStuckBar = function () { 783 var scrolled = 784 window.pageYOffset || document.documentElement.scrollTop || 0; 785 var threshold = barIsStuck ? barStickPoint - BAR_RELEASE : barStickPoint; 786 var isStuck = scrolled >= threshold; 787 // Only touch the class list on a change: this runs on every scroll 788 // frame, and a write per frame invalidates style on every one of them. 789 if (isStuck === barIsStuck) { 790 return; 791 } 792 barIsStuck = isStuck; 793 articleBar.classList.toggle("is-stuck", isStuck); 794 // The class steps the headline down, so the bar the trending rail is 795 // parked under is now a different height. 796 publishChromeHeights(); 797 }; 798 799 var remeasureStickPoint = function () { 800 measureStickPoint(); 801 syncStuckBar(); 802 }; 803 804 window.addEventListener("scroll", syncStuckBar, { passive: true }); 805 window.addEventListener("resize", remeasureStickPoint, { passive: true }); 806 // The leaderboard above the bar is a lazy image, so the bar's offset is 807 // not final at DOMContentLoaded. 808 window.addEventListener("load", remeasureStickPoint); 809 remeasureStickPoint(); 810 } 811 812 // --- Reading progress (single posts only) --- 813 var article = document.getElementById("mmoc-article"); 814 var progress = document.getElementById("mmoc-progress"); 815 816 if (article && progress) { 817 var updateProgress = function () { 818 var rect = article.getBoundingClientRect(); 819 // Fraction of the ARTICLE scrolled past, not the whole document. 820 var total = rect.height - window.innerHeight; 821 var done = 822 total > 0 823 ? -rect.top / total 824 : rect.bottom <= window.innerHeight 825 ? 1 826 : 0; 827 progress.style.width = Math.min(1, Math.max(0, done)) * 100 + "%"; 828 }; 829 window.addEventListener("scroll", updateProgress, { passive: true }); 830 window.addEventListener("resize", updateProgress, { passive: true }); 831 updateProgress(); 832 } 833 834 // --- Copy buttons (codes table) --- 835 document.querySelectorAll(".mmoc-copy").forEach(function (btn) { 836 btn.addEventListener("click", function () { 837 var value = btn.getAttribute("data-copy") || ""; 838 var done = function () { 839 var original = btn.getAttribute("data-label") || btn.textContent; 840 btn.setAttribute("data-label", original); 841 btn.textContent = "COPIED"; 842 window.setTimeout(function () { 843 btn.textContent = btn.getAttribute("data-label") || "COPY"; 844 }, 1500); 845 }; 846 if (navigator.clipboard && navigator.clipboard.writeText) { 847 navigator.clipboard.writeText(value).then(done, done); 848 } else { 849 done(); 850 } 851 }); 852 }); 853 854 // --- Archive: load more --- 855 var loadMore = document.getElementById("mmoc-load-more"); 856 var archiveGrid = document.getElementById("mmoc-archive-grid"); 857 858 if (loadMore && archiveGrid && window.mmocData) {
859 loadMore.addEventListener("click", function () { 860 var page = parseInt(loadMore.getAttribute("data-page"), 10) || 1; 861 862 loadMore.disabled = true; 863 loadMore.textContent = "Loadingâ¦"; 864 865 var body = new URLSearchParams(); 866 body.set("action", "mmoc_load_more"); 867 body.set("nonce", window.mmocData.nonce); 868 body.set("cat", loadMore.getAttribute("data-cat")); 869 body.set("page", String(page)); 870 871 var restore = function () { 872 loadMore.disabled = false; 873 loadMore.textContent = "Load more"; 874 }; 875 876 fetch(window.mmocData.ajaxUrl, { 877 method: "POST", 878 body: body, 879 credentials: "same-origin", 880 }) 881 .then(function (res) { 882 return res.json(); 883 }) 884 .then(function (json) { 885 if (!json || !json.success || !json.data) { 886 restore(); 887 return; 888 } 889 archiveGrid.insertAdjacentHTML("beforeend", json.data.html); 890 // The appended batch carries its own deferred in-feed slots. 891 loadMore.setAttribute("data-page", String(json.data.page)); 892 if (json.data.has_more) { 893 restore(); 894 } else { 895 loadMore.parentNode.removeChild(loadMore); 896 } 897 }) 898 .catch(restore); 899 }); 900 } 901});
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.