/ src / features / edit-mode / EditModeSearchNode3D.tsx
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  }