rastrillo / fiesta Public

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

Plain git — no account needed to clone.

Download

Download this file

1/* copy.js: a snippet's Copy button. First-party, dependency-free,
2 CSP-clean and optional: delete it and its <script> tag and the code
3 is still on the page to select by hand, because the button is hidden
4 in the markup and only this file shows it. It is app-owned from the
5 moment it is scaffolded.
6
7 What it reads, in either spelling:
8 [rst-snippet], .rst-snippet the box
9 the box's <code> what is copied, as text
10 [rst-snippet-copy], .rst-snippet__copy the button, hidden
11 [rst-snippet-status], .rst-snippet__status a role="status" line
12 On the button, both optional:
13 data-rst-copy-done the label, and the status, once it is copied
14 data-rst-copy-failed the status when the copy fails
15 Each is looked up on the attribute, then in the page's table
16 (self.fiestaStrings[lang], which i18n/<lang>.js fills), then the
17 English written here, as select.js does.
18
19 Where the browser has no clipboard API (an old engine, a page that is
20 not a secure context) the file stops before it listens for anything
21 and the buttons stay hidden: a button that does nothing is worse than
22 no button.
23
24 Each click is a request with a number, and only a button's latest
25 request may change anything. A click puts the label back and empties
26 the status at once, and cancels the button's two timers, before it
27 writes. A write that worked shows the done string on the label for
28 two seconds; one that failed (a rejected promise or a throw, a denied
29 permission among them) leaves the label alone and selects the code,
30 so it can be copied by hand. Either way the status gets its message
31 100 ms after the write settles, never at once: a live region that is
32 written the text it already holds has not changed, and a screen
33 reader says nothing the second time. Clicks closer together than
34 that, or a click while an earlier write is still pending, end in one
35 announcement, the latest request's, and a request that settles after
36 a newer one never writes at all. */
37(function () {
38 "use strict";
39
40 if (window.fiestaCopy) return;
41 window.fiestaCopy = true;
42
43 // No clipboard API, no button: return before listening for anything,
44 // so the buttons keep the hidden attribute the markup gave them.
45 if (typeof navigator.clipboard?.writeText !== "function") return;
46
47 var BOX = "[rst-snippet],.rst-snippet";
48 var BUTTON = "[rst-snippet-copy],.rst-snippet__copy";
49 var STATUS = "[rst-snippet-status],.rst-snippet__status";
50 // Long enough to read the done string, short enough that the button
51 // is the button again before anyone reaches for it twice.
52 var SHOWN_MS = 2000;
53 // The status is emptied at the click and written this long after the
54 // write settles, so two messages in a row are two changes.
55 var SPEAK_MS = 100;
56
57 // The strings: the attribute, then the page's table, then the
58 // fallback (the header says why). select.js carries the same two
59 // functions, so this file stays one script tag;
60 // check/scripts_test.go holds the copies identical.
61
62 // The table for a tag: an exact match whatever its case, else the
63 // first in its primary language (pt-BR reads pt-PT), else null.
64 function fiestaPickLang(tag, registry) {
65 var want = String(tag || "").toLowerCase();
66 if (!want) return null;
67 var keys = Object.keys(registry || {}), i;
68 for (i = 0; i < keys.length; i++) {
69 if (keys[i].toLowerCase() === want) return keys[i];
70 }
71 var primary = want.split("-")[0];
72 for (i = 0; i < keys.length; i++) {
73 if (keys[i].toLowerCase().split("-")[0] === primary) return keys[i];
74 }
75 return null;
76 }
77
78 function fiestaWord(el, key, fallback) {
79 var v = el.getAttribute("data-rst-" + key);
80 if (v !== null && v !== "") return v;
81 var registry = (typeof self !== "undefined" && self.fiestaStrings) || {};
82 var lang = fiestaPickLang(document.documentElement.lang, registry);
83 var table = lang ? registry[lang] : null;
84 return table && table[key] !== undefined && table[key] !== "" ? table[key] : fallback;
85 }
86
87 // Per button: the label it was given, the number of its latest
88 // request, and its two timers. A WeakMap, so a button the page
89 // removes takes its state with it.
90 var buttons = new WeakMap();
91 function state(button) {
92 var s = buttons.get(button);
93 if (!s) {
94 s = { label: button.textContent, latest: 0, reset: 0, speak: 0 };
95 buttons.set(button, s);
96 }
97 return s;
98 }
99
100 // Selecting the code is what a failed copy leaves the reader with:
101 // the text is chosen, and their own copy takes it.
102 function select(code) {
103 var range = document.createRange(), selection = window.getSelection();
104 range.selectNodeContents(code);
105 selection.removeAllRanges();
106 selection.addRange(range);
107 }
108
109 // The message goes in SPEAK_MS after a settlement, and only if the
110 // request is still the button's latest when the timer fires: a click
111 // in between has cancelled the timer already, and the number is the
112 // check that holds if a callback runs anyway.
113 function say(s, n, status, text) {
114 if (!status) return;
115 s.speak = setTimeout(function () {
116 if (n === s.latest) status.textContent = text;
117 }, SPEAK_MS);
118 }
119
120 function copy(button, box, code) {
121 var s = state(button), n = ++s.latest, status = box.querySelector(STATUS), write;
122 // Back to the start before this request writes, so nothing an
123 // earlier one left survives it: not its label, not its message,
124 // not a timer it has yet to fire.
125 clearTimeout(s.reset);
126 clearTimeout(s.speak);
127 button.textContent = s.label;
128 if (status) status.textContent = "";
129 // A throw (a browser that refuses outright) fails like a rejection.
130 try {
131 write = Promise.resolve(navigator.clipboard.writeText(code.textContent));
132 } catch (err) {
133 write = Promise.reject(err);
134 }
135 write.then(function () {
136 if (n !== s.latest) return;
137 var done = fiestaWord(button, "copy-done", "Copied");
138 button.textContent = done;
139 s.reset = setTimeout(function () {
140 if (n === s.latest) button.textContent = s.label;
141 }, SHOWN_MS);
142 say(s, n, status, done);
143 }, function () {
144 if (n !== s.latest) return;
145 select(code);
146 say(s, n, status, fiestaWord(button, "copy-failed", "Copy failed. Select the code and copy it yourself."));
147 });
148 }
149
150 // Shown once the document is parsed, each remembering its label
151 // first, while it still reads as the markup wrote it.
152 function show() {
153 document.querySelectorAll(BUTTON).forEach(function (button) {
154 state(button);
155 button.hidden = false;
156 });
157 }
158 if (document.readyState === "loading") {
159 document.addEventListener("DOMContentLoaded", show);
160 } else {
161 show();
162 }
163
164 // One listener for every snippet on the page, however many.
165 document.addEventListener("click", function (e) {
166 var button = e.target && e.target.closest ? e.target.closest(BUTTON) : null;
167 var box = button && button.closest(BOX);
168 var code = box && box.querySelector("code");
169 if (code) copy(button, box, code);
170 });
171})();
172