feat(preview): add web preview with screenshot capability
- 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.
This commit is contained in:
264
src-tauri/src/commands/screenshot.rs
Normal file
264
src-tauri/src/commands/screenshot.rs
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
use headless_chrome::{Browser, LaunchOptions};
|
||||||
|
use headless_chrome::protocol::cdp::Page;
|
||||||
|
use std::fs;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tauri::AppHandle;
|
||||||
|
|
||||||
|
/// Captures a screenshot of a URL using headless Chrome
|
||||||
|
///
|
||||||
|
/// This function launches a headless Chrome browser, navigates to the specified URL,
|
||||||
|
/// and captures a screenshot of either the entire page or a specific element.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `app` - The Tauri application handle
|
||||||
|
/// * `url` - The URL to capture
|
||||||
|
/// * `selector` - Optional CSS selector for a specific element to capture
|
||||||
|
/// * `full_page` - Whether to capture the entire page or just the viewport
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// * `Result<String, String>` - The path to the saved screenshot file, or an error message
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn capture_url_screenshot(
|
||||||
|
_app: AppHandle,
|
||||||
|
url: String,
|
||||||
|
selector: Option<String>,
|
||||||
|
full_page: bool,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
log::info!(
|
||||||
|
"Capturing screenshot of URL: {}, selector: {:?}, full_page: {}",
|
||||||
|
url,
|
||||||
|
selector,
|
||||||
|
full_page
|
||||||
|
);
|
||||||
|
|
||||||
|
// Run the browser operations in a blocking task since headless_chrome is not async
|
||||||
|
let result = tokio::task::spawn_blocking(move || {
|
||||||
|
capture_screenshot_sync(url, selector, full_page)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to spawn blocking task: {}", e))?;
|
||||||
|
|
||||||
|
// Log the result of the headless Chrome capture before returning
|
||||||
|
match &result {
|
||||||
|
Ok(path) => log::info!("capture_url_screenshot returning path: {}", path),
|
||||||
|
Err(err) => log::error!("capture_url_screenshot encountered error: {}", err),
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Synchronous helper function to capture screenshots using headless Chrome
|
||||||
|
fn capture_screenshot_sync(
|
||||||
|
url: String,
|
||||||
|
selector: Option<String>,
|
||||||
|
full_page: bool,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
// Configure browser launch options
|
||||||
|
let launch_options = LaunchOptions {
|
||||||
|
headless: true,
|
||||||
|
window_size: Some((1920, 1080)),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Launch the browser
|
||||||
|
let browser = Browser::new(launch_options)
|
||||||
|
.map_err(|e| format!("Failed to launch browser: {}", e))?;
|
||||||
|
|
||||||
|
// Create a new tab
|
||||||
|
let tab = browser
|
||||||
|
.new_tab()
|
||||||
|
.map_err(|e| format!("Failed to create new tab: {}", e))?;
|
||||||
|
|
||||||
|
// Set a reasonable timeout for page navigation
|
||||||
|
tab.set_default_timeout(Duration::from_secs(30));
|
||||||
|
|
||||||
|
// Navigate to the URL
|
||||||
|
tab.navigate_to(&url)
|
||||||
|
.map_err(|e| format!("Failed to navigate to URL: {}", e))?;
|
||||||
|
|
||||||
|
// Wait for the page to load
|
||||||
|
// Try to wait for network idle, but don't fail if it times out
|
||||||
|
let _ = tab.wait_until_navigated();
|
||||||
|
|
||||||
|
// Additional wait to ensure dynamic content loads
|
||||||
|
std::thread::sleep(Duration::from_millis(500));
|
||||||
|
|
||||||
|
// Wait explicitly for the <body> element to exist – this often prevents
|
||||||
|
// "Unable to capture screenshot" CDP errors on some pages
|
||||||
|
if let Err(e) = tab.wait_for_element("body") {
|
||||||
|
log::warn!("Timed out waiting for <body> element: {} – continuing anyway", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture the screenshot
|
||||||
|
let screenshot_data = if let Some(selector) = selector {
|
||||||
|
// Wait for the element and capture it
|
||||||
|
log::info!("Waiting for element with selector: {}", selector);
|
||||||
|
|
||||||
|
let element = tab
|
||||||
|
.wait_for_element(&selector)
|
||||||
|
.map_err(|e| format!("Failed to find element '{}': {}", selector, e))?;
|
||||||
|
|
||||||
|
element
|
||||||
|
.capture_screenshot(Page::CaptureScreenshotFormatOption::Png)
|
||||||
|
.map_err(|e| format!("Failed to capture element screenshot: {}", e))?
|
||||||
|
} else {
|
||||||
|
// Capture the entire page or viewport
|
||||||
|
log::info!("Capturing {} screenshot", if full_page { "full page" } else { "viewport" });
|
||||||
|
|
||||||
|
// Get the page dimensions for full page screenshot
|
||||||
|
let clip = if full_page {
|
||||||
|
// Execute JavaScript to get the full page dimensions
|
||||||
|
let dimensions = tab
|
||||||
|
.evaluate(
|
||||||
|
r#"
|
||||||
|
({
|
||||||
|
width: Math.max(
|
||||||
|
document.body.scrollWidth,
|
||||||
|
document.documentElement.scrollWidth,
|
||||||
|
document.body.offsetWidth,
|
||||||
|
document.documentElement.offsetWidth,
|
||||||
|
document.documentElement.clientWidth
|
||||||
|
),
|
||||||
|
height: Math.max(
|
||||||
|
document.body.scrollHeight,
|
||||||
|
document.documentElement.scrollHeight,
|
||||||
|
document.body.offsetHeight,
|
||||||
|
document.documentElement.offsetHeight,
|
||||||
|
document.documentElement.clientHeight
|
||||||
|
)
|
||||||
|
})
|
||||||
|
"#,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Failed to get page dimensions: {}", e))?;
|
||||||
|
|
||||||
|
// Extract dimensions from the result
|
||||||
|
let width = dimensions
|
||||||
|
.value
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.and_then(|obj| obj.get("width"))
|
||||||
|
.and_then(|v| v.as_f64())
|
||||||
|
.unwrap_or(1920.0);
|
||||||
|
|
||||||
|
let height = dimensions
|
||||||
|
.value
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.and_then(|obj| obj.get("height"))
|
||||||
|
.and_then(|v| v.as_f64())
|
||||||
|
.unwrap_or(1080.0);
|
||||||
|
|
||||||
|
Some(Page::Viewport {
|
||||||
|
x: 0.0,
|
||||||
|
y: 0.0,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
scale: 1.0,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let capture_result = tab.capture_screenshot(
|
||||||
|
Page::CaptureScreenshotFormatOption::Png,
|
||||||
|
None,
|
||||||
|
clip.clone(),
|
||||||
|
full_page, // capture_beyond_viewport only makes sense for full page
|
||||||
|
);
|
||||||
|
|
||||||
|
match capture_result {
|
||||||
|
Ok(data) => data,
|
||||||
|
Err(err) => {
|
||||||
|
// Retry once with capture_beyond_viewport=true which works around some Chromium bugs
|
||||||
|
log::warn!(
|
||||||
|
"Initial screenshot attempt failed: {}. Retrying with capture_beyond_viewport=true",
|
||||||
|
err
|
||||||
|
);
|
||||||
|
|
||||||
|
tab.capture_screenshot(
|
||||||
|
Page::CaptureScreenshotFormatOption::Png,
|
||||||
|
None,
|
||||||
|
clip,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Failed to capture screenshot after retry: {}", e))?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save to temporary file
|
||||||
|
let temp_dir = std::env::temp_dir();
|
||||||
|
let timestamp = chrono::Utc::now().timestamp_millis();
|
||||||
|
let filename = format!("claudia_screenshot_{}.png", timestamp);
|
||||||
|
let file_path = temp_dir.join(filename);
|
||||||
|
|
||||||
|
fs::write(&file_path, screenshot_data)
|
||||||
|
.map_err(|e| format!("Failed to save screenshot: {}", e))?;
|
||||||
|
|
||||||
|
// Log the screenshot path prominently
|
||||||
|
println!("═══════════════════════════════════════════════════════════════");
|
||||||
|
println!("📸 SCREENSHOT SAVED SUCCESSFULLY!");
|
||||||
|
println!("📁 Location: {}", file_path.display());
|
||||||
|
println!("═══════════════════════════════════════════════════════════════");
|
||||||
|
|
||||||
|
log::info!("Screenshot saved to: {:?}", file_path);
|
||||||
|
|
||||||
|
Ok(file_path.to_string_lossy().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cleans up old screenshot files from the temporary directory
|
||||||
|
///
|
||||||
|
/// This function removes screenshot files older than the specified number of minutes
|
||||||
|
/// to prevent accumulation of temporary files.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `older_than_minutes` - Remove files older than this many minutes (default: 60)
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// * `Result<usize, String>` - The number of files deleted, or an error message
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn cleanup_screenshot_temp_files(
|
||||||
|
older_than_minutes: Option<u64>,
|
||||||
|
) -> Result<usize, String> {
|
||||||
|
let minutes = older_than_minutes.unwrap_or(60);
|
||||||
|
log::info!("Cleaning up screenshot files older than {} minutes", minutes);
|
||||||
|
|
||||||
|
let temp_dir = std::env::temp_dir();
|
||||||
|
let cutoff_time = chrono::Utc::now() - chrono::Duration::minutes(minutes as i64);
|
||||||
|
let mut deleted_count = 0;
|
||||||
|
|
||||||
|
// Read directory entries
|
||||||
|
let entries = fs::read_dir(&temp_dir)
|
||||||
|
.map_err(|e| format!("Failed to read temp directory: {}", e))?;
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
if let Ok(entry) = entry {
|
||||||
|
let path = entry.path();
|
||||||
|
|
||||||
|
// Check if it's a claudia screenshot file
|
||||||
|
if let Some(filename) = path.file_name() {
|
||||||
|
if let Some(filename_str) = filename.to_str() {
|
||||||
|
if filename_str.starts_with("claudia_screenshot_") && filename_str.ends_with(".png") {
|
||||||
|
// Check file age
|
||||||
|
if let Ok(metadata) = fs::metadata(&path) {
|
||||||
|
if let Ok(modified) = metadata.modified() {
|
||||||
|
let modified_time = chrono::DateTime::<chrono::Utc>::from(modified);
|
||||||
|
if modified_time < cutoff_time {
|
||||||
|
// Delete the file
|
||||||
|
if fs::remove_file(&path).is_ok() {
|
||||||
|
deleted_count += 1;
|
||||||
|
log::debug!("Deleted old screenshot: {:?}", path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("Cleaned up {} old screenshot files", deleted_count);
|
||||||
|
Ok(deleted_count)
|
||||||
|
}
|
113
src/components/PreviewPromptDialog.tsx
Normal file
113
src/components/PreviewPromptDialog.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { Globe, ExternalLink } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
interface PreviewPromptDialogProps {
|
||||||
|
/**
|
||||||
|
* Whether the dialog is open
|
||||||
|
*/
|
||||||
|
isOpen: boolean;
|
||||||
|
/**
|
||||||
|
* The detected URL to preview
|
||||||
|
*/
|
||||||
|
url: string;
|
||||||
|
/**
|
||||||
|
* Callback when user confirms opening preview
|
||||||
|
*/
|
||||||
|
onConfirm: () => void;
|
||||||
|
/**
|
||||||
|
* Callback when user cancels
|
||||||
|
*/
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dialog component that prompts the user to open a detected URL in the preview pane
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* <PreviewPromptDialog
|
||||||
|
* isOpen={showPrompt}
|
||||||
|
* url="http://localhost:3000"
|
||||||
|
* onConfirm={() => openPreview(url)}
|
||||||
|
* onCancel={() => setShowPrompt(false)}
|
||||||
|
* />
|
||||||
|
*/
|
||||||
|
export const PreviewPromptDialog: React.FC<PreviewPromptDialogProps> = ({
|
||||||
|
isOpen,
|
||||||
|
url,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}) => {
|
||||||
|
// Extract domain for display
|
||||||
|
const getDomain = (urlString: string) => {
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(urlString);
|
||||||
|
return urlObj.hostname;
|
||||||
|
} catch {
|
||||||
|
return urlString;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const domain = getDomain(url);
|
||||||
|
const isLocalhost = domain.includes('localhost') || domain.includes('127.0.0.1');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={(open) => !open && onCancel()}>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Globe className="h-5 w-5 text-primary" />
|
||||||
|
Open Preview?
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
A URL was detected in the terminal output. Would you like to open it in the preview pane?
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="py-4">
|
||||||
|
<div className="rounded-lg border bg-muted/50 p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<ExternalLink className={`h-4 w-4 mt-0.5 ${isLocalhost ? 'text-green-500' : 'text-blue-500'}`} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{isLocalhost ? 'Local Development Server' : 'External URL'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 break-all">
|
||||||
|
{url}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.1 }}
|
||||||
|
className="mt-3 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
The preview will open in a split view on the right side of the screen.
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onConfirm} className="gap-2">
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
Open Preview
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
439
src/components/WebviewPreview.tsx
Normal file
439
src/components/WebviewPreview.tsx
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
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);
|
214
src/components/ui/split-pane.tsx
Normal file
214
src/components/ui/split-pane.tsx
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import React, { useState, useRef, useEffect, useCallback } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface SplitPaneProps {
|
||||||
|
/**
|
||||||
|
* Content for the left pane
|
||||||
|
*/
|
||||||
|
left: React.ReactNode;
|
||||||
|
/**
|
||||||
|
* Content for the right pane
|
||||||
|
*/
|
||||||
|
right: React.ReactNode;
|
||||||
|
/**
|
||||||
|
* Initial split position as percentage (0-100)
|
||||||
|
* @default 50
|
||||||
|
*/
|
||||||
|
initialSplit?: number;
|
||||||
|
/**
|
||||||
|
* Minimum width for left pane in pixels
|
||||||
|
* @default 200
|
||||||
|
*/
|
||||||
|
minLeftWidth?: number;
|
||||||
|
/**
|
||||||
|
* Minimum width for right pane in pixels
|
||||||
|
* @default 200
|
||||||
|
*/
|
||||||
|
minRightWidth?: number;
|
||||||
|
/**
|
||||||
|
* Callback when split position changes
|
||||||
|
*/
|
||||||
|
onSplitChange?: (position: number) => void;
|
||||||
|
/**
|
||||||
|
* Optional className for styling
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resizable split pane component for side-by-side layouts
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* <SplitPane
|
||||||
|
* left={<div>Left content</div>}
|
||||||
|
* right={<div>Right content</div>}
|
||||||
|
* initialSplit={60}
|
||||||
|
* onSplitChange={(pos) => console.log('Split at', pos)}
|
||||||
|
* />
|
||||||
|
*/
|
||||||
|
export const SplitPane: React.FC<SplitPaneProps> = ({
|
||||||
|
left,
|
||||||
|
right,
|
||||||
|
initialSplit = 50,
|
||||||
|
minLeftWidth = 200,
|
||||||
|
minRightWidth = 200,
|
||||||
|
onSplitChange,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
const [splitPosition, setSplitPosition] = useState(initialSplit);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const dragStartX = useRef(0);
|
||||||
|
const dragStartSplit = useRef(0);
|
||||||
|
const animationFrameRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
// Handle mouse down on divider
|
||||||
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(true);
|
||||||
|
dragStartX.current = e.clientX;
|
||||||
|
dragStartSplit.current = splitPosition;
|
||||||
|
document.body.style.cursor = 'col-resize';
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle mouse move
|
||||||
|
const handleMouseMove = useCallback((e: MouseEvent) => {
|
||||||
|
if (!isDragging || !containerRef.current) return;
|
||||||
|
|
||||||
|
if (animationFrameRef.current) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
animationFrameRef.current = requestAnimationFrame(() => {
|
||||||
|
const containerWidth = containerRef.current!.offsetWidth;
|
||||||
|
const deltaX = e.clientX - dragStartX.current;
|
||||||
|
const deltaPercent = (deltaX / containerWidth) * 100;
|
||||||
|
const newSplit = dragStartSplit.current + deltaPercent;
|
||||||
|
|
||||||
|
// Calculate min/max based on pixel constraints
|
||||||
|
const minSplit = (minLeftWidth / containerWidth) * 100;
|
||||||
|
const maxSplit = 100 - (minRightWidth / containerWidth) * 100;
|
||||||
|
|
||||||
|
const clampedSplit = Math.min(Math.max(newSplit, minSplit), maxSplit);
|
||||||
|
setSplitPosition(clampedSplit);
|
||||||
|
onSplitChange?.(clampedSplit);
|
||||||
|
});
|
||||||
|
}, [isDragging, minLeftWidth, minRightWidth, onSplitChange]);
|
||||||
|
|
||||||
|
// Handle mouse up
|
||||||
|
const handleMouseUp = useCallback(() => {
|
||||||
|
if (animationFrameRef.current) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
}
|
||||||
|
setIsDragging(false);
|
||||||
|
document.body.style.cursor = '';
|
||||||
|
document.body.style.userSelect = '';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Add global mouse event listeners
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDragging) {
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [isDragging, handleMouseMove, handleMouseUp]);
|
||||||
|
|
||||||
|
// Handle keyboard navigation
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
const step = e.shiftKey ? 10 : 2; // Larger steps with shift
|
||||||
|
const containerWidth = containerRef.current.offsetWidth;
|
||||||
|
const minSplit = (minLeftWidth / containerWidth) * 100;
|
||||||
|
const maxSplit = 100 - (minRightWidth / containerWidth) * 100;
|
||||||
|
|
||||||
|
let newSplit = splitPosition;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowLeft':
|
||||||
|
e.preventDefault();
|
||||||
|
newSplit = Math.max(splitPosition - step, minSplit);
|
||||||
|
break;
|
||||||
|
case 'ArrowRight':
|
||||||
|
e.preventDefault();
|
||||||
|
newSplit = Math.min(splitPosition + step, maxSplit);
|
||||||
|
break;
|
||||||
|
case 'Home':
|
||||||
|
e.preventDefault();
|
||||||
|
newSplit = minSplit;
|
||||||
|
break;
|
||||||
|
case 'End':
|
||||||
|
e.preventDefault();
|
||||||
|
newSplit = maxSplit;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSplitPosition(newSplit);
|
||||||
|
onSplitChange?.(newSplit);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={cn("flex h-full w-full relative", className)}
|
||||||
|
>
|
||||||
|
{/* Left pane */}
|
||||||
|
<div
|
||||||
|
className="h-full overflow-hidden"
|
||||||
|
style={{ width: `${splitPosition}%` }}
|
||||||
|
>
|
||||||
|
{left}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative flex-shrink-0 group",
|
||||||
|
"w-1 hover:w-2 transition-all duration-150",
|
||||||
|
"bg-border hover:bg-primary/50",
|
||||||
|
"cursor-col-resize",
|
||||||
|
"focus:outline-none focus:bg-primary focus:w-2",
|
||||||
|
isDragging && "bg-primary w-2"
|
||||||
|
)}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
tabIndex={0}
|
||||||
|
role="separator"
|
||||||
|
aria-label="Resize panes"
|
||||||
|
aria-valuenow={Math.round(splitPosition)}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
>
|
||||||
|
{/* Expand hit area for easier dragging */}
|
||||||
|
<div className="absolute inset-y-0 -left-2 -right-2 z-10" />
|
||||||
|
|
||||||
|
{/* Visual indicator dots */}
|
||||||
|
<div className={cn(
|
||||||
|
"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2",
|
||||||
|
"flex flex-col items-center justify-center gap-1",
|
||||||
|
"opacity-0 group-hover:opacity-100 transition-opacity",
|
||||||
|
isDragging && "opacity-100"
|
||||||
|
)}>
|
||||||
|
<div className="w-1 h-1 bg-primary rounded-full" />
|
||||||
|
<div className="w-1 h-1 bg-primary rounded-full" />
|
||||||
|
<div className="w-1 h-1 bg-primary rounded-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right pane */}
|
||||||
|
<div
|
||||||
|
className="h-full overflow-hidden flex-1"
|
||||||
|
style={{ width: `${100 - splitPosition}%` }}
|
||||||
|
>
|
||||||
|
{right}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
Reference in New Issue
Block a user