EditModeSearchNode3D.tsx
1 import React, { useState, useRef, useEffect } from 'react'; 2 import { Html } from '@react-three/drei'; 3 import { dreamNodeStyles, getNodeColors } from '../../dreamspace/dreamNodeStyles'; 4 import { useInterBrainStore } from '../../store/interbrain-store'; 5 import { semanticSearchService } from '../semantic-search/services/semantic-search-service'; 6 7 interface EditModeSearchNode3DProps { 8 position: [number, number, number]; 9 // Note: onCancel prop removed as escape handling is now managed by global DreamspaceCanvas handler 10 } 11 12 /** 13 * EditModeSearchNode3D - Relationship search interface for edit mode 14 * 15 * Renders on top of EditNode3D when relationship search is active. 16 * Reuses SearchNode3D visual design but integrates with edit mode search functionality. 17 * 18 * Search queries trigger real-time relationship discovery for the editing node. 19 */ 20 export default function EditModeSearchNode3D({ 21 position 22 }: EditModeSearchNode3DProps) { 23 const titleInputRef = useRef<globalThis.HTMLInputElement>(null); 24 25 // Get edit mode state from store 26 const { editMode, setEditModeSearchResults, setSearchResults } = useInterBrainStore(); 27 28 // Local UI state for immediate responsiveness 29 const [localQuery, setLocalQuery] = useState(''); 30 const [isSearching, setIsSearching] = useState(false); 31 const [searchError, setSearchError] = useState<string | null>(null); 32 33 // Debounced search - trigger search 500ms after user stops typing 34 const debounceTimeoutRef = React.useRef<ReturnType<typeof globalThis.setTimeout> | null>(null); 35 36 // Animation state - spawn directly at position (no fly-in needed for edit mode) 37 const [animatedOpacity, setAnimatedOpacity] = useState<number>(0); 38 39 // Handle spawn animation 40 useEffect(() => { 41 // Fade in when component mounts 42 const timer = globalThis.setTimeout(() => { 43 setAnimatedOpacity(1); 44 }, 50); 45 46 // Focus the input after animation and keep it focused 47 const focusTimer = globalThis.setTimeout(() => { 48 titleInputRef.current?.focus(); 49 }, 100); 50 51 return () => { 52 globalThis.clearTimeout(timer); 53 globalThis.clearTimeout(focusTimer); 54 }; 55 }, []); 56 57 // Escape handling is now centralized in DreamspaceCanvas using spatialLayout state 58 59 // Cleanup debounce timeout on unmount 60 useEffect(() => { 61 return () => { 62 if (debounceTimeoutRef.current) { 63 globalThis.clearTimeout(debounceTimeoutRef.current); 64 } 65 }; 66 }, []); 67 68 // Handle query changes with debounced search 69 const handleQueryChange = (e: React.ChangeEvent<globalThis.HTMLInputElement>) => { 70 const newQuery = e.target.value; 71 setLocalQuery(newQuery); 72 setSearchError(null); 73 74 // Clear existing timeout 75 if (debounceTimeoutRef.current) { 76 globalThis.clearTimeout(debounceTimeoutRef.current); 77 } 78 79 // If query is empty, clear results immediately 80 if (!newQuery.trim()) { 81 setEditModeSearchResults([]); 82 return; 83 } 84 85 // Debounced search after 500ms 86 debounceTimeoutRef.current = globalThis.setTimeout(async () => { 87 await performSearch(newQuery.trim()); 88 }, 500); 89 }; 90 91 // Perform the actual search 92 const performSearch = async (query: string) => { 93 if (!editMode.editingNode || !query.trim()) return; 94 95 try { 96 setIsSearching(true); 97 setSearchError(null); 98 99 // Check if semantic search is available 100 const isAvailable = await semanticSearchService.isSemanticSearchAvailable(); 101 if (!isAvailable) { 102 setSearchError('Semantic search not available. Please check Ollama configuration.'); 103 return; 104 } 105 106 console.log(`🔍 [EditModeSearchNode3D] Searching for relationships: "${query}"`); 107 108 // Search for opposite-type nodes for relationship editing 109 const searchResults = await semanticSearchService.searchOppositeTypeNodes( 110 query, 111 editMode.editingNode, 112 { 113 maxResults: 35, // Leave room for center node in honeycomb layout 114 includeSnippets: false // We don't need snippets for relationship editing 115 } 116 ); 117 118 const resultNodes = searchResults.map(result => result.node); 119 console.log(`✅ [EditModeSearchNode3D] Found ${resultNodes.length} related nodes`); 120 121 // Update store with search results for edit mode tracking 122 setEditModeSearchResults(resultNodes); 123 124 // CRITICAL: Set main search results for spatial visualization but stay in edit-search layout 125 // Edit-search mode should maintain its own layout state 126 setSearchResults(resultNodes); 127 // Note: Do NOT change layout - stay in 'edit-search' mode 128 129 } catch (error) { 130 console.error('EditModeSearchNode3D: Search failed:', error); 131 setSearchError(error instanceof Error ? error.message : 'Search failed'); 132 } finally { 133 setIsSearching(false); 134 } 135 }; 136 137 // Handle keyboard shortcuts 138 const handleKeyDown = (e: React.KeyboardEvent) => { 139 if (e.key === 'Enter' && localQuery.trim()) { 140 e.preventDefault(); 141 // Trigger immediate search 142 if (debounceTimeoutRef.current) { 143 globalThis.clearTimeout(debounceTimeoutRef.current); 144 } 145 performSearch(localQuery.trim()); 146 } 147 // Note: Escape handling is now managed by global DreamspaceCanvas handler 148 }; 149 150 // Note: handleCancel was removed as escape handling is now managed by global DreamspaceCanvas handler 151 152 // Node styling - 2/3 the size of EditNode3D for more compact search interface 153 const nodeSize = 133; // 2/3 of 200px 154 const nodeColors = getNodeColors('dream'); // Always use dream style for search 155 156 return ( 157 <group position={position}> 158 {/* HTML Interface - No 3D geometry needed for pure UI overlay */} 159 <Html 160 center 161 distanceFactor={200} 162 style={{ 163 pointerEvents: 'auto', 164 userSelect: 'none', 165 opacity: animatedOpacity 166 }} 167 > 168 <div 169 style={{ 170 // Remove fixed dimensions to eliminate rectangular blocking 171 display: 'flex', 172 flexDirection: 'column', 173 alignItems: 'center', 174 justifyContent: 'center', 175 position: 'relative', 176 pointerEvents: 'none' // Allow clicks to pass through container 177 }} 178 > 179 {/* Search Input */} 180 <input 181 ref={titleInputRef} 182 type="text" 183 value={localQuery} 184 onChange={handleQueryChange} 185 onKeyDown={handleKeyDown} 186 onFocus={() => { 187 // Maintain focus highlight permanently while in search mode 188 if (titleInputRef.current) { 189 titleInputRef.current.style.borderColor = nodeColors.border; 190 } 191 }} 192 onBlur={(e) => { 193 // Immediately refocus to maintain persistent highlight 194 e.target.focus(); 195 }} 196 placeholder="Search relationships..." 197 style={{ 198 position: 'relative', 199 width: `${nodeSize * 0.9}px`, // 2/3 of original 200px = 120px (133 * 0.9) 200 height: `${nodeSize * 0.15}px`, // Proportionally smaller height 201 padding: `${nodeSize * 0.03}px ${nodeSize * 0.04}px`, 202 background: 'rgba(0, 0, 0, 1.0)', // Fully opaque black background 203 border: `2px solid ${nodeColors.border}`, 204 borderRadius: `${nodeSize * 0.075}px`, // Pill shape - semicircles on ends 205 color: 'white', 206 fontSize: `${nodeSize * 0.06}px`, // 75% of original size (0.08 * 0.75 = 0.06) 207 fontFamily: dreamNodeStyles.typography.fontFamily, 208 textAlign: 'center', 209 outline: 'none', // Remove gray browser outline completely 210 boxShadow: 'none', // Remove any default focus shadow 211 pointerEvents: 'auto' // Enable clicks only on the input itself 212 }} 213 /> 214 215 {/* Elegant Spinning Ring Loading Indicator */} 216 {isSearching && ( 217 <div 218 style={{ 219 position: 'absolute', 220 right: `${nodeSize * 0.075}px`, // Distance from right edge to center of right semicircle 221 top: '50%', 222 transform: 'translate(50%, -50%)', // Center the circle on the right semicircle center 223 width: `${nodeSize * 0.12}px`, // Full background circle size 224 height: `${nodeSize * 0.12}px`, 225 pointerEvents: 'none' 226 }} 227 > 228 {/* Background circle - opaque black to hide text behind (full size) */} 229 <div 230 style={{ 231 position: 'absolute', 232 width: '100%', 233 height: '100%', 234 borderRadius: '50%', 235 background: 'rgba(0, 0, 0, 1.0)' 236 }} 237 /> 238 239 {/* Spinning gradient ring - 75% size of background circle */} 240 <div 241 style={{ 242 position: 'absolute', 243 top: '12.5%', // Center the 75% sized ring: (100% - 75%) / 2 = 12.5% 244 left: '12.5%', 245 width: '75%', // 75% of the background circle size 246 height: '75%', 247 borderRadius: '50%', 248 background: `conic-gradient(from 0deg, transparent 0%, transparent 75%, ${nodeColors.border} 100%)`, 249 mask: 'radial-gradient(circle, transparent 60%, black 65%)', // Creates ring effect 250 WebkitMask: 'radial-gradient(circle, transparent 60%, black 65%)', // Safari support 251 animation: 'spin 1s linear infinite', 252 opacity: 0.9 253 }} 254 /> 255 256 {/* CSS keyframe animation */} 257 <style> 258 {` 259 @keyframes spin { 260 from { transform: rotate(0deg); } 261 to { transform: rotate(360deg); } 262 } 263 `} 264 </style> 265 </div> 266 )} 267 268 {/* Error Status - only show errors, not searching text */} 269 {searchError && ( 270 <div 271 style={{ 272 position: 'relative', 273 marginTop: '8px', 274 fontSize: '12px', 275 color: '#ff6b6b', 276 textAlign: 'center', 277 whiteSpace: 'nowrap', 278 pointerEvents: 'none' 279 }} 280 > 281 {searchError} 282 </div> 283 )} 284 285 {/* Close button removed - escape key only */} 286 </div> 287 </Html> 288 </group> 289 ); 290 }