| 1 | import { RefreshIcon } from "~/components/ui/refresh-icon"; |
| 2 | import { |
| 3 | ArrowLeft, |
| 4 | ArrowRight, |
| 5 | Camera, |
| 6 | ExternalLink, |
| 7 | MousePointerClick, |
| 8 | PictureInPicture2, |
| 9 | } from "lucide-react"; |
| 10 | import { |
| 11 | type FormEvent, |
| 12 | type KeyboardEvent, |
| 13 | type ReactNode, |
| 14 | useEffect, |
| 15 | useRef, |
| 16 | useState, |
| 17 | } from "react"; |
| 18 | |
| 19 | import { Button } from "~/components/ui/button"; |
| 20 | import { InputGroup, InputGroupAddon, InputGroupInput } from "~/components/ui/input-group"; |
| 21 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; |
| 22 | import { cn } from "~/lib/utils"; |
| 23 | |
| 24 | interface 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 | |
| 67 | const NOOP = () => {}; |
| 68 | |
| 69 | export 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 | } |