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:
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