| 58 | 58 | | import { accessTo, refusal } from "../../lib/access.server"; |
| 59 | 59 | | import { useRefreshWhile } from "../../lib/refresh"; |
| 60 | 60 | | import { runControls } from "../../lib/run-controls"; |
| 61 | + | import { endPlace } from "../../lib/log-follow"; |
| 61 | 62 | | import { type GraphJob, type Unit, groupJobs, jobsOf, unitStanding } from "../../lib/run-graph"; |
| 62 | 63 | | import { repoPages } from "../../lib/repo-live"; |
| 63 | 64 | | import { useRepoLive } from "../../lib/use-repo-live"; |
| ⋯ |
| 198 | 199 | | {quiet} |
| 199 | 200 | | </p> |
| 200 | 201 | | )} |
| 201 | | − | {running && <div ref={end} aria-hidden className="h-px" />} |
| 202 | + | {/* The end of the log: kept clear of what covers the window's bottom (a phone's tab bar), with room under the last line. */} |
| 203 | + | {running && <div ref={end} aria-hidden className="h-px scroll-mb-[calc(var(--tabbar-h)+0.75rem)]" />} |
| 202 | 204 | | </div> |
| 203 | 205 | | </details> |
| 204 | 206 | | ); |
| ⋯ |
| 254 | 256 | | /** |
| 255 | 257 | | * Following the running step's log as it grows, as GitHub does: while the |
| 256 | 258 | | * end of it is in view, new lines keep it in view; once the viewer |
| 257 | | − | * scrolls up, it stays put and `behind` says there is more below. |
| 259 | + | * scrolls up, it stays put and `behind` says there is more below. The end |
| 260 | + | * is in view only with the last line wholly above whatever covers the |
| 261 | + | * window's bottom (lib/log-follow.ts), and following pins it there again |
| 262 | + | * once the new lines have been laid out. |
| 258 | 263 | | */ |
| 259 | 264 | | function useFollow(running: number | null, grew: unknown): { end: RefObject<HTMLDivElement | null>; behind: boolean; jump: () => void } { |
| 260 | 265 | | const end = useRef<HTMLDivElement | null>(null); |
| 261 | 266 | | const following = useRef(false); |
| 267 | + | // Jump to latest's smooth scroll is on its way: the scrolls it makes are |
| 268 | + | // not the viewer scrolling up. |
| 269 | + | const jumping = useRef(false); |
| 262 | 270 | | const [behind, setBehind] = useState(false); |
| 263 | | − | // Where the end of the log is: folded away (a closed step draws nothing), |
| 264 | | − | // on screen or nearly (the viewer is reading the latest), or below. |
| 265 | | − | const where = (): "folded" | "here" | "below" => { |
| 266 | | − | const box = end.current?.getBoundingClientRect(); |
| 267 | | − | if (!box || (box.width === 0 && box.height === 0)) return "folded"; |
| 268 | | − | return box.top <= window.innerHeight + 160 ? "here" : "below"; |
| 271 | + | const where = () => { |
| 272 | + | const marker = end.current; |
| 273 | + | const covered = marker ? parseFloat(getComputedStyle(marker).scrollMarginBottom) || 0 : 0; |
| 274 | + | return endPlace(marker?.getBoundingClientRect() ?? null, { height: window.innerHeight, covered }); |
| 275 | + | }; |
| 276 | + | // The end of the log at the bottom of what can be seen, now and once |
| 277 | + | // anything still settling (a long log's rows being measured, the Jump to |
| 278 | + | // latest bar going away) has been laid out. |
| 279 | + | const pin = () => { |
| 280 | + | end.current?.scrollIntoView({ block: "end" }); |
| 281 | + | requestAnimationFrame(() => { |
| 282 | + | if (following.current && where() !== "folded") end.current?.scrollIntoView({ block: "end" }); |
| 283 | + | }); |
| 269 | 284 | | }; |
| 270 | 285 | | useEffect(() => { |
| 271 | 286 | | if (running === null) { |
| ⋯ |
| 275 | 290 | | } |
| 276 | 291 | | // The viewer moving decides: at the end, follow; scrolled up, stay. |
| 277 | 292 | | const moved = () => { |
| 293 | + | if (jumping.current) return; |
| 278 | 294 | | const at = where(); |
| 279 | 295 | | following.current = at === "here"; |
| 280 | 296 | | setBehind(at === "below"); |
| 281 | 297 | | }; |
| 282 | | − | // A step folded or opened moves nothing the viewer chose; it only |
| 283 | | − | // changes whether there is anything below to jump to. |
| 298 | + | // A step folded or opened, or the window resized (a phone's address |
| 299 | + | // bar or keyboard), moves nothing the viewer chose: a log followed |
| 300 | + | // stays at its end, and whether there is anything below to jump to may |
| 301 | + | // change. |
| 284 | 302 | | const toggled = () => { |
| 285 | 303 | | const at = where(); |
| 286 | 304 | | if (at === "folded") following.current = false; |
| 305 | + | else if (following.current && at === "below" && !jumping.current) { |
| 306 | + | pin(); |
| 307 | + | return; |
| 308 | + | } |
| 287 | 309 | | setBehind(at === "below"); |
| 288 | 310 | | }; |
| 289 | 311 | | window.addEventListener("scroll", moved, { passive: true }); |
| ⋯ |
| 298 | 320 | | useLayoutEffect(() => { |
| 299 | 321 | | if (running === null || !end.current) return; |
| 300 | 322 | | if (following.current && where() !== "folded") { |
| 301 | | − | end.current.scrollIntoView({ block: "end" }); |
| 323 | + | pin(); |
| 302 | 324 | | return; |
| 303 | 325 | | } |
| 304 | 326 | | // Not following: once there is a log and its end is on screen, follow |
| ⋯ |
| 308 | 330 | | if (grew && at === "here") following.current = true; |
| 309 | 331 | | setBehind(at === "below"); |
| 310 | 332 | | }, [grew, running]); |
| 333 | + | // Smoothly there, then pinned where the end is once the scroll is over: |
| 334 | + | // lines that arrived on the way, and the bar this button sits in going |
| 335 | + | // away, move it. |
| 311 | 336 | | const jump = () => { |
| 312 | 337 | | following.current = true; |
| 313 | | − | end.current?.scrollIntoView({ block: "end", behavior: "smooth" }); |
| 338 | + | const marker = end.current; |
| 339 | + | if (!marker) return; |
| 340 | + | jumping.current = true; |
| 341 | + | setBehind(false); |
| 342 | + | let settled = false; |
| 343 | + | const settle = () => { |
| 344 | + | if (settled) return; |
| 345 | + | settled = true; |
| 346 | + | jumping.current = false; |
| 347 | + | window.removeEventListener("scrollend", settle); |
| 348 | + | if (following.current && where() !== "folded") pin(); |
| 349 | + | }; |
| 350 | + | window.addEventListener("scrollend", settle); |
| 351 | + | // A browser without `scrollend`, or nothing left to scroll. |
| 352 | + | window.setTimeout(settle, 800); |
| 353 | + | marker.scrollIntoView({ block: "end", behavior: "smooth" }); |
| 314 | 354 | | }; |
| 315 | 355 | | return { end, behind, jump }; |
| 316 | 356 | | } |
| ⋯ |
| 651 | 691 | | ))} |
| 652 | 692 | | </Card> |
| 653 | 693 | | {behind && !searching && ( |
| 654 | | − | <div className="pointer-events-none sticky bottom-4 z-10 flex justify-center"> |
| 694 | + | <div className="pointer-events-none sticky bottom-[calc(var(--tabbar-h)+1rem)] z-10 flex justify-center"> |
| 655 | 695 | | <Button variant="secondary" size="sm" onClick={jump} className="pointer-events-auto shadow-md ring-1 ring-line"> |
| 656 | 696 | | <ArrowDown size={14} /> |
| 657 | 697 | | Jump to latest |