SearchNode3D.tsx
1 import React, { useState, useRef, useEffect } from 'react'; 2 import { Html } from '@react-three/drei'; 3 import { useFrame } from '@react-three/fiber'; 4 import { dreamNodeStyles, getNodeColors, getNodeGlow, getMediaContainerStyle, getMediaOverlayStyle } from '../../dreamspace/dreamNodeStyles'; 5 import { useInterBrainStore } from '../../store/interbrain-store'; 6 7 interface SearchNode3DProps { 8 position: [number, number, number]; 9 onSave: (query: string, dreamTalkFile?: globalThis.File, additionalFiles?: globalThis.File[]) => void; 10 onCancel: () => void; 11 } 12 13 /** 14 * SearchNode3D - Search interface that visually appears as a Dream-type DreamNode 15 * 16 * Extends ProtoNode3D architecture for consistent UI/UX. The search query becomes 17 * the title input, and supports drag-and-drop for multi-modal search functionality. 18 * 19 * Animation: Spawns from sphere surface (5000 units) and flies to focus position (50 units) 20 * using easeOutQuart over 1 second to match spatial orchestration timing. 21 */ 22 export default function SearchNode3D({ 23 position, 24 onSave, 25 onCancel 26 }: SearchNode3DProps) { 27 const titleInputRef = useRef<globalThis.HTMLInputElement>(null); 28 const fileInputRef = useRef<globalThis.HTMLInputElement>(null); 29 30 // Get search state from store 31 const { searchInterface, setSearchQuery } = useInterBrainStore(); 32 33 // Local UI state for immediate responsiveness 34 const [localQuery, setLocalQuery] = useState(searchInterface.currentQuery); 35 const [isDragOver, setIsDragOver] = useState(false); 36 const [previewMedia, setPreviewMedia] = useState<string | null>(null); 37 const [dreamTalkFile, setDreamTalkFile] = useState<globalThis.File | null>(null); 38 const [additionalFiles, setAdditionalFiles] = useState<globalThis.File[]>([]); 39 const [isAnimating, setIsAnimating] = useState(false); 40 41 // Debounced store update - only update store 300ms after user stops typing 42 const debounceTimeoutRef = React.useRef<number | null>(null); 43 44 // Animation state - handles both spawn and save animations 45 const [animatedPosition, setAnimatedPosition] = useState<[number, number, number]>([ 46 position[0], 47 position[1], 48 -5000 // Start at sphere surface distance 49 ]); 50 const [animatedOpacity, setAnimatedOpacity] = useState<number>(1.0); 51 const [animatedUIOpacity, setAnimatedUIOpacity] = useState<number>(0.0); // UI starts hidden during spawn 52 const animationStartTime = useRef<number | null>(Date.now()); // Start animation immediately 53 const [animationType, setAnimationType] = useState<'spawn' | 'save'>('spawn'); 54 55 // Animation handler: supports both spawn and save animations 56 useFrame(() => { 57 if (!animationStartTime.current) return; 58 59 const elapsed = Date.now() - animationStartTime.current; 60 const progress = Math.min(elapsed / 1000, 1); // 1 second duration 61 62 if (animationType === 'spawn') { 63 // Spawn animation: 5000 → 50 units using easeOutQuart 64 const easeOutQuart = 1 - Math.pow(1 - progress, 4); 65 66 // Animate position: [0,0,-5000] → [0,0,-50] 67 const startZ = -5000; 68 const endZ = position[2]; // Target position (-50) 69 const newZ = startZ + (endZ - startZ) * easeOutQuart; 70 setAnimatedPosition([position[0], position[1], newZ]); 71 72 // Keep main node fully visible during spawn 73 setAnimatedOpacity(1.0); 74 75 // Animate UI elements: 0.0 → 1.0 (fade in UI controls after spawn) 76 const startUIOpacity = 0.0; 77 const endUIOpacity = 1.0; 78 const newUIOpacity = startUIOpacity + (endUIOpacity - startUIOpacity) * easeOutQuart; 79 setAnimatedUIOpacity(newUIOpacity); 80 81 // Complete spawn animation 82 if (progress >= 1) { 83 animationStartTime.current = null; 84 setAnimatedPosition(position); 85 setAnimatedOpacity(1.0); 86 setAnimatedUIOpacity(1.0); 87 88 // Auto-focus search input after spawn animation 89 globalThis.setTimeout(() => { 90 titleInputRef.current?.focus(); 91 }, 50); 92 } 93 } else if (animationType === 'save') { 94 // Save animation: current position → [0,0,-75] using easeInOut (like ProtoNode) 95 const easeInOut = progress < 0.5 96 ? 2 * progress * progress 97 : 1 - Math.pow(-2 * progress + 2, 2) / 2; 98 99 // Animate position: [0,0,-50] → [0,0,-75] 100 const startZ = position[2]; // -50 (current focus position) 101 const endZ = -75; // Move away like ProtoNode 102 const newZ = startZ + (endZ - startZ) * easeInOut; 103 setAnimatedPosition([position[0], position[1], newZ]); 104 105 // Keep main node opacity at 1.0 (no fade) 106 setAnimatedOpacity(1.0); 107 108 // Animate UI elements: 1.0 → 0.0 (fade out buttons/controls) 109 const startUIOpacity = 1.0; 110 const endUIOpacity = 0.0; 111 const newUIOpacity = startUIOpacity + (endUIOpacity - startUIOpacity) * easeInOut; 112 setAnimatedUIOpacity(newUIOpacity); 113 114 // Complete save animation 115 if (progress >= 1) { 116 animationStartTime.current = null; 117 setAnimatedPosition([position[0], position[1], endZ]); 118 setAnimatedOpacity(1.0); 119 setAnimatedUIOpacity(endUIOpacity); 120 } 121 } 122 }); 123 124 // Auto-focus on mount (fallback if animation completes before useEffect) 125 useEffect(() => { 126 if (!animationStartTime.current) { 127 globalThis.setTimeout(() => { 128 titleInputRef.current?.focus(); 129 }, 50); 130 } 131 }, []); 132 133 // Cleanup debounce timeout on unmount 134 useEffect(() => { 135 return () => { 136 if (debounceTimeoutRef.current) { 137 globalThis.clearTimeout(debounceTimeoutRef.current); 138 } 139 }; 140 }, []); 141 142 // Sync local query with store query (for external resets) 143 useEffect(() => { 144 setLocalQuery(searchInterface.currentQuery); 145 }, [searchInterface.currentQuery]); 146 147 // Dream-type node styling for search node (blue) 148 const nodeColors = getNodeColors('dream'); 149 const nodeSize = dreamNodeStyles.dimensions.nodeSizeThreeD; 150 const borderWidth = dreamNodeStyles.dimensions.borderWidth; 151 152 // Debounced store update function 153 const updateStoreQuery = React.useCallback((query: string) => { 154 setSearchQuery(query); 155 }, [setSearchQuery]); 156 157 // Event handlers - immediate local state update, debounced store update 158 const handleQueryChange = (e: React.ChangeEvent<globalThis.HTMLInputElement>) => { 159 const query = e.target.value; 160 161 // IMMEDIATE: Update local state for responsive UI 162 setLocalQuery(query); 163 164 // DEBOUNCED: Update store only after user stops typing for 300ms 165 if (debounceTimeoutRef.current) { 166 globalThis.clearTimeout(debounceTimeoutRef.current); 167 } 168 169 debounceTimeoutRef.current = globalThis.setTimeout(() => { 170 updateStoreQuery(query); 171 }, 300) as unknown as number; 172 }; 173 174 const handleDragOver = (e: React.DragEvent) => { 175 e.preventDefault(); 176 e.stopPropagation(); // Prevent bubbling to DreamspaceCanvas 177 setIsDragOver(true); 178 }; 179 180 const handleDragLeave = (e: React.DragEvent) => { 181 e.preventDefault(); 182 e.stopPropagation(); // Prevent bubbling to DreamspaceCanvas 183 setIsDragOver(false); 184 }; 185 186 const handleDrop = (e: React.DragEvent) => { 187 e.preventDefault(); 188 e.stopPropagation(); // CRITICAL: Prevent bubbling to DreamspaceCanvas 189 setIsDragOver(false); 190 191 const files = Array.from(e.dataTransfer.files); 192 const file = files[0]; 193 194 if (file) { 195 if (isValidMediaFile(file)) { 196 // Set as DreamTalk media 197 setDreamTalkFile(file); 198 const previewUrl = globalThis.URL.createObjectURL(file); 199 setPreviewMedia(previewUrl); 200 } else { 201 // Add as additional file 202 setAdditionalFiles(prev => [...prev, file]); 203 } 204 } 205 }; 206 207 const handleFileSelect = (e: React.ChangeEvent<globalThis.HTMLInputElement>) => { 208 const file = e.target.files?.[0]; 209 if (file) { 210 if (isValidMediaFile(file)) { 211 setDreamTalkFile(file); 212 const previewUrl = globalThis.URL.createObjectURL(file); 213 setPreviewMedia(previewUrl); 214 } 215 } 216 }; 217 218 const handleSave = () => { 219 if (localQuery.trim()) { 220 setIsAnimating(true); 221 222 // Switch to save animation and start timing 223 setAnimationType('save'); 224 animationStartTime.current = Date.now(); 225 226 // Mark that save animation is in progress (keeps SearchNode rendered) 227 const store = useInterBrainStore.getState(); 228 store.setSearchSaving(true); 229 230 // IMMEDIATELY trigger constellation return to run in parallel with save animation 231 // This makes all nodes start moving from sphere surface to constellation NOW 232 store.setSpatialLayout('constellation'); 233 234 // Complete exactly when animation finishes (node will be fully faded out) 235 globalThis.setTimeout(() => { 236 setIsAnimating(false); 237 onSave(localQuery, dreamTalkFile || undefined, additionalFiles); 238 239 // Keep SearchNode rendered for 200ms longer to ensure temporal overlap 240 // This prevents flicker by ensuring new DreamNode is fully rendered before unmount 241 globalThis.setTimeout(() => { 242 store.setSearchSaving(false); // Now safe to unmount SearchNode 243 }, 200); // Extended overlap to guarantee DreamNode is rendered 244 }, 1000); // Exactly when animation completes 245 } 246 }; 247 248 const handleCancel = () => { 249 // Clean up preview URL if exists 250 if (previewMedia) { 251 globalThis.URL.revokeObjectURL(previewMedia); 252 } 253 onCancel(); 254 }; 255 256 // Keyboard handler for the entire component 257 const handleKeyDown = (e: React.KeyboardEvent) => { 258 if (e.key === 'Enter' && !e.shiftKey) { 259 e.preventDefault(); 260 handleSave(); 261 } 262 // Note: Escape handling is now managed by global DreamspaceCanvas handler 263 }; 264 265 const isSaveDisabled = !localQuery.trim() || isAnimating; 266 267 return ( 268 <group position={animatedPosition}> 269 <Html 270 position={[0, 0, 0]} 271 center 272 transform 273 sprite 274 distanceFactor={10} 275 style={{ 276 pointerEvents: 'auto', 277 userSelect: 'none' 278 }} 279 > 280 <div 281 onKeyDown={handleKeyDown} 282 data-ui-element="search-node" 283 onMouseDown={(e) => e.stopPropagation()} // Prevent sphere rotation 284 onMouseMove={(e) => e.stopPropagation()} // Prevent sphere rotation 285 onMouseUp={(e) => e.stopPropagation()} // Prevent sphere rotation 286 onClick={(e) => e.stopPropagation()} // Prevent any click propagation 287 > 288 {/* Main Search Node Circle - Blue Dream-type styling */} 289 <div 290 style={{ 291 width: `${nodeSize}px`, 292 height: `${nodeSize}px`, 293 borderRadius: dreamNodeStyles.dimensions.borderRadius, 294 border: `${borderWidth}px solid ${nodeColors.border}`, 295 background: nodeColors.fill, 296 overflow: 'hidden', 297 position: 'relative', 298 opacity: animatedOpacity, 299 transition: dreamNodeStyles.transitions.creation, 300 boxShadow: getNodeGlow('dream', 15), // Blue dream glow 301 fontFamily: dreamNodeStyles.typography.fontFamily 302 }} 303 onDragOver={handleDragOver} 304 onDragLeave={handleDragLeave} 305 onDrop={handleDrop} 306 > 307 {/* DreamTalk Media Area or Drop Zone */} 308 {previewMedia || dreamTalkFile ? ( 309 <div 310 style={{ 311 ...getMediaContainerStyle(), 312 opacity: animatedOpacity 313 }} 314 > 315 {previewMedia && ( 316 <img 317 src={previewMedia} 318 alt="DreamTalk preview" 319 style={{ 320 width: '100%', 321 height: '100%', 322 objectFit: 'cover' 323 }} 324 /> 325 )} 326 <div style={getMediaOverlayStyle()} /> 327 </div> 328 ) : ( 329 <div 330 style={{ 331 width: '100%', 332 height: '100%', 333 position: 'relative', 334 cursor: 'pointer', 335 border: isDragOver ? '2px dashed rgba(255,255,255,0.5)' : 'none', 336 borderRadius: '50%', 337 zIndex: 1, // Lower z-index than text input 338 pointerEvents: 'auto', 339 opacity: animatedUIOpacity // Fade in with other UI 340 }} 341 onClick={(e) => { 342 e.stopPropagation(); 343 e.preventDefault(); 344 fileInputRef.current?.click(); 345 }} 346 onMouseDown={(e) => { 347 e.stopPropagation(); 348 e.preventDefault(); 349 }} 350 > 351 <div 352 style={{ 353 position: 'absolute', 354 top: '75%', 355 left: '50%', 356 transform: 'translate(-50%, -50%)', 357 color: dreamNodeStyles.colors.text.secondary, 358 fontSize: '24px', 359 textAlign: 'center', 360 whiteSpace: 'nowrap', 361 pointerEvents: 'none' // Let parent handle clicks 362 }} 363 > 364 <div>Drop image here</div> 365 <div>or click to browse</div> 366 </div> 367 </div> 368 )} 369 370 {/* Text Input - Clean, rectangular, always positioned in center */} 371 <input 372 ref={titleInputRef} 373 type="text" 374 value={localQuery} 375 onChange={handleQueryChange} 376 placeholder="Search query..." 377 style={{ 378 position: 'absolute', 379 top: '50%', 380 left: '50%', 381 transform: 'translate(-50%, -50%)', 382 width: `${Math.max(120, nodeSize * 0.7)}px`, // Responsive width 383 height: `${Math.max(32, nodeSize * 0.12)}px`, // Adequate height for descenders 384 padding: `${Math.max(8, nodeSize * 0.02)}px ${Math.max(12, nodeSize * 0.03)}px`, 385 background: 'transparent', // Completely transparent 386 border: 'none', // No border 387 borderRadius: '4px', // Rectangular with subtle rounding 388 color: dreamNodeStyles.colors.text.primary, 389 fontSize: `${Math.max(14, nodeSize * 0.08)}px`, 390 fontFamily: dreamNodeStyles.typography.fontFamily, 391 textAlign: 'center', 392 outline: 'none', 393 boxShadow: 'none', 394 zIndex: 10, // Above file selection area 395 pointerEvents: 'auto', 396 cursor: 'text' 397 }} 398 onClick={(e) => e.stopPropagation()} 399 /> 400 401 {/* Hidden file input */} 402 <input 403 ref={fileInputRef} 404 type="file" 405 accept="image/*,video/*,.pdf,.txt,.md,.doc,.docx" 406 onChange={handleFileSelect} 407 style={{ display: 'none' }} 408 /> 409 </div> 410 411 {/* Additional Files Indicator */} 412 {additionalFiles.length > 0 && ( 413 <div 414 style={{ 415 position: 'absolute', 416 top: `${nodeSize + 10}px`, 417 left: '50%', 418 transform: 'translateX(-50%)', 419 background: 'rgba(100, 100, 255, 0.8)', 420 color: 'white', 421 padding: '4px 8px', 422 borderRadius: '4px', 423 fontSize: '12px', 424 whiteSpace: 'nowrap', 425 opacity: animatedUIOpacity 426 }} 427 > 428 {additionalFiles.length} file{additionalFiles.length > 1 ? 's' : ''} added 429 </div> 430 )} 431 432 {/* Action Buttons */} 433 <div 434 style={{ 435 position: 'absolute', 436 top: `${nodeSize + (additionalFiles.length > 0 ? 60 : 40)}px`, 437 left: '50%', 438 transform: 'translateX(-50%)', 439 display: 'flex', 440 gap: '12px', 441 opacity: animatedUIOpacity 442 }} 443 > 444 <button 445 onClick={(e) => { 446 e.stopPropagation(); 447 handleCancel(); 448 }} 449 onMouseDown={(e) => e.stopPropagation()} 450 disabled={isAnimating} 451 style={{ 452 padding: `${Math.max(8, nodeSize * 0.02)}px ${Math.max(16, nodeSize * 0.04)}px`, 453 border: '1px solid rgba(255,255,255,0.5)', 454 background: 'transparent', 455 color: isAnimating ? 'rgba(255,255,255,0.5)' : 'white', 456 fontSize: `${Math.max(14, nodeSize * 0.035)}px`, 457 fontFamily: dreamNodeStyles.typography.fontFamily, 458 borderRadius: `${Math.max(4, nodeSize * 0.01)}px`, 459 cursor: isAnimating ? 'not-allowed' : 'pointer', 460 transition: dreamNodeStyles.transitions.default 461 }} 462 > 463 Cancel 464 </button> 465 <button 466 onClick={(e) => { 467 e.stopPropagation(); 468 handleSave(); 469 }} 470 onMouseDown={(e) => e.stopPropagation()} 471 disabled={isSaveDisabled} 472 style={{ 473 padding: `${Math.max(8, nodeSize * 0.02)}px ${Math.max(16, nodeSize * 0.04)}px`, 474 border: 'none', 475 background: isSaveDisabled 476 ? 'rgba(255,255,255,0.3)' 477 : nodeColors.border, 478 color: isSaveDisabled ? 'rgba(255,255,255,0.5)' : 'white', 479 fontSize: `${Math.max(14, nodeSize * 0.035)}px`, 480 fontFamily: dreamNodeStyles.typography.fontFamily, 481 borderRadius: `${Math.max(4, nodeSize * 0.01)}px`, 482 cursor: isSaveDisabled ? 'not-allowed' : 'pointer', 483 transition: dreamNodeStyles.transitions.default 484 }} 485 > 486 {isAnimating ? 'Saving...' : 'Save'} 487 </button> 488 </div> 489 </div> 490 </Html> 491 </group> 492 ); 493 } 494 495 /** 496 * Validate media file types for DreamTalk (reused from ProtoNode3D) 497 */ 498 function isValidMediaFile(file: globalThis.File): boolean { 499 const validTypes = [ 500 'image/png', 501 'image/jpeg', 502 'image/jpg', 503 'image/gif', 504 'image/webp', 505 'video/mp4', 506 'video/webm', 507 // .link files may appear as text/plain or application/octet-stream 508 'text/plain', 509 'application/octet-stream' 510 ]; 511 512 // Check file extension as fallback for unreliable MIME types 513 const fileName = file.name.toLowerCase(); 514 if (fileName.endsWith('.link')) { 515 return true; 516 } 517 518 return validTypes.includes(file.type); 519 }