1import { RefreshIcon } from "~/components/ui/refresh-icon";
2import {
3 ArrowLeft,
4 ArrowRight,
5 Camera,
6 ExternalLink,
7 MousePointerClick,
8 PictureInPicture2,
9} from "lucide-react";
10import {
11 type FormEvent,
12 type KeyboardEvent,
13 type ReactNode,
14 useEffect,
15 useRef,
16 useState,
17} from "react";
18
19import { Button } from "~/components/ui/button";
20import { InputGroup, InputGroupAddon, InputGroupInput } from "~/components/ui/input-group";
21import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
22import { cn } from "~/lib/utils";
23
24interface Props {
25 url: string;
26 loading: boolean;
27 canGoBack: boolean;
28 canGoForward: boolean;
29 refreshDisabled: boolean;
30 inputDisabled?: boolean | undefined;
31 /** Bumping this value re-focuses and selects the URL input. */
32 focusUrlNonce?: number | undefined;
33 onBack: () => void;
34 onForward: () => void;
35 onRefresh: () => void;
36 onSubmit: (url: string) => void;
37 /** When provided, renders an "Open in browser" affordance to the right. */
38 onOpenInBrowser?: (() => void) | undefined;
39 onCapture?: ((record: boolean) => void) | undefined;
40 captureDisabled?: boolean | undefined;
41 recording?: boolean | undefined;
42 onPictureInPicture?: (() => void) | undefined;
43 pictureInPicture?: boolean | undefined;
44 pictureInPictureDisabled?: boolean | undefined;
45 /**
46 * When provided, renders an annotation-mode toggle button to the right of
47 * the URL input. Pressed while annotation mode is active (button shows in `pressed`
48 * state). Disabled in `pickDisabled` mode.
49 */
50 onPickElement?: (() => void) | undefined;
51 pickActive?: boolean | undefined;
52 pickDisabled?: boolean | undefined;
53 /** Optional reason string surfaced in the disabled tooltip. */
54 pickDisabledReason?: string | undefined;
55 /**
56 * Trailing slot rendered after the URL input. Used by the preview view
57 * to mount the three-dot menu (hard reload, devtools, zoom, clear data).
58 */
59 trailingActions?: ReactNode;
60 /**
61 * Slot between the nav buttons and the URL input. The preview view uses it
62 * to name the tab's browser profile, which is otherwise invisible.
63 */
64 leadingActions?: ReactNode;
65}
66
67const NOOP = () => {};
68
69export function PreviewChromeRow({
70 url,
71 loading,
72 canGoBack,
73 canGoForward,
74 refreshDisabled,
75 inputDisabled,
76 focusUrlNonce,
77 onBack,
78 onForward,
79 onRefresh,
80 onSubmit,
81 onOpenInBrowser,
82 onCapture,
83 captureDisabled,
84 recording,
85 onPictureInPicture,
86 pictureInPicture,
87 pictureInPictureDisabled,
88 onPickElement,
89 pickActive,
90 pickDisabled,
91 pickDisabledReason,
92 trailingActions,
93 leadingActions,
94}: Props) {
95 const inputRef = useRef<HTMLInputElement | null>(null);
96 const [draft, setDraft] = useState(url);
97 const [inputFocused, setInputFocused] = useState(false);
98
99 useEffect(() => {
100 if (focusUrlNonce == null) return;
101 const node = inputRef.current;
102 if (!node) return;
103 node.focus();
104 }, [focusUrlNonce]);
105
106 const submit = (event?: FormEvent | KeyboardEvent) => {
107 event?.preventDefault();
108 const next = draft.trim();
109 if (next.length === 0) return;
110 onSubmit(next);
111 inputRef.current?.blur();
112 };
113
114 return (
115 <div className="relative">
116 <form
117 onSubmit={submit}
118 className="flex h-10 min-h-10 shrink-0 items-center gap-1 border-b border-border/60 bg-background px-2 in-data-[preview-panel-mode=inline]:mb-3 in-data-[preview-panel-mode=inline]:h-7 in-data-[preview-panel-mode=inline]:min-h-7 in-data-[preview-panel-mode=inline]:border-b-transparent"
119 data-surface-subheader
120 >
121 <div className="flex items-center gap-0.5" role="group" aria-label="Navigation">
122 <Tooltip>
123 <TooltipTrigger
124 render={
125 <Button
126 variant="ghost"
127 size="icon-xs"
128 onClick={canGoBack ? onBack : NOOP}
129 disabled={!canGoBack}
130 aria-label="Back"
131 type="button"
132 />
133 }
134 >
135 <ArrowLeft />
136 </TooltipTrigger>
137 <TooltipPopup>Back</TooltipPopup>
138 </Tooltip>
139 <Tooltip>
140 <TooltipTrigger
141 render={
142 <Button
143 variant="ghost"
144 size="icon-xs"
145 onClick={canGoForward ? onForward : NOOP}
146 disabled={!canGoForward}
147 aria-label="Forward"
148 type="button"
149 />
150 }
151 >
152 <ArrowRight />
153 </TooltipTrigger>
154 <TooltipPopup>Forward</TooltipPopup>
155 </Tooltip>
156 <Tooltip>
157 <TooltipTrigger
158 render={
159 <Button
160 variant="ghost"
161 size="icon-xs"
162 onClick={refreshDisabled ? NOOP : onRefresh}
163 disabled={refreshDisabled}
164 aria-label={loading ? "Stop" : "Refresh"}
165 type="button"
166 />
167 }
168 >
169 <RefreshIcon refreshing={loading} />
170 </TooltipTrigger>
171 <TooltipPopup>{loading ? "Loading…" : "Refresh"}</TooltipPopup>
172 </Tooltip>
173 </div>
174
175 {leadingActions}
176
177 <InputGroup variant="ghost" className="group/address h-7 flex-1">
178 <Tooltip>
179 <TooltipTrigger
180 render={
181 <InputGroupInput
182 ref={inputRef}
183 value={inputFocused ? draft : url}
184 onChange={(event) => setDraft(event.target.value)}
185 onFocus={() => {
186 setDraft(url);
187 setInputFocused(true);
188 queueMicrotask(() => inputRef.current?.select());
189 }}
190 onBlur={() => {
191 setInputFocused(false);
192 }}
193 onKeyDown={(event) => {
194 if (event.key === "Enter") submit(event);
195 if (event.key === "Escape") {
196 event.preventDefault();
197 setDraft(url);
198 inputRef.current?.blur();
199 }
200 }}
201 placeholder="Search or enter URL"
202 spellCheck={false}
203 disabled={inputDisabled}
204 data-preview-url-input
205 size="sm"
206 />
207 }
208 />
209 </Tooltip>
210 {onOpenInBrowser && !inputFocused ? (
211 <InputGroupAddon align="inline-end">
212 {/* Revealed on hover so a resting address bar reads as plain text. */}
213 <span className="pointer-events-none flex opacity-0 transition-opacity focus-within:pointer-events-auto focus-within:opacity-100 group-hover/address:pointer-events-auto group-hover/address:opacity-100">
214 <Tooltip>
215 <TooltipTrigger
216 render={
217 <Button
218 variant="ghost"
219 size="icon-xs"
220 onClick={onOpenInBrowser}
221 aria-label="Open in system browser"
222 type="button"
223 />
224 }
225 >
226 <ExternalLink />
227 </TooltipTrigger>
228 <TooltipPopup>Open in system browser</TooltipPopup>
229 </Tooltip>
230 </span>
231 </InputGroupAddon>
232 ) : null}
233 </InputGroup>
234
235 {onPickElement ? (
236 <Tooltip>
237 <TooltipTrigger
238 render={
239 <Button
240 variant={pickActive ? "secondary" : "ghost"}
241 size="icon-xs"
242 onClick={onPickElement}
243 disabled={pickDisabled}
244 aria-label={pickActive ? "Cancel annotation" : "Annotate preview"}
245 aria-pressed={pickActive ? "true" : "false"}
246 type="button"
247 />
248 }
249 >
250 <MousePointerClick className={cn(pickActive && "text-primary")} />
251 </TooltipTrigger>
252 <TooltipPopup>
253 {pickDisabled && pickDisabledReason
254 ? pickDisabledReason
255 : pickActive
256 ? "Cancel annotation (Esc)"
257 : "Annotate elements, regions, and drawings"}
258 </TooltipPopup>
259 </Tooltip>
260 ) : null}
261 {onCapture ? (
262 <Tooltip>
263 <TooltipTrigger
264 render={
265 <Button
266 variant={recording ? "secondary" : "ghost"}
267 size="icon-xs"
268 onClick={(event) => onCapture(event.shiftKey)}
269 aria-label={recording ? "Stop recording" : "Capture screenshot"}
270 type="button"
271 className="relative"
272 disabled={captureDisabled}
273 />
274 }
275 >
276 <Camera className={cn(recording && "text-destructive")} />
277 {recording ? (
278 <span className="absolute right-0.5 top-0.5 size-1.5 animate-status-pulse rounded-full bg-destructive" />
279 ) : null}
280 </TooltipTrigger>
281 <TooltipPopup>
282 {recording ? "Stop recording" : "Screenshot · Shift-click to record"}
283 </TooltipPopup>
284 </Tooltip>
285 ) : null}
286 {onPictureInPicture ? (
287 <Tooltip>
288 <TooltipTrigger
289 render={
290 <Button
291 variant={pictureInPicture ? "secondary" : "ghost"}
292 size="icon-xs"
293 onClick={onPictureInPicture}
294 aria-label={
295 pictureInPicture ? "Close floating preview" : "Float preview over chat"
296 }
297 aria-pressed={pictureInPicture ? "true" : "false"}
298 type="button"
299 disabled={pictureInPictureDisabled}
300 />
301 }
302 >
303 <PictureInPicture2 className={cn(pictureInPicture && "text-primary")} />
304 </TooltipTrigger>
305 <TooltipPopup>
306 {pictureInPicture ? "Close floating preview" : "Float preview over chat"}
307 </TooltipPopup>
308 </Tooltip>
309 ) : null}
310 {trailingActions}
311 </form>
312 <div
313 aria-hidden
314 data-loading={loading}
315 className="preview-loading-progress pointer-events-none absolute bottom-0 left-0 z-10 h-0.5 w-full origin-left rounded-r-full bg-primary"
316 style={{ boxShadow: "0 0 6px 1px var(--color-ring)" }}
317 />
318 </div>
319 );
320}