
- Implement WebviewPreview component with browser-like navigation - Add headless Chrome integration for capturing screenshots - Create split-pane component for side-by-side layout - Add dialog for URL detection prompts Allows users to preview web applications and capture screenshots directly into Claude prompts for better context sharing.
439 lines
14 KiB
TypeScript
439 lines
14 KiB
TypeScript
import React, { useState, useRef, useEffect } from "react";
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
import {
|
|
ArrowLeft,
|
|
ArrowRight,
|
|
RefreshCw,
|
|
X,
|
|
Minimize2,
|
|
Maximize2,
|
|
Camera,
|
|
Loader2,
|
|
AlertCircle,
|
|
Globe,
|
|
Home,
|
|
} from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
import { cn } from "@/lib/utils";
|
|
import { api } from "@/lib/api";
|
|
// TODO: These imports will be used when implementing actual Tauri webview
|
|
// import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
|
// import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
|
|
|
|
interface WebviewPreviewProps {
|
|
/**
|
|
* Initial URL to load
|
|
*/
|
|
initialUrl: string;
|
|
/**
|
|
* Callback when close is clicked
|
|
*/
|
|
onClose: () => void;
|
|
/**
|
|
* Callback when screenshot is requested
|
|
*/
|
|
onScreenshot?: (imagePath: string) => void;
|
|
/**
|
|
* Whether the webview is maximized
|
|
*/
|
|
isMaximized?: boolean;
|
|
/**
|
|
* Callback when maximize/minimize is clicked
|
|
*/
|
|
onToggleMaximize?: () => void;
|
|
/**
|
|
* Callback when URL changes
|
|
*/
|
|
onUrlChange?: (url: string) => void;
|
|
/**
|
|
* Optional className for styling
|
|
*/
|
|
className?: string;
|
|
}
|
|
|
|
/**
|
|
* WebviewPreview component - Browser-like webview with navigation controls
|
|
*
|
|
* @example
|
|
* <WebviewPreview
|
|
* initialUrl="http://localhost:3000"
|
|
* onClose={() => setShowPreview(false)}
|
|
* onScreenshot={(path) => attachImage(path)}
|
|
* />
|
|
*/
|
|
const WebviewPreviewComponent: React.FC<WebviewPreviewProps> = ({
|
|
initialUrl,
|
|
onClose,
|
|
onScreenshot,
|
|
isMaximized = false,
|
|
onToggleMaximize,
|
|
onUrlChange,
|
|
className,
|
|
}) => {
|
|
const [currentUrl, setCurrentUrl] = useState(initialUrl);
|
|
const [inputUrl, setInputUrl] = useState(initialUrl);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [hasError, setHasError] = useState(false);
|
|
const [errorMessage, setErrorMessage] = useState("");
|
|
// TODO: These will be implemented with actual webview navigation
|
|
// const [canGoBack, setCanGoBack] = useState(false);
|
|
// const [canGoForward, setCanGoForward] = useState(false);
|
|
const [isCapturing, setIsCapturing] = useState(false);
|
|
const [showShutterAnimation, setShowShutterAnimation] = useState(false);
|
|
|
|
// TODO: These will be used for actual Tauri webview implementation
|
|
// const webviewRef = useRef<WebviewWindow | null>(null);
|
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const contentRef = useRef<HTMLDivElement>(null);
|
|
// const previewId = useRef(`preview-${Date.now()}`);
|
|
|
|
// Handle ESC key to exit full screen
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape' && isMaximized && onToggleMaximize) {
|
|
onToggleMaximize();
|
|
}
|
|
};
|
|
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
}, [isMaximized, onToggleMaximize]);
|
|
|
|
// Debug: Log initial URL on mount
|
|
useEffect(() => {
|
|
console.log('[WebviewPreview] Component mounted with initialUrl:', initialUrl, 'isMaximized:', isMaximized);
|
|
}, []);
|
|
|
|
// Focus management for full screen mode
|
|
useEffect(() => {
|
|
if (isMaximized && containerRef.current) {
|
|
containerRef.current.focus();
|
|
}
|
|
}, [isMaximized]);
|
|
|
|
// For now, we'll use an iframe as a placeholder
|
|
// In the full implementation, this would create a Tauri webview window
|
|
useEffect(() => {
|
|
if (currentUrl) {
|
|
// This is where we'd create the actual webview
|
|
// For now, using iframe for demonstration
|
|
setIsLoading(true);
|
|
setHasError(false);
|
|
|
|
// Simulate loading
|
|
const timer = setTimeout(() => {
|
|
setIsLoading(false);
|
|
}, 1000);
|
|
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [currentUrl]);
|
|
|
|
const navigate = (url: string) => {
|
|
try {
|
|
// Validate URL
|
|
const urlObj = new URL(url.startsWith('http') ? url : `https://${url}`);
|
|
const finalUrl = urlObj.href;
|
|
|
|
console.log('[WebviewPreview] Navigating to:', finalUrl);
|
|
setCurrentUrl(finalUrl);
|
|
setInputUrl(finalUrl);
|
|
setHasError(false);
|
|
onUrlChange?.(finalUrl);
|
|
} catch (err) {
|
|
setHasError(true);
|
|
setErrorMessage("Invalid URL");
|
|
}
|
|
};
|
|
|
|
const handleNavigate = () => {
|
|
if (inputUrl.trim()) {
|
|
navigate(inputUrl);
|
|
}
|
|
};
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
if (e.key === 'Enter') {
|
|
handleNavigate();
|
|
}
|
|
};
|
|
|
|
const handleGoBack = () => {
|
|
// In real implementation, this would call webview.goBack()
|
|
console.log("Go back");
|
|
};
|
|
|
|
const handleGoForward = () => {
|
|
// In real implementation, this would call webview.goForward()
|
|
console.log("Go forward");
|
|
};
|
|
|
|
const handleRefresh = () => {
|
|
setIsLoading(true);
|
|
// In real implementation, this would call webview.reload()
|
|
setTimeout(() => setIsLoading(false), 1000);
|
|
};
|
|
|
|
const handleGoHome = () => {
|
|
navigate(initialUrl);
|
|
};
|
|
|
|
const handleScreenshot = async () => {
|
|
if (isCapturing || !currentUrl) return;
|
|
|
|
try {
|
|
setIsCapturing(true);
|
|
setShowShutterAnimation(true);
|
|
|
|
// Wait for shutter animation to start
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
|
// Capture the current URL using headless Chrome
|
|
const filePath = await api.captureUrlScreenshot(
|
|
currentUrl,
|
|
null, // No specific selector - capture the whole viewport
|
|
false // Not full page, just viewport
|
|
);
|
|
|
|
console.log("Screenshot captured and saved to:", filePath);
|
|
|
|
// Continue shutter animation
|
|
await new Promise(resolve => setTimeout(resolve, 200));
|
|
setShowShutterAnimation(false);
|
|
|
|
// Trigger callback with animation
|
|
onScreenshot?.(filePath);
|
|
} catch (err) {
|
|
console.error("Failed to capture screenshot:", err);
|
|
setShowShutterAnimation(false);
|
|
} finally {
|
|
setIsCapturing(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className={cn("flex flex-col h-full bg-background border-l", className)}
|
|
tabIndex={-1}
|
|
role="region"
|
|
aria-label="Web preview"
|
|
>
|
|
{/* Browser Top Bar */}
|
|
<div className="border-b bg-muted/30 flex-shrink-0">
|
|
{/* Title Bar */}
|
|
<div className="flex items-center justify-between px-3 py-2 border-b">
|
|
<div className="flex items-center gap-2">
|
|
<Globe className="h-4 w-4 text-muted-foreground" />
|
|
<span className="text-sm font-medium">Preview</span>
|
|
{isLoading && (
|
|
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1">
|
|
{onToggleMaximize && (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={onToggleMaximize}
|
|
className="h-7 w-7"
|
|
>
|
|
{isMaximized ? (
|
|
<Minimize2 className="h-3.5 w-3.5" />
|
|
) : (
|
|
<Maximize2 className="h-3.5 w-3.5" />
|
|
)}
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{isMaximized ? "Exit full screen (ESC)" : "Enter full screen"}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={onClose}
|
|
className="h-7 w-7 hover:bg-destructive/10 hover:text-destructive"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Navigation Bar */}
|
|
<div className="flex items-center gap-2 px-3 py-2">
|
|
{/* Navigation Buttons */}
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={handleGoBack}
|
|
disabled={true} // TODO: Enable when implementing actual navigation
|
|
className="h-8 w-8"
|
|
>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={handleGoForward}
|
|
disabled={true} // TODO: Enable when implementing actual navigation
|
|
className="h-8 w-8"
|
|
>
|
|
<ArrowRight className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={handleRefresh}
|
|
disabled={isLoading}
|
|
className="h-8 w-8"
|
|
>
|
|
<RefreshCw className={cn("h-4 w-4", isLoading && "animate-spin")} />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={handleGoHome}
|
|
className="h-8 w-8"
|
|
>
|
|
<Home className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* URL Bar */}
|
|
<div className="flex-1 relative">
|
|
<Input
|
|
value={inputUrl}
|
|
onChange={(e) => setInputUrl(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder="Enter URL..."
|
|
className="pr-10 h-8 text-sm font-mono"
|
|
/>
|
|
{inputUrl !== currentUrl && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={handleNavigate}
|
|
className="absolute right-1 top-1 h-6 w-6"
|
|
>
|
|
<ArrowRight className="h-3 w-3" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Screenshot Button */}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleScreenshot}
|
|
disabled={isCapturing || hasError}
|
|
className="gap-2"
|
|
>
|
|
{isCapturing ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Camera className="h-4 w-4" />
|
|
)}
|
|
Send to Claude
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Webview Content */}
|
|
<div className="flex-1 relative bg-background" ref={contentRef}>
|
|
{/* Shutter Animation */}
|
|
<AnimatePresence>
|
|
{showShutterAnimation && (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
className="absolute inset-0 z-20 pointer-events-none"
|
|
>
|
|
<motion.div
|
|
initial={{ borderWidth: 0 }}
|
|
animate={{ borderWidth: 8 }}
|
|
exit={{ borderWidth: 0 }}
|
|
transition={{ duration: 0.3 }}
|
|
className="absolute inset-0 border-white shadow-lg"
|
|
style={{ boxShadow: 'inset 0 0 20px rgba(255, 255, 255, 0.8)' }}
|
|
/>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Loading Overlay */}
|
|
<AnimatePresence>
|
|
{isLoading && (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className="absolute inset-0 bg-background/80 backdrop-blur-sm z-10 flex items-center justify-center"
|
|
>
|
|
<div className="flex flex-col items-center gap-3">
|
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
|
<p className="text-sm text-muted-foreground">Loading preview...</p>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Error State */}
|
|
{hasError ? (
|
|
<div className="flex flex-col items-center justify-center h-full p-8">
|
|
<AlertCircle className="h-12 w-12 text-destructive mb-4" />
|
|
<h3 className="text-lg font-semibold mb-2">Failed to load preview</h3>
|
|
<p className="text-sm text-muted-foreground text-center mb-4">
|
|
{errorMessage || "The page could not be loaded. Please check the URL and try again."}
|
|
</p>
|
|
<Button onClick={handleRefresh} variant="outline" size="sm">
|
|
Try Again
|
|
</Button>
|
|
</div>
|
|
) : currentUrl ? (
|
|
// Placeholder iframe - in real implementation, this would be a Tauri webview
|
|
<iframe
|
|
ref={iframeRef}
|
|
src={currentUrl}
|
|
className="absolute inset-0 w-full h-full border-0"
|
|
title="Preview"
|
|
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox"
|
|
onLoad={() => setIsLoading(false)}
|
|
onError={() => {
|
|
setHasError(true);
|
|
setIsLoading(false);
|
|
}}
|
|
/>
|
|
) : (
|
|
// Empty state when no URL is provided
|
|
<div className="flex flex-col items-center justify-center h-full p-8 text-foreground">
|
|
<Globe className="h-16 w-16 text-muted-foreground/50 mb-6" />
|
|
<h3 className="text-xl font-semibold mb-3">Enter a URL to preview</h3>
|
|
<p className="text-sm text-muted-foreground text-center mb-6 max-w-md">
|
|
Enter a URL in the address bar above to preview a website.
|
|
</p>
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<span>Try entering</span>
|
|
<code className="px-2 py-1 bg-muted/50 text-foreground rounded font-mono text-xs">localhost:3000</code>
|
|
<span>or any other URL</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const WebviewPreview = React.memo(WebviewPreviewComponent);
|