lmjtfy.git / apps / lmjtfy / src / page.js
page.jsannotatedpage.jssource288 lines · 12.8 KB · raw
1// The LMGTFY part: a link with ?q= types the question into the box, then asks.
2// Everything after the submit is the server's: Datastar morphs what it streams.
3// Only the home page has the box; the code and rules pages do not.
4(() => {
5  const form = document.getElementById("ask");
6  if (!form) return;
7  const box = document.getElementById("q");
8  const question = form.dataset.type;
9  const calm = matchMedia("(prefers-reduced-motion: reduce)").matches;
10
11  // The box is a textarea that grows with the question. `field-sizing: content`
12  // does it where it is supported; elsewhere the height follows the text here.
13  const native = CSS.supports("field-sizing", "content");
14  function grow() {
15    if (native) return;
16    box.style.height = "auto";
17    box.style.height = `${box.scrollHeight + box.offsetHeight - box.clientHeight}px`;
18  }
19  box.addEventListener("input", grow);
20  form.addEventListener("submit", () => setTimeout(grow, 0));
21  // A question is one line: Enter asks (Shift+Enter too), and a pasted line
22  // break becomes a space. Not while an IME is composing: Enter confirms there.
23  box.addEventListener("keydown", (event) => {
24    if (event.key !== "Enter" || event.isComposing) return;
25    event.preventDefault();
26    form.requestSubmit();
27  });
28  box.addEventListener("paste", (event) => {
29    const text = event.clipboardData?.getData("text");
30    if (text === undefined || !/[\r\n]/.test(text)) return;
31    event.preventDefault();
32    box.setRangeText(text.replace(/\s*[\r\n]+\s*/g, " "), box.selectionStart, box.selectionEnd, "end");
33    box.dispatchEvent(new Event("input", { bubbles: true }));
34  });
35  const wait = (ms) => new Promise((done) => setTimeout(done, calm ? 0 : ms));
36
37  // Datastar loads as a module, after this script. When it binds the box it
38  // writes its (empty) signal into it, so anything typed before then is lost.
39  // This listener is in place before the module runs, so it cannot miss the event.
40  const bound = new Promise((done) => document.addEventListener("datastar-ready", done, { once: true }));
41
42  async function type() {
43    await bound;
44    box.value = "";
45    await wait(700);
46    for (const ch of question) {
47      box.value += ch;
48      // Datastar's bind listens for input: the signal follows the box.
49      box.dispatchEvent(new Event("input", { bubbles: true }));
50      await wait(45 + Math.random() * 70);
51    }
52    await wait(500);
53    form.requestSubmit();
54  }
55  if (question) type();
56
57  // What this page asked last, so the toast for it is not shown back to it.
58  form.addEventListener("submit", () => { window.lmjtfyAsked = box.value.trim(); });
59})();
60
61// `/live`: the archive tells every open browser how many are open and where,
62// and sends a toast when someone asks a question or clones the code. The
63// socket is a SharedWorker's (`/live.js`), one for all this browser's tabs of
64// this build; a browser without SharedWorker holds its own, per tab. Either
65// reconnects, waiting longer each time, if the socket drops.
66(() => {
67  const online = document.getElementById("online");
68  const toasts = document.getElementById("toasts");
69  if (!online || !toasts) return;
70  const mine = document.querySelector('meta[name="lmjtfy-build"]')?.content ?? "";
71  function heard(text) {
72    let live;
73    try { live = JSON.parse(text); } catch { return; }
74    if (typeof live.online === "number") {
75      online.textContent = `${live.online} online`;
76      online.hidden = false;
77    }
78    if (live.closed) online.hidden = true;
79    if (typeof live.toast === "string") toast(live.toast);
80    if (typeof live.patch === "string") patch(live.patch);
81    if (typeof live.build === "string") built(live.build);
82  }
83  function shared() {
84    const worker = new SharedWorker(`/live.js?build=${encodeURIComponent(mine)}`, { name: `lmjtfy-live-${mine}` });
85    worker.port.addEventListener("message", (event) => heard(event.data));
86    worker.port.start();
87    addEventListener("pagehide", () => worker.port.postMessage("bye"), { once: true });
88  }
89  let wait = 1000;
90  function connect() {
91    // The page's build, so the archive tells only an earlier page what a
92    // deploy changed.
93    const socket = new WebSocket(`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/live?build=${encodeURIComponent(mine)}`);
94    socket.addEventListener("open", () => { wait = 1000; });
95    socket.addEventListener("message", (event) => heard(event.data));
96    socket.addEventListener("close", () => {
97      online.hidden = true;
98      setTimeout(connect, wait);
99      wait = Math.min(wait * 2, 60000);
100    });
101  }
102  function start() {
103    if (typeof SharedWorker === "function") {
104      try { shared(); return; } catch { /* a browser that refuses it: hold our own */ }
105    }
106    connect();
107  }
108  // Back from the back-forward cache: the worker forgot this tab when it
109  // said goodbye, so it joins again.
110  addEventListener("pageshow", (event) => { if (event.persisted && typeof SharedWorker === "function") shared(); });
111  // The site was deployed since this page was rendered: a toast that stays
112  // until it is used or dismissed, once.
113  let offered = false;
114  function built(build) {
115    if (!mine || build === mine || offered) return;
116    offered = true;
117    const shown = document.createElement("div");
118    shown.className = "toast stays";
119    shown.innerHTML = 'A new version of the site is live. <button type="button" class="reload">Reload</button> <button type="button" class="ghost dismiss" aria-label="Dismiss">×</button>';
120    shown.querySelector(".reload").addEventListener("click", () => location.reload());
121    shown.querySelector(".dismiss").addEventListener("click", () => shown.remove());
122    toasts.append(shown);
123  }
124  // Each element sent replaces this page's element with the same id, if it
125  // has one: the feeds on the home page, and whatever else is sent this way.
126  // One with `data-prepend` puts its children at the top of the element it
127  // names instead, each taking the place of its own id wherever that was,
128  // so what this page has scrolled in further down stays.
129  function patch(html) {
130    const holder = document.createElement("template");
131    holder.innerHTML = html;
132    for (const fresh of [...holder.content.children]) {
133      const into = fresh.dataset.prepend && document.getElementById(fresh.dataset.prepend);
134      if (into) {
135        for (const line of [...fresh.children].reverse()) {
136          if (line.id) document.getElementById(line.id)?.remove();
137          into.prepend(line);
138        }
139      } else if (fresh.id) {
140        document.getElementById(fresh.id)?.replaceWith(fresh);
141      }
142    }
143  }
144  // The markup is the server's, escaped there (view/toast.rs).
145  function toast(html) {
146    const holder = document.createElement("div");
147    holder.innerHTML = html;
148    const shown = holder.firstElementChild;
149    if (!shown || (shown.dataset.q && shown.dataset.q === window.lmjtfyAsked)) return;
150    toasts.append(shown);
151    // The oldest passing toasts go first; one that stays is never pushed out.
152    const passing = [...toasts.children].filter((toast) => !toast.classList.contains("stays"));
153    while (passing.length > 3) passing.shift().remove();
154    setTimeout(() => { shown.classList.add("gone"); setTimeout(() => shown.remove(), 400); }, 7000);
155  }
156  start();
157})();
158
159// The copy buttons, on every page: `data-copy` names the element whose link
160// or text is copied.
161(() => {
162  document.addEventListener("click", (event) => {
163    const button = event.target.closest("[data-copy]");
164    if (!button) return;
165    const link = document.getElementById(button.dataset.copy);
166    navigator.clipboard.writeText(link.href ?? link.textContent).then(
167      () => { button.textContent = "Copied"; },
168      () => { getSelection().selectAllChildren(link); button.textContent = "Selected, press Ctrl+C"; }
169    );
170  });
171})();
172
173// The dot fields behind the page, after typesafe.ai: soft blobs put through an
174// 8x8 ordered dither, one image pixel per dot, tiled. Drawn once into CSS
175// variables rather than into elements, because the transcript is replaced
176// wholesale and a canvas inside it would be wiped with it.
177(() => {
178  const BAYER = [
179    [0, 32, 8, 40, 2, 34, 10, 42], [48, 16, 56, 24, 50, 18, 58, 26],
180    [12, 44, 4, 36, 14, 46, 6, 38], [60, 28, 52, 20, 62, 30, 54, 22],
181    [3, 35, 11, 43, 1, 33, 9, 41], [51, 19, 59, 27, 49, 17, 57, 25],
182    [15, 47, 7, 39, 13, 45, 5, 37], [63, 31, 55, 23, 61, 29, 53, 21],
183  ];
184  const W = 400, H = 300;
185  // A fixed seed: the same field on every load.
186  const random = (seed) => () => ((seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296);
187
188  function field(seed, count, strength, [r, g, b]) {
189    const next = random(seed);
190    const blobs = Array.from({ length: count }, () => ({
191      x: next() * W, y: next() * H, size: 16 + next() * 30, weight: (0.5 + next() * 0.5) * strength,
192    }));
193    const canvas = document.createElement("canvas");
194    canvas.width = W; canvas.height = H;
195    const context = canvas.getContext("2d");
196    const image = context.createImageData(W, H);
197    for (let y = 0; y < H; y++) {
198      for (let x = 0; x < W; x++) {
199        let value = 0;
200        for (const blob of blobs) {
201          // Distances wrap, so the tile has no seam.
202          const dx = Math.min(Math.abs(x - blob.x), W - Math.abs(x - blob.x));
203          const dy = Math.min(Math.abs(y - blob.y), H - Math.abs(y - blob.y));
204          value += blob.weight * Math.exp(-(dx * dx + dy * dy) / (2 * blob.size * blob.size));
205        }
206        if (value > (BAYER[y % 8][x % 8] + 0.5) / 64) {
207          const at = (y * W + x) * 4;
208          image.data[at] = r; image.data[at + 1] = g; image.data[at + 2] = b; image.data[at + 3] = 255;
209        }
210      }
211    }
212    context.putImageData(image, 0, 0);
213    return `url(${canvas.toDataURL()})`;
214  }
215
216  const root = document.documentElement.style;
217  root.setProperty("--dither-paper", field(7, 4, 0.55, [196, 196, 196]));
218  root.setProperty("--dither-pink", field(23, 5, 0.9, [30, 30, 30]));
219})();
220
221// A diagram or picture in a document opens full size on a click: mermaid's
222// drawings and the rules' SVG are too small to read inline. Escape, the
223// close button or a click on the backdrop closes it.
224(() => {
225  let dialog;
226  function open(figure) {
227    if (!dialog) {
228      dialog = document.createElement("dialog");
229      dialog.className = "zoom";
230      dialog.innerHTML = '<button type="button" class="ghost close">Close</button><div class="zoomed"></div>';
231      dialog.addEventListener("click", (event) => {
232        if (event.target === dialog || event.target.closest(".close")) dialog.close();
233      });
234      document.body.append(dialog);
235    }
236    const zoomed = dialog.querySelector(".zoomed");
237    const copy = figure.cloneNode(true);
238    copy.removeAttribute("style");
239    copy.removeAttribute("width");
240    copy.removeAttribute("height");
241    zoomed.replaceChildren(copy);
242    dialog.showModal();
243  }
244  document.addEventListener("click", (event) => {
245    const figure = event.target.closest(".md pre.mermaid svg, .md img");
246    if (figure && !event.target.closest("dialog")) open(figure);
247  });
248})();
249
250// What only the page knows, told to `/seen` (the archive keeps it as a
251// `read` event): how long this page was in view, how far down it was seen,
252// and the screen. It is said each time the page is put away, as the time in
253// view since it last said, so a tab left open and come back to adds up. A
254// link followed off the site is said as it is followed (`out`).
255(() => {
256  // The code host has no `/seen`, and its pages have no `#online` (nav).
257  if (!navigator.sendBeacon || !document.getElementById("online")) return;
258  let since = document.visibilityState === "visible" ? performance.now() : null;
259  let seen = 0;
260  let deepest = 0;
261  // The home page scrolls the window; a code page scrolls its pane.
262  const measure = () => {
263    const pane = document.querySelector("main.pane");
264    const top = pane ? pane.scrollTop : scrollY;
265    const tall = pane ? pane.scrollHeight : document.documentElement.scrollHeight;
266    const high = pane ? pane.clientHeight : innerHeight;
267    if (tall > 0) deepest = Math.max(deepest, Math.min(1, (top + high) / tall));
268  };
269  const say = (more) => navigator.sendBeacon("/seen", JSON.stringify({
270    path: location.pathname, query: location.search, ms: 0, scroll: deepest,
271    screen: `${screen.width}x${screen.height}`, viewport: `${innerWidth}x${innerHeight}`, ...more,
272  }));
273  addEventListener("scroll", measure, { passive: true, capture: true });
274  document.addEventListener("visibilitychange", () => {
275    if (document.visibilityState === "visible") { since = performance.now(); return; }
276    if (since === null) return;
277    measure();
278    seen = performance.now() - since;
279    since = null;
280    // A glance of under a second is not a reading.
281    if (seen >= 1000) say({ ms: Math.round(seen) });
282  });
283  document.addEventListener("click", (event) => {
284    const link = event.target.closest && event.target.closest("a[href]");
285    if (link && link.host && link.host !== location.host && /^https?:$/.test(link.protocol)) say({ out: link.href });
286  }, { capture: true });
287  measure();
288})();