186 lines (167 loc) · 7.05 KB
  1import { $ } from "./dom.js";
  2
  3/**
  4 * /src/ file view: text "copy" in the meta row, line selection by click,
  5 * drag, or shift+click with #t-N / #t-N-t-M links, and the
  6 * "copy link / copy line" popup on the selected line number.
  7 */
  8export const initSrcView = (signal) => {
  9  const meta = $(".src-view__meta");
 10  const code = $(".src-view__code code");
 11  if (!meta || !code || meta.dataset.copyWired) return;
 12  meta.dataset.copyWired = "true";
 13
 14  // Reads the code from the .cl spans so the line-number column doesn't end
 15  // up in the clipboard.
 16  const getText = () =>
 17    [...code.querySelectorAll(".cl")].map((l) => l.innerText).join("") || code.innerText;
 18
 19  const btn = document.createElement("button");
 20  btn.type = "button";
 21  btn.className = "src-view__copy";
 22  btn.textContent = "copy";
 23  btn.setAttribute("aria-label", "Copy file contents to clipboard");
 24  btn.addEventListener("click", async () => {
 25    try {
 26      await navigator.clipboard.writeText(getText());
 27      btn.textContent = "copied";
 28    } catch (_) {
 29      btn.textContent = "failed";
 30    }
 31    setTimeout(() => { btn.textContent = "copy"; }, 1500);
 32  }, { signal });
 33
 34  meta.append(btn);
 35
 36  // Strip hrefs from Chroma's line-number anchors so hovering shows no URL
 37  // preview; the ids stay so incoming #t-N links still scroll natively.
 38  code.querySelectorAll(".lnlinks").forEach((a) => a.removeAttribute("href"));
 39
 40  const lines = [...code.querySelectorAll(".line")];
 41  const selectRange = (from, to) => {
 42    code.querySelectorAll(".line.is-selected").forEach((l) => l.classList.remove("is-selected"));
 43    for (let i = from; i <= to; i++) lines[i - 1]?.classList.add("is-selected");
 44  };
 45  let anchorNum = null; // start of a shift-click range
 46
 47  // Incoming #t-5 or #t-5-t-10 → highlight; ranges have no matching element
 48  // id, so the browser won't scroll to them on its own.
 49  const m = location.hash.match(/^#t-(\d+)(?:-t-(\d+))?$/);
 50  if (m) {
 51    const from = Math.min(+m[1], +(m[2] ?? m[1]));
 52    const to = Math.max(+m[1], +(m[2] ?? m[1]));
 53    selectRange(from, to);
 54    anchorNum = from;
 55    if (m[2]) lines[from - 1]?.scrollIntoView();
 56  }
 57
 58  // clicking anywhere but a line number (code text included) clears the
 59  // selection and drops the #t-… fragment; popup and "copy" are exempt, and
 60  // so is the click that lands right after a drag-selection ends
 61  document.addEventListener("click", (e) => {
 62    if (e.target.closest(".ln, .src-linktip, .src-view__copy")) return;
 63    if (Date.now() - dragEndAt < 300) return;
 64    if (!code.querySelector(".line.is-selected")) return;
 65    selectRange(1, 0); // empty range = clear
 66    anchorNum = null;
 67    history.pushState(null, "", location.pathname);
 68  }, { signal });
 69
 70  const showTip = (ln, hash) => {
 71    if (!ln) return;
 72    $(".src-linktip")?.remove();
 73    const shownAt = Date.now();
 74
 75    const tip = document.createElement("span");
 76    tip.className = "src-linktip";
 77    let used = false;
 78
 79    // per-tip listeners die with the tip (or with the page, whichever first)
 80    const tipCtl = new AbortController();
 81    const tipSignal = AbortSignal.any([signal, tipCtl.signal]);
 82    const dismiss = () => {
 83      tipCtl.abort();
 84      tip.classList.add("is-hiding");
 85      setTimeout(() => tip.remove(), 350);
 86    };
 87
 88    const makeAction = (label, getText) => {
 89      const b = document.createElement("button");
 90      b.type = "button";
 91      b.textContent = label;
 92      b.addEventListener("click", async (ev) => {
 93        ev.stopPropagation();
 94        used = true;
 95        try {
 96          await navigator.clipboard.writeText(getText());
 97          b.textContent = "copied!";
 98        } catch (_) {
 99          b.textContent = "failed";
100        }
101        setTimeout(dismiss, 900);
102      }, { signal: tipSignal });
103      return b;
104    };
105
106    tip.append(
107      makeAction("copy link", () => location.origin + location.pathname + hash),
108      makeAction(hash.includes("-t-", 1) ? "copy lines" : "copy line", () =>
109        [...code.querySelectorAll(".line.is-selected .cl")]
110          .map((c) => c.innerText).join("").replace(/\n$/, "")),
111    );
112
113    // fixed-positioned on body so the code block's overflow can't clip it;
114    // above the number, or below when too close to the viewport top
115    const r = ln.getBoundingClientRect();
116    tip.style.left = `${r.left}px`;
117    if (r.top > 60) {
118      tip.style.top = `${r.top - 6}px`;
119      tip.style.transform = "translateY(-100%)";
120    } else {
121      tip.style.top = `${r.bottom + 6}px`;
122    }
123    // fade in after a short delay
124    tip.style.opacity = "0";
125    document.body.appendChild(tip);
126    setTimeout(() => { tip.style.opacity = ""; }, 200);
127    window.addEventListener("scroll", dismiss, { once: true, signal: tipSignal });
128    // click anywhere outside (and not on another line number) → fade out;
129    // the click that ends a drag fires right after mouseup, so ignore it
130    document.addEventListener("click", (ev) => {
131      if (Date.now() - shownAt < 300) return;
132      if (!tip.contains(ev.target) && !ev.target.closest(".ln")) dismiss();
133    }, { signal: tipSignal });
134    setTimeout(() => { if (!used) dismiss(); }, 3000);
135  };
136
137  // Click selects a line; dragging across numbers (or shift+click) selects a
138  // range. The hash and popup are applied on release.
139  //
140  // Pointer Events (not mouse+touch separately): on touch, a captured
141  // pointer keeps e.target pinned to the element where the drag started, so
142  // "which line is under the finger now" has to come from the coordinates
143  // (elementFromPoint), not e.target - that's true on move for both input
144  // types here, so one code path covers mouse and touch alike.
145  let dragFrom = null;
146  let dragTo = null;
147  let dragEndAt = 0;
148
149  const lineAt = (x, y) => document.elementFromPoint(x, y)?.closest(".ln");
150
151  code.addEventListener("pointerdown", (e) => {
152    if (!e.isPrimary || (e.pointerType === "mouse" && e.button !== 0)) return;
153    const ln = e.target.closest(".ln");
154    if (!ln?.id) return;
155    // no native text selection / long-press callout while dragging
156    e.preventDefault();
157    const num = +ln.id.slice(2);
158    dragFrom = e.shiftKey && anchorNum !== null ? anchorNum : num;
159    dragTo = num;
160    selectRange(Math.min(dragFrom, dragTo), Math.max(dragFrom, dragTo));
161  }, { signal });
162
163  code.addEventListener("pointermove", (e) => {
164    if (dragFrom === null) return;
165    const ln = lineAt(e.clientX, e.clientY);
166    if (!ln?.id) return;
167    dragTo = +ln.id.slice(2);
168    selectRange(Math.min(dragFrom, dragTo), Math.max(dragFrom, dragTo));
169  }, { signal });
170
171  const endDrag = () => {
172    if (dragFrom === null) return;
173    const from = Math.min(dragFrom, dragTo);
174    const to = Math.max(dragFrom, dragTo);
175    anchorNum = dragFrom;
176    const hash = from === to ? `#t-${from}` : `#t-${from}-t-${to}`;
177    // pushState instead of location.hash: no scroll jump. :target won't
178    // update this way, so the highlight is a class instead.
179    history.pushState(null, "", hash);
180    showTip(lines[dragTo - 1]?.querySelector(".ln"), hash);
181    dragFrom = null;
182    dragEndAt = Date.now();
183  };
184  window.addEventListener("pointerup", endDrag, { signal });
185  window.addEventListener("pointercancel", endDrag, { signal });
186};