| 1 | /* gallery.js: the gallery page's own script, the chrome's half, and no |
| 2 | part of the design system. It is served joined with view.js, the |
| 3 | preview widget's half, as the one gallery.js every page loads |
| 4 | blocking in <head>. It is Rastrillo's gallery script for the same |
| 5 | chrome: the rail filter, keeping your place across a theme or |
| 6 | language switch, and following a moved section's old link; and in |
| 7 | place of Rastrillo's own colour toggle, a bootstrap for the scheme |
| 8 | fiesta.js owns. |
| 9 | |
| 10 | Same rules as the scripts it loads beside: first-party, |
| 11 | dependency-free, no network, and inert-safe: the page it enhances is |
| 12 | a complete document with scripts off. |
| 13 | |
| 14 | Why it is a blocking <script> in <head> rather than a deferred one |
| 15 | at the foot: two of the things it does have to happen before the |
| 16 | first paint. A stored Dark applied after the body has parsed is a |
| 17 | visible flash of the system scheme on every load, and the marker |
| 18 | that hides the filter box and the unpainted frames has to be on the |
| 19 | root before either could paint. It does no work beyond setting two |
| 20 | attributes before DOMContentLoaded, so blocking on it costs one |
| 21 | parse. |
| 22 | |
| 23 | The filter box is display: none until this file sets |
| 24 | data-gallery-js on <html>, which is the whole of the scriptless |
| 25 | story: with scripts off the control is never shown and the rail is |
| 26 | a complete list of every page. A visible control that cannot do |
| 27 | anything would be the misleading version. */ |
| 28 | (function () { |
| 29 | "use strict"; |
| 30 | |
| 31 | var root = document.documentElement; |
| 32 | root.setAttribute("data-gallery-js", "on"); |
| 33 | |
| 34 | function ready(fn) { |
| 35 | if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", fn); |
| 36 | else fn(); |
| 37 | } |
| 38 | |
| 39 | // The scheme, before the first paint. fiesta.js owns the choice: |
| 40 | // its menu writes data-theme on <html> and, on a page that carries |
| 41 | // data-rst-scheme-store="local", remembers it under fiesta-scheme. |
| 42 | // It restores a stored choice itself when it runs, but it is |
| 43 | // deferred, which is after the first paint, and it skips its own |
| 44 | // restore, menu marking included, when data-theme is already on the |
| 45 | // root. So this file, at parse time, puts the stored scheme on the |
| 46 | // root; then at readiness, and again when a page comes back from |
| 47 | // the back/forward cache (where the stored choice may have changed |
| 48 | // since), marks every copy of the scheme menu on the page (the |
| 49 | // bar's and the display settings menu's) the way fiesta.js marks |
| 50 | // one: aria-current on the button whose form's scheme is the one on |
| 51 | // the root, System when the root carries none. The root, not |
| 52 | // storage: where storage refuses, a choice is applied but never |
| 53 | // stored, and the marks follow what the page shows. view.js repaints |
| 54 | // the frames from the root on pageshow. |
| 55 | var KEY = "fiesta-scheme"; |
| 56 | var remembers = root.getAttribute("data-rst-scheme-store") === "local"; |
| 57 | |
| 58 | function stored() { |
| 59 | if (!remembers) return null; |
| 60 | try { |
| 61 | var v = localStorage.getItem(KEY); |
| 62 | return v === "light" || v === "dark" ? v : null; |
| 63 | } catch (e) { |
| 64 | return null; |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | function restore() { |
| 69 | var s = stored(); |
| 70 | if (s) root.setAttribute("data-theme", s); |
| 71 | else if (remembers) root.removeAttribute("data-theme"); |
| 72 | } |
| 73 | |
| 74 | // Not toggleAttribute: aria-current="" reads as false. |
| 75 | function mark() { |
| 76 | var s = root.getAttribute("data-theme") || "system"; |
| 77 | var menus = document.querySelectorAll("[rst-scheme]"); |
| 78 | for (var i = 0; i < menus.length; i++) { |
| 79 | var buttons = menus[i].querySelectorAll("button"); |
| 80 | for (var j = 0; j < buttons.length; j++) { |
| 81 | var f = buttons[j].form, v = f && f.elements.scheme && f.elements.scheme.value; |
| 82 | if (v === s) buttons[j].setAttribute("aria-current", "true"); |
| 83 | else buttons[j].removeAttribute("aria-current"); |
| 84 | } |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | if (!root.hasAttribute("data-theme")) restore(); |
| 89 | ready(mark); |
| 90 | addEventListener("pageshow", function (event) { |
| 91 | if (!event.persisted) return; |
| 92 | restore(); |
| 93 | mark(); |
| 94 | }); |
| 95 | // A choice made in one copy of the menu marks the other copy too; |
| 96 | // fiesta.js marks only the menu the form was in. |
| 97 | document.addEventListener("rst-scheme", function () { requestAnimationFrame(mark); }); |
| 98 | |
| 99 | // ── The nav filter ────────────────────────────────────────────────── |
| 100 | // |
| 101 | // The sidebar is a complete list of every page before this runs and |
| 102 | // stays one if it never does: the box is display:none until |
| 103 | // data-gallery-js is set, so scripts off gets the nav and no dead |
| 104 | // control. |
| 105 | // |
| 106 | // It says nothing of its own: the one sentence it can put on screen, |
| 107 | // No matches, is rendered by the page in the page's language and |
| 108 | // starts out hidden, and this file only takes the attribute off it. |
| 109 | ready(function () { |
| 110 | var input = document.querySelector("[data-ds-filter]"); |
| 111 | var nav = input && document.getElementById(input.getAttribute("aria-controls")); |
| 112 | if (!input || !nav) return; |
| 113 | var empty = document.querySelector("[data-ds-filter-empty]"); |
| 114 | var sections = nav.querySelectorAll("details"); |
| 115 | |
| 116 | // Folded both sides: "seccion" finds "Sección", and the rail is |
| 117 | // headings in twelve languages. NFD splits a letter from its |
| 118 | // accents; the range is the combining marks, and dropping them is |
| 119 | // the whole of it. |
| 120 | function fold(s) { |
| 121 | return s.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, ""); |
| 122 | } |
| 123 | |
| 124 | var links = nav.querySelectorAll("a"); |
| 125 | // An entry's synonyms (data-ds-terms) match beside its name, and |
| 126 | // the plain page links filter like every entry, so No matches and |
| 127 | // a visible Overview link never show together. |
| 128 | for (var i = 0; i < links.length; i++) links[i].dsText = fold(links[i].textContent + " " + (links[i].getAttribute("data-ds-terms") || "")); |
| 129 | var pages = nav.querySelectorAll(":scope > a"); |
| 130 | // A section's own name is searchable too: "shells" has to land |
| 131 | // somewhere, and the reader typing it means the whole section. |
| 132 | for (var i = 0; i < sections.length; i++) { |
| 133 | var head = sections[i].querySelector("summary"); |
| 134 | sections[i].dsText = head ? fold(head.textContent) : ""; |
| 135 | } |
| 136 | |
| 137 | // What the reader had open before they typed. A query opens |
| 138 | // whatever it found something in; clearing it hands the reader |
| 139 | // their own arrangement back. |
| 140 | var chosen = null; |
| 141 | |
| 142 | function run(query) { |
| 143 | var q = fold(query.trim()); |
| 144 | if (q && chosen === null) { |
| 145 | chosen = []; |
| 146 | for (var i = 0; i < sections.length; i++) chosen.push(sections[i].open); |
| 147 | } |
| 148 | var found = false; |
| 149 | for (var s = 0; s < sections.length; s++) { |
| 150 | var section = sections[s]; |
| 151 | var kids = section.children; |
| 152 | var shown = 0; |
| 153 | // The section's own name matching stands for everything under |
| 154 | // it: a reader who types "form" means the Form page, not the |
| 155 | // one entry in it whose name happens to contain the word. |
| 156 | var whole = !q || section.dsText.indexOf(q) >= 0; |
| 157 | for (var k = 0; k < kids.length; k++) { |
| 158 | var el = kids[k]; |
| 159 | if (el.tagName !== "A") continue; |
| 160 | var hit = whole || el.dsText.indexOf(q) >= 0; |
| 161 | el.hidden = !hit; |
| 162 | if (hit) shown++; |
| 163 | } |
| 164 | section.hidden = shown === 0; |
| 165 | if (shown) found = true; |
| 166 | section.open = q ? shown > 0 : chosen ? chosen[s] : section.open; |
| 167 | } |
| 168 | for (var p = 0; p < pages.length; p++) { |
| 169 | pages[p].hidden = q && pages[p].dsText.indexOf(q) < 0; |
| 170 | if (q && !pages[p].hidden) found = true; |
| 171 | } |
| 172 | if (!q) chosen = null; |
| 173 | if (empty) empty.hidden = !q || found; |
| 174 | } |
| 175 | |
| 176 | input.addEventListener("input", function () { |
| 177 | run(input.value); |
| 178 | }); |
| 179 | |
| 180 | // Escape clears a query that is there and is left alone when there |
| 181 | // is not, so the key still belongs to whatever is around the box. |
| 182 | // Focus never moves: nothing here touches it. |
| 183 | input.addEventListener("keydown", function (event) { |
| 184 | if (event.key !== "Escape" || input.value === "") return; |
| 185 | event.preventDefault(); |
| 186 | input.value = ""; |
| 187 | run(""); |
| 188 | }); |
| 189 | |
| 190 | // A back-navigation restores the box with a value already in it: |
| 191 | // filter to what it says, not to what the page was rendered saying. |
| 192 | if (input.value) run(input.value); |
| 193 | }); |
| 194 | |
| 195 | // ── Keeping your place ────────────────────────────────────────────── |
| 196 | // |
| 197 | // A theme or language link, in the pinned bar or in the phone's |
| 198 | // display settings menu, carries the section being read, worked out |
| 199 | // at the click; the address bar is never rewritten. |
| 200 | // 1. A fragment this document put in place, while its target has not |
| 201 | // moved (2px): the case geometry cannot answer, the last section |
| 202 | // of a page that clamps, or a glyph in a row of icons sharing a |
| 203 | // top. |
| 204 | // 2. Else the anchor at the reading line (the scroll padding plus a |
| 205 | // pixel, where a fragment lands): the greatest top at or above |
| 206 | // it, first in document order. |
| 207 | // 3. Above every anchor, nothing. |
| 208 | // The rule-1 record is written only when this document scrolls to a |
| 209 | // target: at load on a fresh, untouched navigation (redoing the |
| 210 | // parser's scroll, which late layout may have moved), and a frame |
| 211 | // after a plain, uncancelled click on a fragment link here, the only |
| 212 | // signal when that fragment is already in the address. History drops |
| 213 | // it, because Back restores where the reader had scrolled. |
| 214 | var record = null, touched = false; |
| 215 | |
| 216 | function anchor(id) { |
| 217 | var el = id && document.getElementById(id); |
| 218 | return el && el.hasAttribute("data-ds-anchor") ? el : null; |
| 219 | } |
| 220 | |
| 221 | function fragment(hash) { |
| 222 | try { |
| 223 | return decodeURIComponent(hash.slice(1)); |
| 224 | } catch (e) { |
| 225 | return hash.slice(1); |
| 226 | } |
| 227 | } |
| 228 | |
| 229 | function keep(el) { |
| 230 | record = { id: el.id, top: el.getBoundingClientRect().top }; |
| 231 | } |
| 232 | |
| 233 | ["wheel", "touchstart", "pointerdown", "keydown"].forEach(function (type) { |
| 234 | addEventListener(type, function () { touched = true; }, { capture: true, passive: true }); |
| 235 | }); |
| 236 | |
| 237 | addEventListener("load", function () { |
| 238 | var nav = performance.getEntriesByType("navigation")[0], el = anchor(fragment(location.hash)); |
| 239 | if (!el || touched || !nav || nav.type !== "navigate") return; |
| 240 | el.scrollIntoView({ block: "start" }); |
| 241 | requestAnimationFrame(function () { keep(el); }); |
| 242 | }); |
| 243 | |
| 244 | document.addEventListener("click", function (event) { |
| 245 | var a = event.target.closest && event.target.closest("a[href]"), el = a && anchor(fragment(a.hash)); |
| 246 | if (!el || event.button || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey || a.target || a.hasAttribute("download") || |
| 247 | a.origin + a.pathname + a.search !== location.origin + location.pathname + location.search) return; |
| 248 | requestAnimationFrame(function () { |
| 249 | if (!event.defaultPrevented) keep(el); |
| 250 | }); |
| 251 | }); |
| 252 | |
| 253 | addEventListener("popstate", function () { record = null; }); |
| 254 | if (window.navigation) navigation.addEventListener("navigate", function (event) { |
| 255 | if (event.navigationType === "traverse") record = null; |
| 256 | }); |
| 257 | |
| 258 | function place() { |
| 259 | var el = record && fragment(location.hash) === record.id && anchor(record.id); |
| 260 | if (el && Math.abs(el.getBoundingClientRect().top - record.top) <= 2) return el.id; |
| 261 | var line = (parseFloat(getComputedStyle(root).scrollPaddingBlockStart) || 0) + 1; |
| 262 | var all = document.querySelectorAll("[data-ds-anchor]"), best = "", top = -Infinity; |
| 263 | for (var i = 0; i < all.length; i++) { |
| 264 | var r = all[i].getBoundingClientRect(); |
| 265 | if ((r.width || r.height) && r.top <= line && r.top > top) { |
| 266 | best = all[i].id; |
| 267 | top = r.top; |
| 268 | } |
| 269 | } |
| 270 | return best; |
| 271 | } |
| 272 | |
| 273 | // A link's address is restored by a timer scheduled before it is |
| 274 | // changed, so nothing failing between can leave it set. Not a |
| 275 | // microtask: that checkpoint comes before activation reads href. |
| 276 | // Again on pageshow, for a page back from the cache whose timer never |
| 277 | // ran. |
| 278 | ready(function () { |
| 279 | var links = document.querySelectorAll(".ds-top__controls a[href], .ds-prefs a[href]"); |
| 280 | if (!links.length) return; |
| 281 | for (var i = 0; i < links.length; i++) links[i].dsHref = links[i].getAttribute("href"); |
| 282 | function canonical() { |
| 283 | for (var i = 0; i < links.length; i++) links[i].setAttribute("href", links[i].dsHref); |
| 284 | } |
| 285 | addEventListener("pageshow", canonical); |
| 286 | document.addEventListener("click", function (event) { |
| 287 | var a = event.target.closest && event.target.closest("a[href]"), id; |
| 288 | if (!a || a.dsHref === undefined) return; |
| 289 | setTimeout(canonical, 0); |
| 290 | if ((id = place())) a.setAttribute("href", a.dsHref + "#" + encodeURIComponent(id)); |
| 291 | }); |
| 292 | }); |
| 293 | |
| 294 | // ── A section that moved pages ────────────────────────────────────── |
| 295 | // |
| 296 | // A fragment naming nothing here that the rail links on another |
| 297 | // page: the section moved, and the old link in a bookmark or a search |
| 298 | // result is followed to where it went. The rail is the lookup, so no |
| 299 | // page keeps a list and the next move works the same way. Two |
| 300 | // lookups: Rastrillo's, a rail link whose fragment is the one asked |
| 301 | // for; and Fiesta's own, a rail link whose page stem is the fragment, |
| 302 | // since part 3 kept every example on one page under its name |
| 303 | // (index.html#button is button.html now). Only a fragment with no |
| 304 | // element at all is looked up, so a hash that is on the page is left |
| 305 | // alone; only a link to another path is followed, so a page can |
| 306 | // never send a reader to itself; a link that opens a new tab is not |
| 307 | // followed in this one; and the address is replaced, so Back does |
| 308 | // not return to a page that would send them on again. On hashchange |
| 309 | // as well as at readiness: a reader already on the page who opens |
| 310 | // the old link changes only the fragment, and nothing is loaded. |
| 311 | function moved() { |
| 312 | var id = fragment(location.hash), nav = id && !document.getElementById(id) && document.getElementById("ds-nav"); |
| 313 | var links = nav ? nav.querySelectorAll("a[href]") : []; |
| 314 | for (var i = 0; i < links.length; i++) { |
| 315 | var a = links[i]; |
| 316 | if (a.target || a.pathname === location.pathname) continue; |
| 317 | var stem = a.pathname.slice(a.pathname.lastIndexOf("/") + 1); |
| 318 | if (fragment(a.hash) === id || (!a.hash && stem === id + ".html")) { |
| 319 | location.replace(a.href); |
| 320 | return; |
| 321 | } |
| 322 | } |
| 323 | } |
| 324 | ready(moved); |
| 325 | addEventListener("hashchange", moved); |
| 326 | })(); |
| 327 | |