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};