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:
Mufeed VH
2025-06-23 00:29:33 +05:30
parent 0c6c089810
commit 444d480bad
4 changed files with 1030 additions and 0 deletions

View 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)
}

View 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>
);
};

View 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);

View 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>
);
};