Skip to content
Source
Sign inSign up

HtmlRenderDialog.tsx

108 lines4.0 KB

1import type { ScopedThreadRef } from "@t3tools/contracts";
2import {
3 HTML_RENDER_COLUMN_WIDTH,
4 htmlRenderFrameHeight,
5 type HtmlRenderReference,
6} from "@t3tools/shared/htmlRender";
7import { MinusIcon, PlusIcon, XIcon } from "lucide-react";
8import { useRef, useState } from "react";
9​
10import { observeResize } from "~/lib/observeResize";
11​
12import { HtmlRenderDocument } from "../files/BrowserDocumentFrame";
13import { Button } from "../ui/button";
14import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog";
15​
16export function HtmlRenderDialog(props: {
17 readonly src: string;
18 readonly htmlRender: HtmlRenderReference;
19 readonly threadRef?: ScopedThreadRef | undefined;
20 readonly onClose: () => void;
21}) {
22 const { title } = props.htmlRender;
23 const [zoom, setZoom] = useState(1);
24 const closeButtonRef = useRef<HTMLButtonElement>(null);
25 const [width, setWidth] = useState(HTML_RENDER_COLUMN_WIDTH);
26 // A callback ref, since the popup can mount after this component's first commit.
27 const measureBox = (box: HTMLDivElement) => {
28 setWidth(box.clientWidth);
29 return observeResize(box, ([entry]) => {
30 // Rounded like clientWidth, so the two writes agree at fractional widths.
31 if (entry) setWidth(Math.round(entry.contentRect.width));
32 });
33 };
34 const [contentHeight, setContentHeight] = useState<number>();
35 // Zoom divides the page's layout width, so the page reflows to fill the card
36 // at every level, and the frame is as tall as the page so only the card scrolls.
37 const height = htmlRenderFrameHeight(props.htmlRender, width / zoom, contentHeight);
38 return (
39 <Dialog open onOpenChange={(open) => !open && props.onClose()}>
40 <DialogPopup
41 variant="media"
42 showCloseButton={false}
43 bottomStickOnMobile={false}
44 className="row-start-1"
45 initialFocus={closeButtonRef}
46 >
47 <DialogTitle className="sr-only">Expanded {title} preview</DialogTitle>
48 {/* 1144px is the widest the server measures a page; pages are written for the reply column and stretch badly beyond it. */}
49 <div className="relative w-[min(92vw,1176px)]">
50 <Button
51 ref={closeButtonRef}
52 aria-label="Close"
53 size="icon-xs"
54 variant="media-close"
55 className="absolute right-0 -top-10 z-20"
56 onClick={props.onClose}
57 >
58 <XIcon />
59 </Button>
60 <div className="max-h-[min(86vh,calc(100vh-160px))] overflow-y-auto overscroll-contain rounded-lg bg-background p-4 shadow-2xl ring-1 ring-border/70">
61 <div ref={measureBox}>
62 <div style={{ zoom, height }}>
63 <HtmlRenderDocument
64 src={props.src}
65 title={title}
66 threadRef={props.threadRef}
67 className="block size-full"
68 onContentHeight={setContentHeight}
69 />
70 </div>
71 </div>
72 </div>
73 <div className="mt-2 flex min-w-0 items-center justify-center gap-3 text-xs text-white/80">
74 <span className="truncate">{title}</span>
75 <div className="flex shrink-0 items-center gap-1">
76 <Button
77 aria-label="Zoom out"
78 size="icon-sm"
79 variant="overlay"
80 disabled={zoom === 0.5}
81 onClick={() => setZoom((value) => value - 0.25)}
82 >
83 <MinusIcon />
84 </Button>
85 <Button
86 aria-label="Reset zoom"
87 size="sm"
88 variant="overlay"
89 onClick={() => setZoom(1)}
90 >
91 {Math.round(zoom * 100)}%
92 </Button>
93 <Button
94 aria-label="Zoom in"
95 size="icon-sm"
96 variant="overlay"
97 disabled={zoom === 2}
98 onClick={() => setZoom((value) => value + 0.25)}
99 >
100 <PlusIcon />
101 </Button>
102 </div>
103 </div>
104 </div>
105 </DialogPopup>
106 </Dialog>
107 );