1import { $, asElement } from "./dom";
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: AbortSignal): void => {
9 const meta = $<HTMLElement>(".src-view__meta");
10 const code = $<HTMLElement>(".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<HTMLElement>(".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: number, to: number) => {
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: number | null = 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 (asElement(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: Element | null | undefined, hash: string) => {
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: string, getText: () => string) => {
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<HTMLElement>(".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 const target = asElement(ev.target);
133 if (!(target && tip.contains(target)) && !target?.closest(".ln")) dismiss();
134 }, { signal: tipSignal });
135 setTimeout(() => { if (!used) dismiss(); }, 3000);
136 };
137
138 // Click selects a line; dragging across numbers (or shift+click) selects a
139 // range. The hash and popup are applied on release.
140 //
141 // Pointer Events (not mouse+touch separately): on touch, a captured
142 // pointer keeps e.target pinned to the element where the drag started, so
143 // "which line is under the finger now" has to come from the coordinates
144 // (elementFromPoint), not e.target - that's true on move for both input
145 // types here, so one code path covers mouse and touch alike.
146 let dragFrom: number | null = null;
147 let dragTo = 0; // only read while dragFrom is non-null, i.e. after pointerdown set it
148 let dragEndAt = 0;
149
150 const lineAt = (x: number, y: number) => document.elementFromPoint(x, y)?.closest(".ln");
151
152 code.addEventListener("pointerdown", (e) => {
153 if (!e.isPrimary || (e.pointerType === "mouse" && e.button !== 0)) return;
154 const ln = asElement(e.target)?.closest(".ln");
155 if (!ln?.id) return;
156 // no native text selection / long-press callout while dragging
157 e.preventDefault();
158 const num = +ln.id.slice(2);
159 dragFrom = e.shiftKey && anchorNum !== null ? anchorNum : num;
160 dragTo = num;
161 selectRange(Math.min(dragFrom, dragTo), Math.max(dragFrom, dragTo));
162 }, { signal });
163
164 code.addEventListener("pointermove", (e) => {
165 if (dragFrom === null) return;
166 const ln = lineAt(e.clientX, e.clientY);
167 if (!ln?.id) return;
168 dragTo = +ln.id.slice(2);
169 selectRange(Math.min(dragFrom, dragTo), Math.max(dragFrom, dragTo));
170 }, { signal });
171
172 const endDrag = () => {
173 if (dragFrom === null) return;
174 const from = Math.min(dragFrom, dragTo);
175 const to = Math.max(dragFrom, dragTo);
176 anchorNum = dragFrom;
177 const hash = from === to ? `#t-${from}` : `#t-${from}-t-${to}`;
178 // pushState instead of location.hash: no scroll jump. :target won't
179 // update this way, so the highlight is a class instead.
180 history.pushState(null, "", hash);
181 showTip(lines[dragTo - 1]?.querySelector(".ln"), hash);
182 dragFrom = null;
183 dragEndAt = Date.now();
184 };
185 window.addEventListener("pointerup", endDrag, { signal });
186 window.addEventListener("pointercancel", endDrag, { signal });
187};