g1t/apps/web/app/components/code-lines.tsx
| 1 | import { type MouseEvent, memo, useCallback, useEffect, useRef, useState } from "react"; |
| 2 | import { useLocation, useNavigationType } from "react-router"; |
| 3 | |
| 4 | import { 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 | */ |
| 13 | export 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 | |
| 74 | const 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 | }); |