/ src / features / search / SearchNode3D.tsx
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  }