Skip to content

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

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