import { ArrowLeft, ArrowRight, Camera, ExternalLink, MousePointerClick, RotateCw,} from "lucide-react";import { type FormEvent, type KeyboardEvent, type ReactNode, useEffect, useRef, useState,} from "react"; import { Button } from "~/components/ui/button";import { InputGroup, InputGroupAddon, InputGroupInput } from "~/components/ui/input-group";import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";import { cn } from "~/lib/utils"; interface Props { url: string; displayUrl?: string | undefined; loading: boolean; loadProgress: number; canGoBack: boolean; canGoForward: boolean; refreshDisabled: boolean; inputDisabled?: boolean | undefined; /** Bumping this value re-focuses and selects the URL input. */ focusUrlNonce?: number | undefined; onBack: () => void; onForward: () => void; onRefresh: () => void; onSubmit: (url: string) => void; /** When provided, renders an "Open in browser" affordance to the right. */ onOpenInBrowser?: (() => void) | undefined; onCapture?: ((record: boolean) => void) | undefined; captureDisabled?: boolean | undefined; recording?: boolean | undefined; /** * When provided, renders an annotation-mode toggle button to the right of * the URL input. Pressed while annotation mode is active (button shows in `pressed` * state). Disabled in `pickDisabled` mode. */ onPickElement?: (() => void) | undefined; pickActive?: boolean | undefined; pickDisabled?: boolean | undefined; /** Optional reason string surfaced in the disabled tooltip. */ pickDisabledReason?: string | undefined; /** * Trailing slot rendered after the URL input. Used by the preview view * to mount the three-dot menu (hard reload, devtools, zoom, clear data). */ trailingActions?: ReactNode;} const NOOP = () => {}; export function PreviewChromeRow({ url, displayUrl, loading, loadProgress, canGoBack, canGoForward, refreshDisabled, inputDisabled, focusUrlNonce, onBack, onForward, onRefresh, onSubmit, onOpenInBrowser, onCapture, captureDisabled, recording, onPickElement, pickActive, pickDisabled, pickDisabledReason, trailingActions,}: Props) { const inputRef = useRef<HTMLInputElement | null>(null); const [draft, setDraft] = useState(url); const [inputFocused, setInputFocused] = useState(false); useEffect(() => { if (focusUrlNonce == null) return; const node = inputRef.current; if (!node) return; node.focus(); }, [focusUrlNonce]); const submit = (event?: FormEvent | KeyboardEvent) => { event?.preventDefault(); const next = draft.trim(); if (next.length === 0) return; onSubmit(next); inputRef.current?.blur(); }; return ( <div className="relative"> <form onSubmit={submit} className="surface-subheader gap-1 px-2" data-surface-subheader> <div className="flex items-center gap-0.5" role="group" aria-label="Navigation"> <Tooltip> <TooltipTrigger render={ <Button variant="ghost" size="icon-xs" onClick={canGoBack ? onBack : NOOP} disabled={!canGoBack} aria-label="Back" type="button" /> } > <ArrowLeft /> </TooltipTrigger> <TooltipPopup>Back</TooltipPopup> </Tooltip> <Tooltip> <TooltipTrigger render={ <Button variant="ghost" size="icon-xs" onClick={canGoForward ? onForward : NOOP} disabled={!canGoForward} aria-label="Forward" type="button" /> } > <ArrowRight /> </TooltipTrigger> <TooltipPopup>Forward</TooltipPopup> </Tooltip> <Tooltip> <TooltipTrigger render={ <Button variant="ghost" size="icon-xs" onClick={refreshDisabled ? NOOP : onRefresh} disabled={refreshDisabled} aria-label={loading ? "Stop" : "Refresh"} type="button" /> } > <RotateCw className={cn(loading && "animate-spin")} /> </TooltipTrigger> <TooltipPopup>{loading ? "Loading…" : "Refresh"}</TooltipPopup> </Tooltip> </div> <InputGroup className="group/address h-7 flex-1 rounded-md border-transparent bg-transparent shadow-none before:shadow-none hover:bg-muted/40 focus-within:bg-background"> <Tooltip> <TooltipTrigger render={ <InputGroupInput ref={inputRef} value={inputFocused ? draft : (displayUrl ?? url)} className={cn( onOpenInBrowser && !inputFocused && "group-hover/address:pe-7 transition-[padding]", )} onChange={(event) => setDraft(event.target.value)} onFocus={() => { setDraft(url); setInputFocused(true); queueMicrotask(() => inputRef.current?.select()); }} onBlur={() => { setInputFocused(false); }} onKeyDown={(event) => { if (event.key === "Enter") submit(event); if (event.key === "Escape") { event.preventDefault(); setDraft(url); inputRef.current?.blur(); } }} placeholder="Search or enter URL" spellCheck={false} disabled={inputDisabled} data-preview-url-input size="sm" /> } /> {!inputFocused && displayUrl ? <TooltipPopup>{url}</TooltipPopup> : null} </Tooltip> {onOpenInBrowser && !inputFocused ? ( <InputGroupAddon align="inline-end" className="pointer-events-none absolute inset-y-0 right-0 opacity-0 transition-opacity group-hover/address:pointer-events-auto group-hover/address:opacity-100" > <Tooltip> <TooltipTrigger render={ <Button variant="ghost" size="icon-xs" onClick={onOpenInBrowser} aria-label="Open in system browser" type="button" /> } > <ExternalLink /> </TooltipTrigger> <TooltipPopup>Open in system browser</TooltipPopup> </Tooltip> </InputGroupAddon> ) : null} </InputGroup> {onPickElement ? ( <Tooltip> <TooltipTrigger render={ <Button variant={pickActive ? "secondary" : "ghost"} size="icon-xs" onClick={onPickElement} disabled={pickDisabled} aria-label={pickActive ? "Cancel annotation" : "Annotate preview"} aria-pressed={pickActive ? "true" : "false"} type="button" /> } > <MousePointerClick className={cn(pickActive && "text-primary")} /> </TooltipTrigger> <TooltipPopup> {pickDisabled && pickDisabledReason ? pickDisabledReason : pickActive ? "Cancel annotation (Esc)" : "Annotate elements, regions, and drawings"} </TooltipPopup> </Tooltip> ) : null} {onCapture ? ( <Tooltip> <TooltipTrigger render={ <Button variant={recording ? "secondary" : "ghost"} size="icon-xs" onClick={(event) => onCapture(event.shiftKey)} aria-label={recording ? "Stop recording" : "Capture screenshot"} type="button" className="relative" disabled={captureDisabled} /> } > <Camera className={cn(recording && "text-destructive")} /> {recording ? ( <span className="absolute right-0.5 top-0.5 size-1.5 animate-pulse rounded-full bg-destructive" /> ) : null} </TooltipTrigger> <TooltipPopup> {recording ? "Stop recording" : "Screenshot · Shift-click to record"} </TooltipPopup> </Tooltip> ) : null} {trailingActions} </form> {loadProgress > 0 ? ( <div aria-hidden className="pointer-events-none absolute bottom-0 left-0 z-10 h-0.5 rounded-r-full bg-primary transition-all duration-150 ease-out" style={{ width: `${loadProgress}%`, boxShadow: "0 0 6px 1px var(--color-ring)", }} /> ) : null} </div> );}
import {
ArrowLeft,
ArrowRight,
Camera,
ExternalLink,
MousePointerClick,
RotateCw,
} from "lucide-react";
import {
type FormEvent,
type KeyboardEvent,
type ReactNode,
useEffect,
useRef,
useState,
} from "react";
import { Button } from "~/components/ui/button";
import { InputGroup, InputGroupAddon, InputGroupInput } from "~/components/ui/input-group";
import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
import { cn } from "~/lib/utils";
interface Props {
url: string;
displayUrl?: string | undefined;
loading: boolean;
loadProgress: number;
canGoBack: boolean;
canGoForward: boolean;
refreshDisabled: boolean;
inputDisabled?: boolean | undefined;
/** Bumping this value re-focuses and selects the URL input. */
focusUrlNonce?: number | undefined;
onBack: () => void;
onForward: () => void;
onRefresh: () => void;
onSubmit: (url: string) => void;
/** When provided, renders an "Open in browser" affordance to the right. */
onOpenInBrowser?: (() => void) | undefined;
onCapture?: ((record: boolean) => void) | undefined;
captureDisabled?: boolean | undefined;
recording?: boolean | undefined;
/**
* When provided, renders an annotation-mode toggle button to the right of
* the URL input. Pressed while annotation mode is active (button shows in `pressed`
* state). Disabled in `pickDisabled` mode.
*/
onPickElement?: (() => void) | undefined;
pickActive?: boolean | undefined;
pickDisabled?: boolean | undefined;
/** Optional reason string surfaced in the disabled tooltip. */
pickDisabledReason?: string | undefined;
/**
* Trailing slot rendered after the URL input. Used by the preview view
* to mount the three-dot menu (hard reload, devtools, zoom, clear data).
*/
trailingActions?: ReactNode;
}
const NOOP = () => {};
export function PreviewChromeRow({
url,
displayUrl,
loading,
loadProgress,
canGoBack,
canGoForward,
refreshDisabled,
inputDisabled,
focusUrlNonce,
onBack,
onForward,
onRefresh,
onSubmit,
onOpenInBrowser,
onCapture,
captureDisabled,
recording,
onPickElement,
pickActive,
pickDisabled,
pickDisabledReason,
trailingActions,
}: Props) {
const inputRef = useRef<HTMLInputElement | null>(null);
const [draft, setDraft] = useState(url);
const [inputFocused, setInputFocused] = useState(false);
useEffect(() => {
if (focusUrlNonce == null) return;
const node = inputRef.current;
if (!node) return;
node.focus();
}, [focusUrlNonce]);
const submit = (event?: FormEvent | KeyboardEvent) => {
event?.preventDefault();
const next = draft.trim();
if (next.length === 0) return;
onSubmit(next);
inputRef.current?.blur();
};
return (
<div className="relative">
<form onSubmit={submit} className="surface-subheader gap-1 px-2" data-surface-subheader>
<div className="flex items-center gap-0.5" role="group" aria-label="Navigation">
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-xs"
onClick={canGoBack ? onBack : NOOP}
disabled={!canGoBack}
aria-label="Back"
type="button"
/>
}
>
<ArrowLeft />
</TooltipTrigger>
<TooltipPopup>Back</TooltipPopup>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-xs"
onClick={canGoForward ? onForward : NOOP}
disabled={!canGoForward}
aria-label="Forward"
type="button"
/>
}
>
<ArrowRight />
</TooltipTrigger>
<TooltipPopup>Forward</TooltipPopup>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-xs"
onClick={refreshDisabled ? NOOP : onRefresh}
disabled={refreshDisabled}
aria-label={loading ? "Stop" : "Refresh"}
type="button"
/>
}
>
<RotateCw className={cn(loading && "animate-spin")} />
</TooltipTrigger>
<TooltipPopup>{loading ? "Loading…" : "Refresh"}</TooltipPopup>
</Tooltip>
</div>
<InputGroup className="group/address h-7 flex-1 rounded-md border-transparent bg-transparent shadow-none before:shadow-none hover:bg-muted/40 focus-within:bg-background">
<Tooltip>
<TooltipTrigger
render={
<InputGroupInput
ref={inputRef}
value={inputFocused ? draft : (displayUrl ?? url)}
className={cn(
onOpenInBrowser &&
!inputFocused &&
"group-hover/address:pe-7 transition-[padding]",
)}
onChange={(event) => setDraft(event.target.value)}
onFocus={() => {
setDraft(url);
setInputFocused(true);
queueMicrotask(() => inputRef.current?.select());
}}
onBlur={() => {
setInputFocused(false);
}}
onKeyDown={(event) => {
if (event.key === "Enter") submit(event);
if (event.key === "Escape") {
event.preventDefault();
setDraft(url);
inputRef.current?.blur();
}
}}
placeholder="Search or enter URL"
spellCheck={false}
disabled={inputDisabled}
data-preview-url-input
size="sm"
/>
}
/>
{!inputFocused && displayUrl ? <TooltipPopup>{url}</TooltipPopup> : null}
</Tooltip>
{onOpenInBrowser && !inputFocused ? (
<InputGroupAddon
align="inline-end"
className="pointer-events-none absolute inset-y-0 right-0 opacity-0 transition-opacity group-hover/address:pointer-events-auto group-hover/address:opacity-100"
>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-xs"
onClick={onOpenInBrowser}
aria-label="Open in system browser"
type="button"
/>
}
>
<ExternalLink />
</TooltipTrigger>
<TooltipPopup>Open in system browser</TooltipPopup>
</Tooltip>
</InputGroupAddon>
) : null}
</InputGroup>
{onPickElement ? (
<Tooltip>
<TooltipTrigger
render={
<Button
variant={pickActive ? "secondary" : "ghost"}
size="icon-xs"
onClick={onPickElement}
disabled={pickDisabled}
aria-label={pickActive ? "Cancel annotation" : "Annotate preview"}
aria-pressed={pickActive ? "true" : "false"}
type="button"
/>
}
>
<MousePointerClick className={cn(pickActive && "text-primary")} />
</TooltipTrigger>
<TooltipPopup>
{pickDisabled && pickDisabledReason
? pickDisabledReason
: pickActive
? "Cancel annotation (Esc)"
: "Annotate elements, regions, and drawings"}
</TooltipPopup>
</Tooltip>
) : null}
{onCapture ? (
<Tooltip>
<TooltipTrigger
render={
<Button
variant={recording ? "secondary" : "ghost"}
size="icon-xs"
onClick={(event) => onCapture(event.shiftKey)}
aria-label={recording ? "Stop recording" : "Capture screenshot"}
type="button"
className="relative"
disabled={captureDisabled}
/>
}
>
<Camera className={cn(recording && "text-destructive")} />
{recording ? (
<span className="absolute right-0.5 top-0.5 size-1.5 animate-pulse rounded-full bg-destructive" />
) : null}
</TooltipTrigger>
<TooltipPopup>
{recording ? "Stop recording" : "Screenshot · Shift-click to record"}
</TooltipPopup>
</Tooltip>
) : null}
{trailingActions}
</form>
{loadProgress > 0 ? (
<div
aria-hidden
className="pointer-events-none absolute bottom-0 left-0 z-10 h-0.5 rounded-r-full bg-primary transition-all duration-150 ease-out"
style={{
width: `${loadProgress}%`,
boxShadow: "0 0 6px 1px var(--color-ring)",
}}
/>
) : null}
</div>
);
}