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 | 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 | |
| 78 | const 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 | }); |