Manager
View Site
Name
Type
React (.jsx)
HTML (.html)
Icon
Description
Code Editor
Revision History (0)
import React, { useState, useRef, useEffect, useMemo } from 'react'; import { Play, Pause, Plus, Trash2, Download, Upload, X, Settings, Key, AlertCircle, Circle, Square, Layers, Eye, EyeOff } from 'lucide-react'; // --- Helper: Linear Interpolation --- const lerp = (start, end, t) => start * (1 - t) + end * t; // --- Helper: Generate ID --- const generateId = () => Math.random().toString(36).substr(2, 9); const BlurVideoEditor = () => { // --- State --- const [videoSrc, setVideoSrc] = useState(null); const [isPlaying, setIsPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); const [videoDimensions, setVideoDimensions] = useState({ width: 0, height: 0 }); const [containerDimensions, setContainerDimensions] = useState({ width: 0, height: 0 }); // Data Model // shapes: [{ id, type: 'rect'|'circle', name, visible: true }] // keyframes: [{ id, shapeId, time, x, y, w, h }] const [shapes, setShapes] = useState([]); const [activeShapeId, setActiveShapeId] = useState(null); const [keyframes, setKeyframes] = useState([]); // Editor State const [blurStrength, setBlurStrength] = useState(15); const [edgeFeather, setEdgeFeather] = useState(10); // New: Edge softness const [invertBlur, setInvertBlur] = useState(false); // New: Blur background // Interaction State // We store the current "live" positions of all shapes in a map for easy access during render/drag // { [shapeId]: { x, y, w, h } } const [liveShapePositions, setLiveShapePositions] = useState({}); const [isDragging, setIsDragging] = useState(false); const [dragMode, setDragMode] = useState(null); // 'move' | 'nw' | 'ne' | 'sw' | 'se' const [manualOverride, setManualOverride] = useState(false); const [isExporting, setIsExporting] = useState(false); const [exportProgress, setExportProgress] = useState(0); // --- Refs --- const videoRef = useRef(null); const canvasRef = useRef(null); const containerRef = useRef(null); const mediaRecorderRef = useRef(null); const recordedChunks = useRef([]); const fileInputRef = useRef(null); // Offscreen canvas for compositing (created on demand) const blurBufferRef = useRef(document.createElement('canvas')); const maskBufferRef = useRef(document.createElement('canvas')); // --- Initialization --- const handleFileChange = (e) => { const file = e.target.files[0]; if (file) { const url = URL.createObjectURL(file); setVideoSrc(url); // Reset State const initialShapeId = generateId(); setShapes([{ id: initialShapeId, type: 'rect', name: 'Blur 1', visible: true }]); setActiveShapeId(initialShapeId); setKeyframes([]); setLiveShapePositions({ [initialShapeId]: { x: 0.35, y: 0.35, w: 0.3, h: 0.3 } }); setIsPlaying(false); setManualOverride(false); setExportProgress(0); } }; const handleVideoLoadedMetadata = () => { if (videoRef.current) { setDuration(videoRef.current.duration); setVideoDimensions({ width: videoRef.current.videoWidth, height: videoRef.current.videoHeight, }); } }; // --- Resize Observer --- useEffect(() => { if (!containerRef.current || !videoDimensions.width) return; const updateSize = () => { const container = containerRef.current; const aspect = videoDimensions.width / videoDimensions.height; let w = container.clientWidth; let h = w / aspect; if (h > window.innerHeight * 0.6) { h = window.innerHeight * 0.6; w = h * aspect; } setContainerDimensions({ width: w, height: h }); }; const resizeObserver = new ResizeObserver(updateSize); resizeObserver.observe(containerRef.current); updateSize(); return () => resizeObserver.disconnect(); }, [videoDimensions]); // --- Logic: Calculate Positions from Keyframes --- const calculatePositions = (time) => { const newPositions = {}; shapes.forEach(shape => { // If manually dragging THIS shape, preserve its state from livePositions if (manualOverride && shape.id === activeShapeId && isDragging) { newPositions[shape.id] = liveShapePositions[shape.id]; return; } const shapeKfs = keyframes.filter(k => k.shapeId === shape.id).sort((a, b) => a.time - b.time); let pos = { x: 0.4, y: 0.4, w: 0.2, h: 0.2 }; // Default if (shapeKfs.length > 0) { const prev = shapeKfs.findLast(k => k.time <= time); const next = shapeKfs.find(k => k.time > time); if (prev && next) { const t = (time - prev.time) / (next.time - prev.time); pos = { x: lerp(prev.x, next.x, t), y: lerp(prev.y, next.y, t), w: lerp(prev.w, next.w, t), h: lerp(prev.h, next.h, t), }; } else if (prev) { pos = { x: prev.x, y: prev.y, w: prev.w, h: prev.h }; } else if (next) { pos = { x: next.x, y: next.y, w: next.w, h: next.h }; } } else { // No keyframes for this shape? Keep it where it was or default // If we have a live position for it (e.g. just added), use that if (liveShapePositions[shape.id]) { pos = liveShapePositions[shape.id]; } } newPositions[shape.id] = pos; }); return newPositions; }; // --- The Core Loop: Drawing to Canvas --- const draw = () => { const video = videoRef.current; const canvas = canvasRef.current; if (!video || !canvas || video.readyState < 2) return; const ctx = canvas.getContext('2d'); // Sync Resolutions if (canvas.width !== video.videoWidth || canvas.height !== video.videoHeight) { canvas.width = video.videoWidth; canvas.height = video.videoHeight; blurBufferRef.current.width = video.videoWidth; blurBufferRef.current.height = video.videoHeight; maskBufferRef.current.width = video.videoWidth; maskBufferRef.current.height = video.videoHeight; } // 1. Calculate Positions // If playing, we always calc. If paused, we calc unless overriding. let positions = liveShapePositions; if (isPlaying || !manualOverride) { positions = calculatePositions(video.currentTime); // Only update state if not dragging to avoid render loop conflict if (!isDragging) setLiveShapePositions(positions); } // 2. Prepare Layers const width = canvas.width; const height = canvas.height; // -- Layer A: Clean Video -- ctx.drawImage(video, 0, 0, width, height); // -- Layer B: Blurred Video (Offscreen) -- const blurCtx = blurBufferRef.current.getContext('2d'); blurCtx.filter = `blur(${blurStrength}px)`; blurCtx.drawImage(video, 0, 0, width, height); blurCtx.filter = 'none'; // -- Layer C: Mask (Offscreen) -- const maskCtx = maskBufferRef.current.getContext('2d'); maskCtx.clearRect(0, 0, width, height); // Draw shapes onto mask // If Feathering: We use shadowBlur to create soft edges // Note: Canvas shadowBlur is expensive, but necessary for true feathering maskCtx.fillStyle = 'white'; maskCtx.shadowColor = 'white'; maskCtx.shadowBlur = edgeFeather * 2; // Scale factor for visibility shapes.forEach(shape => { if (!shape.visible) return; const pos = positions[shape.id]; if (!pos) return; const bx = pos.x * width; const by = pos.y * height; const bw = pos.w * width; const bh = pos.h * height; maskCtx.beginPath(); if (shape.type === 'circle') { // Ellipse to support stretched circles maskCtx.ellipse(bx + bw/2, by + bh/2, bw/2, bh/2, 0, 0, 2 * Math.PI); } else { maskCtx.rect(bx, by, bw, bh); } maskCtx.fill(); }); // 3. Composite // We want: Clean Video + (Blurred Video masked by Shapes) // Apply Mask to Blurred Buffer // 'destination-in' : Keep the blurred content ONLY where the mask is opaque // If inverted: 'destination-out' : Remove blurred content where mask is opaque (leaving transparency), then draw over clean? // No. Invert means: Background is blurred, Shapes are sharp. if (invertBlur) { // INVERT LOGIC: // Base: Blurred Video. // Overlay: Sharp Video masked by Shapes. // 1. Draw Blurred Video on Main Canvas (overwrite clean) ctx.drawImage(blurBufferRef.current, 0, 0); // 2. Mask the Clean Video? // Let's use the blurBuffer as a workspace again. // Clear blur buffer. Draw Clean Video. // Draw Mask with 'destination-in'. // Now blur buffer has Clean Video Shapes. // Draw that on top of Main (Blurred). blurCtx.clearRect(0, 0, width, height); blurCtx.drawImage(video, 0, 0, width, height); blurCtx.globalCompositeOperation = 'destination-in'; blurCtx.drawImage(maskBufferRef.current, 0, 0); blurCtx.globalCompositeOperation = 'source-over'; ctx.drawImage(blurBufferRef.current, 0, 0); } else { // NORMAL LOGIC: // Base: Clean Video. // Overlay: Blurred Video masked by Shapes. // Apply mask to the blurred frame we already generated blurCtx.globalCompositeOperation = 'destination-in'; blurCtx.drawImage(maskBufferRef.current, 0, 0); blurCtx.globalCompositeOperation = 'source-over'; // Reset // Draw the masked blurred content onto main canvas ctx.drawImage(blurBufferRef.current, 0, 0); } }; // --- Animation Loop --- useEffect(() => { let animationFrameId; const render = () => { draw(); if (videoRef.current) { setCurrentTime(videoRef.current.currentTime); if (isExporting) setExportProgress((videoRef.current.currentTime / duration) * 100); } animationFrameId = requestAnimationFrame(render); }; render(); return () => cancelAnimationFrame(animationFrameId); }, [liveShapePositions, blurStrength, edgeFeather, invertBlur, shapes, keyframes, isDragging, isExporting, duration, isPlaying, manualOverride]); // --- Actions --- const togglePlay = () => { if (videoRef.current) { if (isPlaying) { videoRef.current.pause(); } else { videoRef.current.play(); setManualOverride(false); } setIsPlaying(!isPlaying); } }; const handleSeek = (e) => { const time = parseFloat(e.target.value); if (videoRef.current) { videoRef.current.currentTime = time; setCurrentTime(time); setManualOverride(false); } }; // --- Shape Management --- const addShape = () => { const newId = generateId(); const newShape = { id: newId, type: 'rect', name: `Blur ${shapes.length + 1}`, visible: true }; setShapes([...shapes, newShape]); setActiveShapeId(newId); // Initialize position slightly offset so it's visible setLiveShapePositions(prev => ({ ...prev, [newId]: { x: 0.3 + (shapes.length * 0.05), y: 0.3 + (shapes.length * 0.05), w: 0.2, h: 0.2 } })); setManualOverride(true); }; const deleteShape = (id) => { const newShapes = shapes.filter(s => s.id !== id); setShapes(newShapes); setKeyframes(keyframes.filter(k => k.shapeId !== id)); const { [id]: deleted, ...remaining } = liveShapePositions; setLiveShapePositions(remaining); if (activeShapeId === id && newShapes.length > 0) { setActiveShapeId(newShapes[0].id); } else if (newShapes.length === 0) { setActiveShapeId(null); } }; const toggleShapeType = (id) => { setShapes(shapes.map(s => s.id === id ? { ...s, type: s.type === 'rect' ? 'circle' : 'rect' } : s)); }; const toggleShapeVisibility = (id, e) => { e.stopPropagation(); setShapes(shapes.map(s => s.id === id ? { ...s, visible: !s.visible } : s)); }; // --- Keyframes --- const addKeyframe = () => { if (!videoRef.current || !activeShapeId) return; const time = videoRef.current.currentTime; const currentPos = liveShapePositions[activeShapeId]; if(!currentPos) return; // Remove existing kf at this time for this shape const filtered = keyframes.filter(k => !(k.shapeId === activeShapeId && Math.abs(k.time - time) < 0.1)); const newKeyframe = { id: Date.now(), shapeId: activeShapeId, time, ...currentPos }; setKeyframes([...filtered, newKeyframe].sort((a, b) => a.time - b.time)); setManualOverride(false); }; const deleteKeyframe = (id) => { setKeyframes(keyframes.filter(k => k.id !== id)); setManualOverride(false); }; const jumpToKeyframe = (time, shapeId) => { if(videoRef.current) { videoRef.current.currentTime = time; setCurrentTime(time); if (shapeId) setActiveShapeId(shapeId); setIsPlaying(false); videoRef.current.pause(); setManualOverride(false); } }; // --- Dragging Logic --- const getPointerPos = (e) => { const clientX = e.touches ? e.touches[0].clientX : e.clientX; const clientY = e.touches ? e.touches[0].clientY : e.clientY; const rect = containerRef.current.getBoundingClientRect(); return { x: (clientX - rect.left) / rect.width, y: (clientY - rect.top) / rect.height }; }; const handlePointerDown = (e, mode, shapeId) => { e.preventDefault(); e.stopPropagation(); setIsDragging(true); setDragMode(mode); setActiveShapeId(shapeId); setManualOverride(true); if(isPlaying && videoRef.current) { videoRef.current.pause(); setIsPlaying(false); } }; const handlePointerMove = (e) => { if (!isDragging || !activeShapeId || !containerRef.current) return; e.preventDefault(); const pos = getPointerPos(e); const cx = Math.max(0, Math.min(1, pos.x)); const cy = Math.max(0, Math.min(1, pos.y)); setLiveShapePositions(prev => { const current = prev[activeShapeId] || { x:0, y:0, w:0.1, h:0.1 }; let { x, y, w, h } = current; if (dragMode === 'move') { x = cx - w / 2; y = cy - h / 2; } else if (dragMode === 'nw') { const right = x + w; const bottom = y + h; x = Math.min(cx, right - 0.05); y = Math.min(cy, bottom - 0.05); w = right - x; h = bottom - y; } else if (dragMode === 'se') { w = Math.max(0.05, cx - x); h = Math.max(0.05, cy - y); } else if (dragMode === 'ne') { const bottom = y + h; y = Math.min(cy, bottom - 0.05); h = bottom - y; w = Math.max(0.05, cx - x); } else if (dragMode === 'sw') { const right = x + w; x = Math.min(cx, right - 0.05); w = right - x; h = Math.max(0.05, cy - y); } // Bounds if (x < 0) x = 0; if (y < 0) y = 0; if (x + w > 1) x = 1 - w; if (y + h > 1) y = 1 - h; return { ...prev, [activeShapeId]: { x, y, w, h } }; }); }; const handlePointerUp = () => { setIsDragging(false); setDragMode(null); }; // --- Export Logic --- const startExport = () => { if (!videoRef.current || !canvasRef.current) return; setIsExporting(true); setIsPlaying(true); setManualOverride(false); recordedChunks.current = []; const video = videoRef.current; const canvas = canvasRef.current; const stream = canvas.captureStream(30); let mimeType = 'video/webm;codecs=vp9'; if (!MediaRecorder.isTypeSupported(mimeType)) mimeType = 'video/webm'; const recorder = new MediaRecorder(stream, { mimeType }); recorder.ondataavailable = (e) => { if (e.data.size > 0) recordedChunks.current.push(e.data); }; recorder.onstop = () => { const blob = new Blob(recordedChunks.current, { type: mimeType }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `blurred-video-${Date.now()}.webm`; a.click(); URL.revokeObjectURL(url); setIsExporting(false); setIsPlaying(false); }; mediaRecorderRef.current = recorder; video.pause(); video.currentTime = 0; setTimeout(() => { recorder.start(); video.play(); video.onended = () => { recorder.stop(); video.onended = null; }; }, 500); }; useEffect(() => { window.addEventListener('pointerup', handlePointerUp); window.addEventListener('pointercancel', handlePointerUp); return () => { window.removeEventListener('pointerup', handlePointerUp); window.removeEventListener('pointercancel', handlePointerUp); }; }, []); return ( <div className="min-h-screen bg-gray-900 text-white font-sans flex flex-col items-center"> {/* Header */} <div className="w-full p-4 bg-gray-800 border-b border-gray-700 flex justify-between items-center shadow-md z-10"> <h1 className="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-blue-400 to-purple-500"> Blur Pro </h1> <div className="flex gap-2"> {!videoSrc && ( <button onClick={() => fileInputRef.current.click()} className="flex items-center gap-2 bg-blue-600 hover:bg-blue-500 px-4 py-2 rounded-lg text-sm font-medium transition-colors" > <Upload size={16} /> Open Video </button> )} {videoSrc && ( <button onClick={() => window.confirm("Reset?") && setVideoSrc(null)} className="p-2 hover:bg-gray-700 rounded-full text-gray-400 hover:text-white" > <X size={20} /> </button> )} </div> <input type="file" ref={fileInputRef} onChange={handleFileChange} className="hidden" accept="video/*" /> </div> {/* Main Workspace */} <div className="flex-1 w-full max-w-6xl p-4 flex flex-col gap-4 overflow-y-auto"> {!videoSrc ? ( <div onClick={() => fileInputRef.current.click()} className="flex-1 flex flex-col items-center justify-center border-2 border-dashed border-gray-700 rounded-2xl bg-gray-800/50 hover:bg-gray-800 transition-colors cursor-pointer min-h-[400px]"> <div className="p-4 rounded-full bg-gray-700 mb-4"><Upload size={32} className="text-blue-400" /></div> <p className="text-lg font-medium text-gray-300">Tap to upload video</p> </div> ) : ( <div className="flex flex-col lg:flex-row gap-6 h-full"> {/* LEFT COLUMN: EDITOR */} <div className="flex-1 flex flex-col gap-4"> {/* Viewport */} <div className="relative w-full flex justify-center bg-black rounded-lg overflow-hidden shadow-2xl border border-gray-800 group/canvas"> <video ref={videoRef} src={videoSrc} className="hidden" onLoadedMetadata={handleVideoLoadedMetadata} playsInline crossOrigin="anonymous"/> <div ref={containerRef} className="relative touch-none select-none" style={{ width: containerDimensions.width || '100%', height: containerDimensions.height || 'auto' }} onPointerMove={handlePointerMove}> <canvas ref={canvasRef} className="w-full h-full object-contain" /> {/* OVERLAYS FOR INTERACTION */} {!isExporting && shapes.map(shape => { if (!shape.visible) return null; const pos = liveShapePositions[shape.id]; if (!pos) return null; const isActive = shape.id === activeShapeId; return ( <div key={shape.id} className={`absolute cursor-move transition-opacity ${isActive ? 'z-20 opacity-100' : 'z-10 opacity-40 hover:opacity-80'}`} style={{ left: `${pos.x * 100}%`, top: `${pos.y * 100}%`, width: `${pos.w * 100}%`, height: `${pos.h * 100}%`, }} onPointerDown={(e) => handlePointerDown(e, 'move', shape.id)} > <div className={`w-full h-full border-2 ${isActive ? 'border-blue-400' : 'border-gray-400'} ${shape.type === 'circle' ? 'rounded-full' : ''} shadow-sm`}> {/* Only show handles for active shape */} {isActive && ( <> <div className="absolute -top-2 -left-2 w-6 h-6 bg-transparent cursor-nw-resize flex items-center justify-center" onPointerDown={(e) => handlePointerDown(e, 'nw', shape.id)}><div className="w-2.5 h-2.5 bg-white border border-blue-500 rounded-full" /></div> <div className="absolute -top-2 -right-2 w-6 h-6 bg-transparent cursor-ne-resize flex items-center justify-center" onPointerDown={(e) => handlePointerDown(e, 'ne', shape.id)}><div className="w-2.5 h-2.5 bg-white border border-blue-500 rounded-full" /></div> <div className="absolute -bottom-2 -left-2 w-6 h-6 bg-transparent cursor-sw-resize flex items-center justify-center" onPointerDown={(e) => handlePointerDown(e, 'sw', shape.id)}><div className="w-2.5 h-2.5 bg-white border border-blue-500 rounded-full" /></div> <div className="absolute -bottom-2 -right-2 w-6 h-6 bg-transparent cursor-se-resize flex items-center justify-center" onPointerDown={(e) => handlePointerDown(e, 'se', shape.id)}><div className="w-2.5 h-2.5 bg-white border border-blue-500 rounded-full" /></div> </> )} </div> </div> ); })} {isExporting && ( <div className="absolute inset-0 bg-black/80 flex flex-col items-center justify-center z-50"> <div className="text-2xl font-bold mb-4 animate-pulse text-blue-400">Rendering Video...</div> <div className="w-64 h-2 bg-gray-700 rounded-full overflow-hidden"> <div className="h-full bg-blue-500 transition-all duration-200" style={{ width: `${exportProgress}%`}} /> </div> <p className="text-sm text-gray-400 mt-2">Do not close this tab</p> </div> )} </div> </div> {/* Timeline & Controls */} <div className="bg-gray-800 rounded-xl p-4 flex flex-col gap-4 shadow-lg"> <div className="flex items-center gap-4"> <button onClick={togglePlay} className="w-10 h-10 flex items-center justify-center rounded-full bg-blue-600 hover:bg-blue-500 transition-colors"> {isPlaying ? <Pause size={20} fill="white" /> : <Play size={20} fill="white" className="ml-0.5" />} </button> <input type="range" min="0" max={duration || 100} step="0.01" value={currentTime} onChange={handleSeek} className="flex-1 h-1.5 bg-gray-600 rounded-lg appearance-none cursor-pointer accent-blue-500" /> <span className="font-mono text-xs w-12 text-right">{currentTime.toFixed(1)}s</span> </div> <div className="flex flex-wrap items-center gap-4 border-t border-gray-700 pt-4"> <button onClick={addKeyframe} className="flex items-center gap-2 px-3 py-2 bg-blue-600/20 text-blue-300 hover:bg-blue-600/30 border border-blue-500/30 rounded-lg text-sm font-medium transition-colors" title="Lock position for selected shape"> <Plus size={16} /> Add Keyframe </button> <button onClick={startExport} disabled={isExporting} className={`ml-auto flex items-center gap-2 px-6 py-2 rounded-lg font-bold text-sm shadow-lg ${isExporting ? 'bg-gray-600 opacity-50' : 'bg-gradient-to-r from-green-600 to-emerald-600 text-white'}`}> <Download size={18} /> Export </button> </div> </div> {/* Keyframes List */} {activeShapeId && ( <div className="bg-gray-800 rounded-xl p-3 shadow-lg flex gap-2 overflow-x-auto items-center min-h-[60px]"> <Key size={14} className="text-gray-500 mr-2" /> {keyframes.filter(k => k.shapeId === activeShapeId).length === 0 ? ( <span className="text-xs text-gray-500 italic">No keyframes for this shape. It will stay static.</span> ) : ( keyframes.filter(k => k.shapeId === activeShapeId).sort((a,b)=>a.time-b.time).map(kf => ( <div key={kf.id} onClick={() => jumpToKeyframe(kf.time)} className={`flex items-center gap-1 px-2 py-1 rounded border text-xs cursor-pointer ${Math.abs(currentTime - kf.time) < 0.2 ? 'bg-blue-900 border-blue-500 text-blue-200' : 'bg-gray-700 border-gray-600 text-gray-300'}`}> <span>{kf.time.toFixed(1)}s</span> <button onClick={(e) => { e.stopPropagation(); deleteKeyframe(kf.id); }} className="hover:text-red-400"><X size={10} /></button> </div> )) )} </div> )} </div> {/* RIGHT COLUMN: PROPERTIES */} <div className="w-full lg:w-72 bg-gray-800 rounded-xl p-4 flex flex-col gap-6 shadow-xl h-fit"> {/* Global Settings */} <div> <h3 className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-3 flex items-center gap-2"> <Settings size={14} /> Blur Settings </h3> <div className="space-y-4"> <div> <div className="flex justify-between text-xs mb-1 text-gray-400"><span>Strength</span><span>{blurStrength}px</span></div> <input type="range" min="0" max="50" value={blurStrength} onChange={(e) => setBlurStrength(parseInt(e.target.value))} className="w-full accent-blue-500" /> </div> <div> <div className="flex justify-between text-xs mb-1 text-gray-400"><span>Fade Edge</span><span>{edgeFeather}px</span></div> <input type="range" min="0" max="50" value={edgeFeather} onChange={(e) => setEdgeFeather(parseInt(e.target.value))} className="w-full accent-purple-500" /> </div> <div className="flex items-center justify-between p-2 bg-gray-700/50 rounded-lg"> <span className="text-sm text-gray-300">Invert Blur</span> <button onClick={() => setInvertBlur(!invertBlur)} className={`w-10 h-5 rounded-full relative transition-colors ${invertBlur ? 'bg-blue-500' : 'bg-gray-600'}`}> <div className={`absolute top-1 w-3 h-3 bg-white rounded-full transition-all ${invertBlur ? 'left-6' : 'left-1'}`} /> </button> </div> </div> </div> <div className="h-px bg-gray-700" /> {/* Shapes List */} <div className="flex-1 flex flex-col gap-2"> <div className="flex justify-between items-center mb-2"> <h3 className="text-xs font-bold text-gray-500 uppercase tracking-wider flex items-center gap-2"> <Layers size={14} /> Shapes </h3> <button onClick={addShape} className="p-1 bg-gray-700 hover:bg-gray-600 rounded text-blue-400"><Plus size={16} /></button> </div> <div className="flex flex-col gap-2 max-h-[300px] overflow-y-auto pr-1"> {shapes.map(shape => ( <div key={shape.id} onClick={() => { setActiveShapeId(shape.id); setManualOverride(false); }} className={`p-2 rounded-lg border flex items-center gap-2 cursor-pointer transition-all ${activeShapeId === shape.id ? 'bg-blue-900/30 border-blue-500/50' : 'bg-gray-700/30 border-gray-700 hover:bg-gray-700'}`} > <button onClick={(e) => toggleShapeVisibility(shape.id, e)} className="text-gray-400 hover:text-white"> {shape.visible ? <Eye size={14} /> : <EyeOff size={14} />} </button> <span className="text-sm flex-1 truncate">{shape.name}</span> {activeShapeId === shape.id && ( <div className="flex items-center gap-1"> <button onClick={(e) => { e.stopPropagation(); toggleShapeType(shape.id); }} className="p-1 hover:bg-gray-600 rounded text-gray-300" title="Toggle Shape"> {shape.type === 'rect' ? <Square size={14} /> : <Circle size={14} />} </button> <button onClick={(e) => { e.stopPropagation(); deleteShape(shape.id); }} className="p-1 hover:bg-red-500/20 hover:text-red-400 rounded text-gray-500"> <Trash2 size={14} /> </button> </div> )} </div> ))} </div> </div> </div> </div> )} </div> </div> ); }; export default BlurVideoEditor;
No revisions found. Save the file to create a backup.
Delete
Update App