g1t/apps/web/app/components/code-lines.tsx

106 lines3,652 bytesCodeBlame
1import { type MouseEvent, memo, useCallback, useEffect, useRef, useState } from "react";
2import { useLocation, useNavigationType } from "react-router";
3
4import { type LineRange, formatLineHash, inRange, parseLineHash, pickLine } from "../lib/line-anchor";
5
6/**
7 * A file's lines, each with a number that links to it. `#L12` and
8 * `#L10-L20` in the address scroll to and light up those lines, on arrival
9 * and on every navigation after. Picking a number puts it in the address;
10 * a shift-pick extends the run. Every row is rendered with `id="L<n>"` on
11 * the server, so a link lands on its line before the page's script runs.
12 */
13export function CodeLines({
14 lines,
15 html,
16}: {
17 lines: string[];
18 /** Highlighted HTML per line, when the language is known. */
19 html: string[] | null;
20}) {
21 const location = useLocation();
22 const navigationType = useNavigationType();
23 const [picked, setPicked] = useState<LineRange | null>(null);
24 const pickedRef = useRef(picked);
25 pickedRef.current = picked;
26 const arrived = useRef(false);
27
28 // The address names the lines. Runs after the router's own scroll
29 // restoration (a layout effect higher up), so it has the last word.
30 useEffect(() => {
31 const range = parseLineHash(location.hash, lines.length);
32 setPicked(range);
33 // Back and forward restore where the reader was; anything else goes to
34 // the lines.
35 const first = !arrived.current;
36 arrived.current = true;
37 if (range && (first || navigationType !== "POP")) {
38 document.getElementById(`L${range.start}`)?.scrollIntoView({ block: "start" });
39 }
40 }, [location.key, location.hash, location.pathname, lines.length, navigationType]);
41
42 const pick = useCallback((event: MouseEvent<HTMLAnchorElement>, line: number) => {
43 // A new tab or window is the browser's to open.
44 if (event.button !== 0 || event.metaKey || event.ctrlKey || event.altKey) return;
45 event.preventDefault();
46 if (event.shiftKey) window.getSelection()?.removeAllRanges();
47 const next = pickLine(pickedRef.current, line, event.shiftKey);
48 setPicked(next);
49 // The router's entry is kept, so back and forward still work; the page
50 // stays where it is.
51 window.history.replaceState(window.history.state, "", formatLineHash(next));
52 }, []);
53
54 return (
55 <div className="overflow-x-auto py-3">
56 <table className="w-full border-collapse font-mono text-sm leading-6">
57 <tbody>
58 {lines.map((text, i) => (
59 <Line
60 key={i}
61 n={i + 1}
62 text={text}
63 html={html?.[i] ?? null}
64 selected={inRange(picked, i + 1)}
65 onPick={pick}
66 />
67 ))}
68 </tbody>
69 </table>
70 </div>
71 );
72}
73
74const Line = memo(function Line({
75 n,
76 text,
77 html,
78 selected,
79 onPick,
80}: {
81 n: number;
82 text: string;
83 html: string | null;
84 selected: boolean;
85 onPick: (event: MouseEvent<HTMLAnchorElement>, line: number) => void;
86}) {
87 return (
88 <tr id={`L${n}`} data-selected={selected || undefined} className={`scroll-mt-24 ${selected ? "bg-accent/10" : ""}`}>
89 <td className="w-px p-0 text-right align-top select-none">
90 <a
91 href={`#L${n}`}
92 tabIndex={-1}
93 onClick={(event) => onPick(event, n)}
94 className={`block min-w-14 pr-5 pl-4 transition-colors ${
95 selected ? "text-accent" : "text-faint hover:text-fg"
96 }`}
97 >
98 {n}
99 </a>
100 </td>
101 <td className="pr-4 whitespace-pre">
102 {html != null ? <span dangerouslySetInnerHTML={{ __html: html || " " }} /> : text || " "}
103 </td>
104 </tr>
105 );
106});