| 1 | /* view.js: the preview widget's half of the gallery's script, served |
| 2 | joined after gallery.js as one file, and no part of the design |
| 3 | system. Two jobs: the frames, and the Copy button on every code |
| 4 | block. It is Rastrillo's frame-painting and Copy code; the scheme |
| 5 | itself is fiesta.js's, from the scheme menu in the header, and this |
| 6 | file only carries what fiesta.js decided into every preview frame. |
| 7 | |
| 8 | The frames. Every example on the page is a document of its own |
| 9 | inside an iframe, and an iframe does not inherit the reader's |
| 10 | choice: a colour scheme is not propagated into an embedded document |
| 11 | that declares one, and every preview links a theme that does. The |
| 12 | frames are same-origin, so the fix is the attribute fiesta.js wrote |
| 13 | on this page's <html>, written again on each of them: once each |
| 14 | document is parsed and styled, and on every rst-scheme event after. |
| 15 | frame.js, first in each frame's head, does the same for itself |
| 16 | before its first paint, so a frame loaded after the choice starts in |
| 17 | it; a document a navigation inside the frame loads from elsewhere |
| 18 | has no such script, and is what the hiding below is for. |
| 19 | |
| 20 | A frame is hidden by view.css until this file marks it painted |
| 21 | (data-gallery-painted), which is why the joined script is a blocking |
| 22 | script in <head>: the marker that turns the hiding rule on |
| 23 | (data-gallery-js) has to be there before the first frame can paint |
| 24 | in the wrong scheme. With scripts off the marker is never set, |
| 25 | nothing is hidden, and the frames show as they come, in the OS's |
| 26 | scheme, which is what System means anyway. */ |
| 27 | (function () { |
| 28 | "use strict"; |
| 29 | |
| 30 | var root = document.documentElement; |
| 31 | root.setAttribute("data-gallery-js", "on"); |
| 32 | |
| 33 | // The scheme in force on this page: fiesta.js keeps it on <html>, |
| 34 | // and no attribute is System. |
| 35 | function scheme() { |
| 36 | return root.getAttribute("data-theme"); |
| 37 | } |
| 38 | |
| 39 | function readable(frame) { |
| 40 | try { |
| 41 | var doc = frame.contentDocument; |
| 42 | return doc && doc.documentElement && doc.URL !== "about:blank" ? doc : null; |
| 43 | } catch (e) { |
| 44 | return null; |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | function paint(frame, s) { |
| 49 | var doc = readable(frame); |
| 50 | if (!doc) return; |
| 51 | if (s === "light" || s === "dark") doc.documentElement.setAttribute("data-theme", s); |
| 52 | else doc.documentElement.removeAttribute("data-theme"); |
| 53 | } |
| 54 | |
| 55 | function frames() { |
| 56 | return document.querySelectorAll(".ds-view__frame"); |
| 57 | } |
| 58 | |
| 59 | function all(s) { |
| 60 | var f = frames(); |
| 61 | for (var i = 0; i < f.length; i++) paint(f[i], s); |
| 62 | } |
| 63 | |
| 64 | // Phase two, once the body exists. This file is in <head> and not |
| 65 | // deferred, so readyState is always "loading" here; the branch is |
| 66 | // for a copy of it moved to the foot of the page. |
| 67 | function ready(fn) { |
| 68 | if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", fn); |
| 69 | else fn(); |
| 70 | } |
| 71 | |
| 72 | // Shown even if it could not be painted: a frame in the wrong scheme |
| 73 | // beats one that never appears. |
| 74 | function shown(frame) { |
| 75 | paint(frame, scheme()); |
| 76 | frame.setAttribute("data-gallery-painted", ""); |
| 77 | } |
| 78 | |
| 79 | // DOMContentLoaded does not wait for stylesheets, and a frame shown |
| 80 | // then shows its document without them. Each sheet not yet arrived |
| 81 | // is waited for, loaded or failed; one that failed before this |
| 82 | // looked never says so, and the frame's load shows it instead. |
| 83 | function styled(frame, doc) { |
| 84 | var links = doc.querySelectorAll('link[rel~="stylesheet"]'), left = 1; |
| 85 | function one() { |
| 86 | if (--left === 0 && readable(frame) === doc) shown(frame); |
| 87 | } |
| 88 | for (var i = 0; i < links.length; i++) { |
| 89 | if (links[i].sheet) continue; |
| 90 | left++; |
| 91 | links[i].addEventListener("load", one); |
| 92 | links[i].addEventListener("error", one); |
| 93 | } |
| 94 | one(); |
| 95 | } |
| 96 | |
| 97 | // Every document a frame loads goes through arm(), the first and each |
| 98 | // one a sample's form or link loads in it after, and in this order: |
| 99 | // the hook that hides the frame again as the document goes, then the |
| 100 | // reveal. Shown first, a document that navigated before its own load |
| 101 | // left the mark in place, and the next one painted in the OS's |
| 102 | // scheme, unhidden. Not cleared when the whole gallery page enters |
| 103 | // the back/forward cache (persisted), which unloads nothing and |
| 104 | // restores as it was. |
| 105 | // |
| 106 | // Parsed and styled is soon enough to show it: waiting for load kept |
| 107 | // a parsed preview an empty box while an image was slow, or for as |
| 108 | // long as the network took to give up on one that hung. |
| 109 | function arm(frame, armed) { |
| 110 | var doc; |
| 111 | try { |
| 112 | doc = frame.contentDocument; |
| 113 | } catch (e) { |
| 114 | return false; |
| 115 | } |
| 116 | if (!doc || doc === armed.doc || doc.URL === "about:blank") return false; |
| 117 | armed.doc = doc; |
| 118 | watchRest(frame, doc); |
| 119 | doc.defaultView.addEventListener("pagehide", function (event) { |
| 120 | if (event.persisted) return; |
| 121 | frame.removeAttribute("data-gallery-painted"); |
| 122 | seek(frame, armed); |
| 123 | }); |
| 124 | if (doc.readyState === "loading") { |
| 125 | doc.addEventListener("DOMContentLoaded", function () { |
| 126 | if (readable(frame) === doc) styled(frame, doc); |
| 127 | }); |
| 128 | } else { |
| 129 | styled(frame, doc); |
| 130 | } |
| 131 | return true; |
| 132 | } |
| 133 | |
| 134 | // The parent hears nothing when a navigation inside a frame commits |
| 135 | // its next document, so after pagehide it looks until the frame holds |
| 136 | // one, and arms it. The look ends there, or at the frame's load, |
| 137 | // which arms whatever is there; a document from another origin cannot |
| 138 | // be read, and load shows the frame unpainted. |
| 139 | // |
| 140 | // It also ends when the frame leaves the page, and after SEEK_MS |
| 141 | // whatever happens. Without those, a frame removed mid-look, or sent |
| 142 | // to another origin whose load hangs, kept a 16ms timer running for |
| 143 | // the life of the page, holding the frame and its old document, and |
| 144 | // the second kept the frame hidden for good. Giving up shows the |
| 145 | // frame unpainted, as load would have. |
| 146 | var SEEK_MS = 5000; |
| 147 | |
| 148 | function seek(frame, armed) { |
| 149 | clearTimeout(armed.timer); |
| 150 | var end = Date.now() + SEEK_MS; |
| 151 | (function look() { |
| 152 | armed.timer = 0; |
| 153 | if (!frame.isConnected || arm(frame, armed)) return; |
| 154 | if (Date.now() >= end) shown(frame); |
| 155 | else armed.timer = setTimeout(look, 16); |
| 156 | })(); |
| 157 | } |
| 158 | |
| 159 | // A frame's first document reuses the window of the about:blank it |
| 160 | // replaces (same origin), so a listener on that window hears the |
| 161 | // preview's DOMContentLoaded and arms it while it is still parsing; |
| 162 | // the frames are lazy, so nothing looks for them before then. load |
| 163 | // stays the fallback, for an engine that does not reuse the window. |
| 164 | function watch(frame) { |
| 165 | var armed = { doc: null, timer: 0 }, view = restView(frame); |
| 166 | // Nothing is open in a frame that has not loaded. |
| 167 | if (view) view.setAttribute("data-ds-rest", ""); |
| 168 | try { |
| 169 | frame.contentWindow.addEventListener("DOMContentLoaded", function () { |
| 170 | arm(frame, armed); |
| 171 | }); |
| 172 | } catch (e) { |
| 173 | /* no window yet, or not readable; load will arm it */ |
| 174 | } |
| 175 | frame.addEventListener("load", function () { |
| 176 | clearTimeout(armed.timer); |
| 177 | arm(frame, armed); |
| 178 | shown(frame); |
| 179 | }); |
| 180 | // A document already parsed before this ran (a frame served from |
| 181 | // cache) has had its DOMContentLoaded. |
| 182 | arm(frame, armed); |
| 183 | } |
| 184 | |
| 185 | // ── Rest heights ──────────────────────────────────────────────────── |
| 186 | // |
| 187 | // A frame whose menus open far below its page (the column's footer |
| 188 | // row) has two heights, the open one in --ds-h and --ds-hm and the |
| 189 | // rest one in --ds-hr and --ds-hmr. Sized for its menus open, its box |
| 190 | // was mostly empty at rest. While no menu in the frame is open its |
| 191 | // widget is marked data-ds-rest, and view.css gives the box the rest |
| 192 | // heights; unmarked, as with scripts off, the box keeps the open |
| 193 | // height, so a menu a reader opens is in view either way. |
| 194 | // |
| 195 | // A click on a closed menu's summary unmarks the widget at once, |
| 196 | // before the menu opens, so the frame is already tall when the panel |
| 197 | // is placed: placed in the short frame, an anchored panel opens up |
| 198 | // over the page, and stays there once the frame grows. toggle then |
| 199 | // settles the mark from what is really open, which also covers a menu |
| 200 | // opened from the keyboard or by a script, one closed by its sibling |
| 201 | // opening, and a click whose menu did not open after all. |
| 202 | var MENU_OPEN = "details[rst-dropdown][open], details.rst-dropdown[open]"; |
| 203 | |
| 204 | function restView(frame) { |
| 205 | var view = frame.closest(".ds-view"); |
| 206 | return view && view.style.getPropertyValue("--ds-hr") ? view : null; |
| 207 | } |
| 208 | |
| 209 | function settleRest(view, doc) { |
| 210 | view.toggleAttribute("data-ds-rest", !doc.querySelector(MENU_OPEN)); |
| 211 | } |
| 212 | |
| 213 | function watchRest(frame, doc) { |
| 214 | var view = restView(frame); |
| 215 | if (!view) return; |
| 216 | settleRest(view, doc); |
| 217 | doc.addEventListener("toggle", function () { |
| 218 | settleRest(view, doc); |
| 219 | }, true); |
| 220 | doc.addEventListener("click", function (event) { |
| 221 | var summary = event.target.closest && event.target.closest("summary"), menu = summary && summary.parentElement; |
| 222 | if (!menu || menu.open || !menu.matches("details[rst-dropdown], details.rst-dropdown")) return; |
| 223 | view.removeAttribute("data-ds-rest"); |
| 224 | setTimeout(function () { |
| 225 | settleRest(view, doc); |
| 226 | }, 100); |
| 227 | }, true); |
| 228 | } |
| 229 | |
| 230 | // fiesta.js dispatches rst-scheme from the form after it has written |
| 231 | // the attribute, so the detail and the root agree; the detail is |
| 232 | // read all the same, so this file never depends on the order. |
| 233 | document.addEventListener("rst-scheme", function (event) { |
| 234 | var s = event.detail && event.detail.scheme; |
| 235 | all(s === "light" || s === "dark" ? s : null); |
| 236 | }); |
| 237 | |
| 238 | // A page back from the back/forward cache is reactivated, not |
| 239 | // re-run, and fiesta.js may have restored a stored choice since. |
| 240 | addEventListener("pageshow", function (event) { |
| 241 | if (event.persisted) all(scheme()); |
| 242 | }); |
| 243 | |
| 244 | ready(function () { |
| 245 | var f = frames(); |
| 246 | for (var i = 0; i < f.length; i++) watch(f[i]); |
| 247 | }); |
| 248 | |
| 249 | // ── Copy ──────────────────────────────────────────────────────────── |
| 250 | // |
| 251 | // A button on every source block, drawn only where the clipboard API |
| 252 | // exists. On a plain-HTTP origin (a tailnet address) it does not, and |
| 253 | // a button that cannot work is worse than a <pre> a reader can |
| 254 | // select. The words are the page's, read off the live region it |
| 255 | // renders in its own language; a button's name is built from text |
| 256 | // already on screen, so the server writes nothing per block. |
| 257 | ready(function () { |
| 258 | var region = document.querySelector("[data-ds-copy-status]"); |
| 259 | if (!region || !navigator.clipboard || !navigator.clipboard.writeText) return; |
| 260 | var d = region.dataset, pres = document.querySelectorAll("pre.ds-src:not([data-ds-nocopy])"); |
| 261 | |
| 262 | // The name after the label: the tab the block is under, the |
| 263 | // section (its anchored heading, or the last heading before it), |
| 264 | // and the nearest state label above. On an example page the |
| 265 | // anchored section's heading is the state's own label, so the two |
| 266 | // read the same and the second is dropped; a grouped widget's rows |
| 267 | // have the page's heading and then each row's label. |
| 268 | function name(pre) { |
| 269 | var sec = pre.closest("[data-ds-anchor]"), h = sec && sec.querySelector("h1, h2, h3, h4"); |
| 270 | var all = document.querySelectorAll("main :is(h1, h2, h3, h4)"), sample = pre.closest(".ds-sample"); |
| 271 | if (!h) for (var i = 0; i < all.length && all[i].compareDocumentPosition(pre) & 4; i++) h = all[i]; |
| 272 | for (var s = pre.previousElementSibling; s && !s.matches(".ds-state"); s = s.previousElementSibling); |
| 273 | s = s || sample && sample.querySelector(":scope > .ds-state"); |
| 274 | var panel = pre.closest(".ds-view__code"), tab = panel && panel.parentNode.querySelector(".ds-view__tab--h"); |
| 275 | var parts = [tab, h, s].filter(Boolean).map(function (e) { |
| 276 | return e.textContent.trim(); |
| 277 | }); |
| 278 | return parts.filter(function (text, i) { |
| 279 | return text && text !== parts[i - 1]; |
| 280 | }).join(d.join); |
| 281 | } |
| 282 | |
| 283 | // Emptied and written a beat later, so Copied twice in a row is |
| 284 | // still announced. |
| 285 | function say(text) { |
| 286 | region.textContent = ""; |
| 287 | setTimeout(function () { region.textContent = text; }, 50); |
| 288 | } |
| 289 | |
| 290 | for (var i = 0; i < pres.length; i++) (function (pre) { |
| 291 | var code = pre.querySelector("code") || pre, b = document.createElement("button"); |
| 292 | var label = document.createTextNode(d.copy), suffix = document.createElement("span"), timer; |
| 293 | b.type = "button"; |
| 294 | b.className = "ds-copy"; |
| 295 | suffix.className = "rst-sr-only"; |
| 296 | suffix.textContent = " " + name(pre); |
| 297 | b.append(label, suffix); |
| 298 | pre.before(b); |
| 299 | // Copied is said only once the write resolves. A refusal (a |
| 300 | // permission, lost focus, a policy) or a throw is announced as a |
| 301 | // failure and the block selected, so Ctrl or Cmd+C works at once. |
| 302 | b.addEventListener("click", function () { |
| 303 | new Promise(function (ok) { ok(navigator.clipboard.writeText(code.textContent)); }).then(function () { |
| 304 | say(d.copied); |
| 305 | label.data = d.copied; |
| 306 | clearTimeout(timer); |
| 307 | timer = setTimeout(function () { label.data = d.copy; }, 2000); |
| 308 | }, function () { |
| 309 | say(d.failed); |
| 310 | getSelection().selectAllChildren(code); |
| 311 | }); |
| 312 | }); |
| 313 | })(pres[i]); |
| 314 | }); |
| 315 | })(); |
| 316 | |