| import { X, ArrowLeft, ArrowRight, RotateCw, Plus, Globe, Search, Maximize2, Minimize2, ShieldCheck, Lock, Camera, Scissors } from 'lucide-react';
|
| import { useAppStore } from '../store';
|
| import { useState, useRef } from 'react';
|
| import { toPng } from 'html-to-image';
|
|
|
| const mockWebImages = [
|
| "https://picsum.photos/id/10/800/600",
|
| "https://picsum.photos/id/11/600/800",
|
| "https://picsum.photos/id/12/800/800",
|
| "https://picsum.photos/id/13/400/600",
|
| "https://picsum.photos/id/14/600/400",
|
| "https://picsum.photos/id/15/800/500",
|
| "https://picsum.photos/id/16/500/800",
|
| "https://picsum.photos/id/17/700/700",
|
| ];
|
|
|
| const bookmarks = [
|
| { name: "ArtStation", icon: "🎨" },
|
| { name: "Pinterest", icon: "📌" },
|
| { name: "Unsplash", icon: "📷" },
|
| { name: "Sketchfab", icon: "📦" },
|
| { name: "Google Images", icon: "🔍" },
|
| { name: "Anatomy360", icon: "🦴" },
|
| { name: "Line of Action", icon: "🏃" },
|
| { name: "SculptRef", icon: "🗿" },
|
| { name: "PolyHaven", icon: "🌍" },
|
| { name: "reference.pictures", icon: "🖼" },
|
| ];
|
|
|
| export const BrowserPanel = () => {
|
| const { isBrowserOpen, setIsBrowserOpen, setImages, pan, zoom } = useAppStore();
|
| const [url, setUrl] = useState("https://artstation.com/search");
|
| const [isFullscreen, setIsFullscreen] = useState(false);
|
| const inputRef = useRef<HTMLInputElement>(null);
|
| const browserContentRef = useRef<HTMLDivElement>(null);
|
| const browserVisibleRef = useRef<HTMLDivElement>(null);
|
| const [isCapturing, setIsCapturing] = useState(false);
|
| const [isSnipping, setIsSnipping] = useState(false);
|
| const [snipStart, setSnipStart] = useState<{x: number, y: number} | null>(null);
|
| const [snipCurrent, setSnipCurrent] = useState<{x: number, y: number} | null>(null);
|
|
|
| const handlePointerDown = (e: React.PointerEvent) => {
|
| if (!isSnipping) return;
|
| e.preventDefault();
|
| const rect = e.currentTarget.getBoundingClientRect();
|
| const x = e.clientX - rect.left;
|
| const y = e.clientY - rect.top;
|
| setSnipStart({ x, y });
|
| setSnipCurrent({ x, y });
|
| };
|
|
|
| const handlePointerMove = (e: React.PointerEvent) => {
|
| if (!isSnipping || !snipStart) return;
|
| e.preventDefault();
|
| const rect = e.currentTarget.getBoundingClientRect();
|
| const x = e.clientX - rect.left;
|
| const y = e.clientY - rect.top;
|
| setSnipCurrent({ x, y });
|
| };
|
|
|
| const handlePointerUp = async (e: React.PointerEvent) => {
|
| if (!isSnipping || !snipStart || !snipCurrent) {
|
| setIsSnipping(false);
|
| setSnipStart(null);
|
| setSnipCurrent(null);
|
| return;
|
| }
|
|
|
| e.preventDefault();
|
| const rect = e.currentTarget.getBoundingClientRect();
|
| const endX = e.clientX - rect.left;
|
| const endY = e.clientY - rect.top;
|
|
|
| const startX = Math.min(snipStart.x, endX);
|
| const startY = Math.min(snipStart.y, endY);
|
| const width = Math.abs(endX - snipStart.x);
|
| const height = Math.abs(endY - snipStart.y);
|
|
|
| setIsSnipping(false);
|
| setSnipStart(null);
|
| setSnipCurrent(null);
|
|
|
| if (width < 20 || height < 20) return;
|
|
|
| setIsCapturing(true);
|
|
|
| try {
|
| if (!browserVisibleRef.current) return;
|
| const dataUrl = await toPng(browserVisibleRef.current, { cacheBust: true, backgroundColor: '#1C1C1E', pixelRatio: 1 });
|
|
|
| const img = new Image();
|
| img.onload = () => {
|
| const canvas = document.createElement('canvas');
|
| canvas.width = Math.round(width);
|
| canvas.height = Math.round(height);
|
| const ctx = canvas.getContext('2d');
|
| if (!ctx) return;
|
|
|
|
|
| ctx.drawImage(
|
| img,
|
| Math.round(startX), Math.round(startY), Math.round(width), Math.round(height),
|
| 0, 0, Math.round(width), Math.round(height)
|
| );
|
|
|
| const croppedDataUrl = canvas.toDataURL('image/png');
|
|
|
| const ratio = width / height;
|
| const targetWidth = Math.min(width, 800);
|
| const newImg = {
|
| id: Math.random().toString(36).substr(2, 9),
|
| url: croppedDataUrl,
|
| x: (-pan.x + window.innerWidth / 2 - targetWidth / 2) / zoom,
|
| y: (-pan.y + window.innerHeight / 2 - (targetWidth / ratio) / 2) / zoom,
|
| width: Math.round(targetWidth),
|
| height: Math.round(targetWidth / ratio),
|
| aspectRatio: ratio,
|
| };
|
| setImages(prev => [...prev, newImg]);
|
| };
|
| img.src = dataUrl;
|
| } catch (err) {
|
| console.error('Failed to capture web page snip', err);
|
| } finally {
|
| setIsCapturing(false);
|
| }
|
| };
|
|
|
| const handleScreenshot = async () => {
|
| if (!browserContentRef.current || isCapturing) return;
|
| setIsCapturing(true);
|
| try {
|
| const dataUrl = await toPng(browserContentRef.current, { cacheBust: true, backgroundColor: '#1C1C1E' });
|
|
|
| const img = new Image();
|
| img.src = dataUrl;
|
| img.onload = () => {
|
| const ratio = img.width / img.height;
|
| const targetWidth = Math.min(800, img.width);
|
| const targetHeight = targetWidth / ratio;
|
| const newImg = {
|
| id: Math.random().toString(36).substr(2, 9),
|
| url: dataUrl,
|
| x: (-pan.x + window.innerWidth / 4) / zoom,
|
| y: (-pan.y + window.innerHeight / 4) / zoom,
|
| width: targetWidth,
|
| height: targetHeight,
|
| aspectRatio: ratio,
|
| };
|
| setImages(prev => [...prev, newImg]);
|
| };
|
| } catch (err) {
|
| console.error('Failed to capture web page screenshot', err);
|
| } finally {
|
| setIsCapturing(false);
|
| }
|
| };
|
|
|
| const handleCapture = (src: string) => {
|
| const img = new Image();
|
| img.src = src;
|
| img.onload = () => {
|
| const ratio = img.width / img.height;
|
| const targetWidth = Math.min(400, img.width);
|
| const targetHeight = targetWidth / ratio;
|
| const newImg = {
|
| id: Math.random().toString(36).substr(2, 9),
|
| url: src,
|
| x: (-pan.x + window.innerWidth / 4) / zoom,
|
| y: (-pan.y + window.innerHeight / 4) / zoom,
|
| width: targetWidth,
|
| height: targetHeight,
|
| aspectRatio: ratio,
|
| };
|
| setImages(prev => [...prev, newImg]);
|
| };
|
| };
|
|
|
| return (
|
| <div className={`absolute right-0 top-0 h-full bg-panel-bg shadow-2xl flex flex-col z-[60] transform transition-all duration-500 ease-[cubic-bezier(0.19,1,0.22,1)] ${isBrowserOpen ? 'translate-x-0' : 'translate-x-full'} ${isFullscreen ? 'w-[100vw] max-w-[100vw]' : 'w-[50vw] max-w-[600px]'}`}>
|
| <div className="flex flex-col bg-panel-bg z-10 px-4 pt-4 pb-2">
|
| <div className="flex items-center gap-2 mb-3">
|
| <div className="flex items-center gap-0.5">
|
| <button className="text-ui-secondary hover:text-ui-primary w-8 h-8 flex items-center justify-center rounded-md hover:bg-white/5 transition-colors"><ArrowLeft size={16} /></button>
|
| <button className="text-ui-secondary hover:text-ui-primary w-8 h-8 flex items-center justify-center rounded-md hover:bg-white/5 transition-colors"><ArrowRight size={16} /></button>
|
| <button className="text-ui-secondary hover:text-ui-primary w-8 h-8 flex items-center justify-center rounded-md hover:bg-white/5 transition-colors"><RotateCw size={14} /></button>
|
| </div>
|
| <div className="flex-1 relative flex items-center group bg-black/40 rounded-lg border border-white/5 focus-within:border-accent-blue/40 focus-within:bg-black/60 transition-all">
|
| <div className="absolute left-3 flex items-center gap-1.5 text-ui-secondary group-focus-within:text-accent-blue transition-colors pointer-events-none">
|
| <ShieldCheck size={13} className="text-emerald-500" />
|
| <Lock size={12} className="opacity-70" />
|
| </div>
|
| <input
|
| ref={inputRef}
|
| type="text"
|
| value={url}
|
| onChange={(e) => setUrl(e.target.value)}
|
| className="w-full bg-transparent text-ui-primary pl-14 pr-8 py-2 text-[13px] outline-none placeholder:text-ui-secondary"
|
| spellCheck={false}
|
| />
|
| {url && (
|
| <button onClick={() => { setUrl(''); inputRef.current?.focus(); }} className="absolute right-2.5 text-ui-secondary hover:text-ui-primary"><X size={14} /></button>
|
| )}
|
| </div>
|
| <div className="flex items-center gap-0.5 ml-1">
|
| <button onClick={() => setIsSnipping(!isSnipping)} disabled={isCapturing} className={`text-ui-secondary hover:text-accent-amber w-8 h-8 flex items-center justify-center rounded-md hover:bg-white/5 transition-colors ${isSnipping ? 'bg-accent-amber/20 text-accent-amber' : ''} ${isCapturing ? 'opacity-50 cursor-not-allowed' : ''}`} title="Area Snip">
|
| <Scissors size={16} />
|
| </button>
|
| <button onClick={handleScreenshot} disabled={isCapturing} className={`text-ui-secondary hover:text-accent-blue w-8 h-8 flex items-center justify-center rounded-md hover:bg-white/5 transition-colors ${isCapturing ? 'opacity-50 cursor-not-allowed' : ''}`} title="Full Web Clip">
|
| <Camera size={16} className={isCapturing && !isSnipping ? "animate-pulse" : ""} />
|
| </button>
|
| <button onClick={() => setIsFullscreen(!isFullscreen)} className="text-ui-secondary hover:text-ui-primary w-8 h-8 flex items-center justify-center rounded-md hover:bg-white/5 transition-colors" title={isFullscreen ? "Minimize" : "Maximize"}>
|
| {isFullscreen ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
|
| </button>
|
| <button onClick={() => setIsBrowserOpen(false)} className="text-ui-secondary hover:text-ui-primary w-8 h-8 flex items-center justify-center rounded-md hover:bg-white/5 transition-colors"><X size={18} /></button>
|
| </div>
|
| </div>
|
|
|
| <div className="flex items-center gap-2 overflow-x-auto hide-scrollbar pb-2">
|
| {bookmarks.map((bm, i) => (
|
| <button key={i} className="flex items-center gap-1.5 px-3 py-1.5 bg-white/5 hover:bg-white/10 border border-white/5 rounded-md text-[11px] font-medium text-ui-primary whitespace-nowrap transition-colors shadow-sm">
|
| <span>{bm.icon}</span>
|
| <span>{bm.name}</span>
|
| </button>
|
| ))}
|
| <button className="flex items-center justify-center w-7 h-7 rounded-md bg-white/5 hover:bg-white/10 text-ui-secondary transition-colors"><Plus size={14}/></button>
|
| </div>
|
| </div>
|
|
|
| <div ref={browserVisibleRef} className="flex-1 relative flex flex-col min-h-0 overflow-hidden">
|
| <div ref={browserContentRef} className="flex-1 p-4 bg-[#1C1C1E] overflow-y-auto relative custom-scrollbar">
|
| {/* Mock Web View */}
|
| <div className="columns-2 md:columns-3 xl:columns-4 gap-4 space-y-4">
|
| {mockWebImages.map((src, i) => (
|
| <button key={i} onClick={() => handleCapture(src)} className="block w-full relative group rounded-xl overflow-hidden ring-1 ring-white/5 hover:ring-white/20 transition-all shadow-sm hover:shadow-lg cursor-pointer text-left focus:outline-none focus:ring-2 focus:ring-accent-blue">
|
| <img src={src} className="w-full block transform transition-transform duration-700 ease-out group-hover:scale-105" />
|
| <div className="absolute inset-0 bg-gradient-to-b from-black/0 via-black/0 to-black/60 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-end justify-center p-4">
|
| <div className="bg-white text-black text-[11px] font-bold px-3 py-1.5 rounded-full flex items-center gap-1 shadow-2xl transform translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition-all duration-300">
|
| <Plus size={14} strokeWidth={2.5} /> ADD
|
| </div>
|
| </div>
|
| </button>
|
| ))}
|
| </div>
|
| </div>
|
|
|
| {isSnipping && (
|
| <div
|
| className="absolute inset-0 z-50 cursor-crosshair touchscreen-none"
|
| onPointerDown={handlePointerDown}
|
| onPointerMove={handlePointerMove}
|
| onPointerUp={handlePointerUp}
|
| onPointerCancel={handlePointerUp}
|
| style={{ backgroundColor: 'rgba(0,0,0,0.15)' }}
|
| >
|
| {snipStart && snipCurrent && (
|
| <div
|
| className="absolute border focus:outline-none"
|
| style={{
|
| left: Math.min(snipStart.x, snipCurrent.x),
|
| top: Math.min(snipStart.y, snipCurrent.y),
|
| width: Math.abs(snipCurrent.x - snipStart.x),
|
| height: Math.abs(snipCurrent.y - snipStart.y),
|
| borderStyle: 'dashed',
|
| borderColor: 'rgba(255, 255, 255, 0.8)',
|
| boxShadow: '0 0 0 9999px rgba(0, 0, 0, 0.4)',
|
| }}
|
| ></div>
|
| )}
|
| <div className="absolute top-4 left-1/2 -translate-x-1/2 bg-accent-amber text-black text-xs font-bold px-3 py-1.5 rounded-full shadow-lg pointer-events-none">
|
| Drag to capture area
|
| </div>
|
| </div>
|
| )}
|
| </div>
|
| </div>
|
| );
|
| };
|
|
|