rastrillo / fiesta Public

Clone
git clone https://amadan.net/rastrillo/fiesta

Plain git — no account needed to clone.

Download

Download this file

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