/ src / features / edit-mode / EditNode3D.tsx
EditNode3D.tsx
  1  import React, { useState, useRef, useCallback, 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  import { setIcon } from 'obsidian';
  7  
  8  interface EditNode3DProps {
  9    position: [number, number, number];
 10    onSave: () => void;
 11    onCancel: () => void;
 12    onToggleSearchOff?: () => Promise<void>;
 13  }
 14  
 15  /**
 16   * EditNode3D - Unified in-space editing UI extending ProtoNode patterns
 17   * 
 18   * Reuses ProtoNode3D logic for consistency but works with existing DreamNode data.
 19   * Provides the same title editing, type selection, and DreamTalk file integration
 20   * but pre-populated with existing node data.
 21   */
 22  export default function EditNode3D({ 
 23    position,
 24    onSave,
 25    onCancel,
 26    onToggleSearchOff
 27  }: EditNode3DProps) {
 28    const titleInputRef = useRef<globalThis.HTMLInputElement>(null);
 29    const fileInputRef = useRef<globalThis.HTMLInputElement>(null);
 30    
 31    // Get edit mode state from store
 32    const { editMode, updateEditingNodeMetadata, setEditModeNewDreamTalkFile, setEditModeValidationErrors, setEditModeSearchActive } = useInterBrainStore();
 33    const { editingNode, validationErrors, newDreamTalkFile } = editMode;
 34    
 35    // Local UI state for immediate responsiveness
 36    const [localTitle, setLocalTitle] = useState(editingNode?.name || '');
 37    const [localEmail, setLocalEmail] = useState('');
 38    const [localPhone, setLocalPhone] = useState('');
 39    const [localRadicleId, setLocalRadicleId] = useState('');
 40    const [isDragOver, setIsDragOver] = useState(false);
 41    const [previewMedia, setPreviewMedia] = useState<string | null>(null);
 42    const [isAnimating, setIsAnimating] = useState(false);
 43    
 44    // Debounced store updates - only update store 300ms after user stops typing
 45    const debounceTimeoutRef = useRef<number | null>(null);
 46    
 47    // Animation state (reuse ProtoNode3D patterns)
 48    // Position no longer animates during save - stays constant
 49    const [animatedOpacity, setAnimatedOpacity] = useState<number>(1.0);
 50    const [animatedUIOpacity, setAnimatedUIOpacity] = useState<number>(1.0);
 51    const animationStartTime = useRef<number | null>(null);
 52    
 53    // Animation frame loop for UI fade out only
 54    useFrame(() => {
 55      if (!animationStartTime.current) return;
 56      
 57      const elapsed = Date.now() - animationStartTime.current;
 58      const progress = Math.min(elapsed / 1000, 1); // 1 second duration
 59      
 60      // Ease-in-out function for smooth transition
 61      const easeInOut = progress < 0.5 
 62        ? 2 * progress * progress 
 63        : 1 - Math.pow(-2 * progress + 2, 2) / 2;
 64      
 65      // Keep main node fully visible - it transitions to the actual DreamNode
 66      setAnimatedOpacity(1.0);
 67      
 68      // Fade out UI controls (buttons, type toggle, validation) smoothly
 69      const startUIOpacity = 1.0;
 70      const endUIOpacity = 0.0;
 71      const newUIOpacity = startUIOpacity + (endUIOpacity - startUIOpacity) * easeInOut;
 72      setAnimatedUIOpacity(newUIOpacity);
 73      
 74      // Complete animation
 75      if (progress >= 1) {
 76        animationStartTime.current = null;
 77        setAnimatedOpacity(1.0);
 78        setAnimatedUIOpacity(endUIOpacity);
 79      }
 80    });
 81    
 82    // Maintain persistent focus on text input when type changes (ProtoNode3D pattern)
 83    useEffect(() => {
 84      if (editingNode && titleInputRef.current) {
 85        titleInputRef.current.focus();
 86      }
 87    }, [editingNode?.type]);
 88    
 89    // Handle pre-filled dreamTalkMedia from existing node or new file
 90    useEffect(() => {
 91      if (newDreamTalkFile && !previewMedia) {
 92        // Prioritize new file over existing media
 93        const previewUrl = globalThis.URL.createObjectURL(newDreamTalkFile);
 94        setPreviewMedia(previewUrl);
 95        console.log(`[EditNode3D] Showing new DreamTalk file: ${newDreamTalkFile.name}`);
 96      } else if (editingNode?.dreamTalkMedia.length && !previewMedia) {
 97        // Fall back to existing media
 98        const existingMedia = editingNode.dreamTalkMedia[0];
 99        if (existingMedia.data) {
100          console.log(`[EditNode3D] Loading existing DreamTalk media: ${existingMedia.path}`);
101          setPreviewMedia(existingMedia.data);
102        }
103      }
104    }, [editingNode?.dreamTalkMedia, newDreamTalkFile, previewMedia]);
105    
106    // Cleanup debounce timeout on unmount
107    useEffect(() => {
108      return () => {
109        if (debounceTimeoutRef.current) {
110          globalThis.clearTimeout(debounceTimeoutRef.current);
111        }
112      };
113    }, []);
114    
115    // Sync local title with store title (for external updates)
116    useEffect(() => {
117      if (editingNode?.name !== undefined) {
118        setLocalTitle(editingNode.name);
119      }
120    }, [editingNode?.name]);
121  
122    // Load contact info from editingNode (for dreamer nodes only)
123    // Only reload when node ID changes, not when email/phone/radicleId fields update
124    useEffect(() => {
125      if (!editingNode || editingNode.type !== 'dreamer') {
126        setLocalEmail('');
127        setLocalPhone('');
128        setLocalRadicleId('');
129        return;
130      }
131  
132      // Load from the DreamNode which is already populated by the service layer
133      setLocalEmail(editingNode.email || '');
134      setLocalPhone(editingNode.phone || '');
135      setLocalRadicleId(editingNode.radicleId || '');
136    }, [editingNode?.id, editingNode?.type]); // Only depend on ID and type, not the whole object
137    
138    if (!editingNode) {
139      return null; // Should not render if no node is being edited
140    }
141    
142    const nodeColors = getNodeColors(editingNode.type);
143    const nodeSize = dreamNodeStyles.dimensions.nodeSizeThreeD;
144    const borderWidth = dreamNodeStyles.dimensions.borderWidth;
145    
146    // Validation handlers (same as ProtoNode3D)
147    const validateTitle = useCallback((title: string) => {
148      const errors: Record<string, string> = {};
149      
150      if (!title.trim()) {
151        errors.title = 'Title is required';
152      } else if (title.length > 255) {
153        errors.title = 'Title must be less than 255 characters';
154      } else if (/[<>:"/\\|?*]/.test(title)) {
155        errors.title = 'Title contains invalid characters';
156      }
157      
158      setEditModeValidationErrors({ ...validationErrors, title: errors.title });
159      return Object.keys(errors).length === 0;
160    }, [setEditModeValidationErrors, validationErrors]);
161    
162    // Debounced store update functions
163    const updateStoreTitle = useCallback((title: string) => {
164      updateEditingNodeMetadata({ name: title });
165    }, [updateEditingNodeMetadata]);
166    
167    const debounceValidation = useCallback((title: string) => {
168      validateTitle(title);
169    }, [validateTitle]);
170    
171    // Event handlers - immediate local state update, debounced store updates
172    const handleTitleChange = (e: React.ChangeEvent<globalThis.HTMLInputElement>) => {
173      const title = e.target.value;
174      
175      // IMMEDIATE: Update local state for responsive UI
176      setLocalTitle(title);
177      
178      // DEBOUNCED: Update store and validation only after user stops typing for 300ms
179      if (debounceTimeoutRef.current) {
180        globalThis.clearTimeout(debounceTimeoutRef.current);
181      }
182      
183      debounceTimeoutRef.current = globalThis.setTimeout(() => {
184        updateStoreTitle(title);
185        debounceValidation(title);
186      }, 300) as unknown as number;
187    };
188    
189    const handleTypeChange = (type: 'dream' | 'dreamer') => {
190      updateEditingNodeMetadata({ type });
191  
192      // TODO: Add warning about type change affecting relationships
193      console.warn('Type change in edit mode - relationship implications need to be handled');
194  
195      // Refocus text input after type change to maintain persistent focus
196      globalThis.setTimeout(() => {
197        titleInputRef.current?.focus();
198      }, 0);
199    };
200  
201    const handleEmailChange = (e: React.ChangeEvent<globalThis.HTMLInputElement>) => {
202      const email = e.target.value;
203      setLocalEmail(email);
204      updateEditingNodeMetadata({ email });
205    };
206  
207    const handlePhoneChange = (e: React.ChangeEvent<globalThis.HTMLInputElement>) => {
208      const phone = e.target.value;
209      setLocalPhone(phone);
210      updateEditingNodeMetadata({ phone });
211    };
212  
213    const handleRadicleIdChange = (e: React.ChangeEvent<globalThis.HTMLInputElement>) => {
214      const radicleId = e.target.value;
215      setLocalRadicleId(radicleId);
216      updateEditingNodeMetadata({ radicleId });
217    };
218    
219    // File handling (same patterns as ProtoNode3D)
220    const handleDragOver = (e: React.DragEvent) => {
221      e.preventDefault();
222      e.stopPropagation();
223      setIsDragOver(true);
224    };
225    
226    const handleDragLeave = (e: React.DragEvent) => {
227      e.preventDefault();
228      e.stopPropagation();
229      setIsDragOver(false);
230    };
231    
232    const handleDrop = (e: React.DragEvent) => {
233      e.preventDefault();
234      e.stopPropagation();
235      setIsDragOver(false);
236      
237      const files = Array.from(e.dataTransfer.files);
238      const file = files[0];
239      
240      if (file && isValidMediaFile(file)) {
241        console.log(`[EditNode3D] New DreamTalk media dropped: ${file.name}`);
242        const previewUrl = globalThis.URL.createObjectURL(file);
243        setPreviewMedia(previewUrl);
244        
245        // Store the new file in edit mode state for save processing
246        setEditModeNewDreamTalkFile(file);
247      }
248    };
249    
250    const handleFileSelect = (e: React.ChangeEvent<globalThis.HTMLInputElement>) => {
251      const file = e.target.files?.[0];
252      if (file && isValidMediaFile(file)) {
253        console.log(`[EditNode3D] New DreamTalk media selected: ${file.name}`);
254        const previewUrl = globalThis.URL.createObjectURL(file);
255        setPreviewMedia(previewUrl);
256        
257        // Store the new file in edit mode state for save processing
258        setEditModeNewDreamTalkFile(file);
259      }
260    };
261    
262    const handleSave = async () => {
263      if (validateTitle(localTitle)) {
264        setIsAnimating(true);
265  
266        // Start save animation (fading out UI controls)
267        animationStartTime.current = Date.now();
268  
269        // Call onSave to trigger data persistence and liminal web transition
270        // The parent component (EditModeOverlay) will handle saving all metadata including contact info
271        onSave();
272  
273        // The animation continues running to fade out the UI controls
274        // EditModeOverlay will handle exit after successful save
275        globalThis.setTimeout(() => {
276          setIsAnimating(false);
277        }, 1000);
278      }
279    };
280    
281    const handleCancel = () => {
282      // Clean up preview URL if exists
283      if (previewMedia) {
284        globalThis.URL.revokeObjectURL(previewMedia);
285      }
286      onCancel();
287    };
288    
289    // Toggle relationship search interface
290    const handleToggleRelationshipSearch = () => {
291      if (editMode.isSearchingRelationships) {
292        // Turning OFF search mode - filter to pending relationships
293        if (onToggleSearchOff) {
294          onToggleSearchOff();
295        } else {
296          // Fallback - just turn off search without filtering
297          setEditModeSearchActive(false);
298        }
299      } else {
300        // Turning ON search mode - just activate the interface
301        setEditModeSearchActive(true);
302      }
303    };
304  
305    // Keyboard handler (same as ProtoNode3D)
306    const handleKeyDown = (e: React.KeyboardEvent) => {
307      if (e.key === 'Enter' && !e.shiftKey) {
308        e.preventDefault();
309        handleSave();
310      }
311      // Note: Escape handling is now managed by global DreamspaceCanvas handler
312    };
313    
314    const isSaveDisabled = !localTitle.trim() || !!validationErrors.title || isAnimating;
315    
316    return (
317      <group position={position}>
318        <Html
319          position={[0, 0, 0]}
320          center
321          transform
322          sprite
323          distanceFactor={10}
324          style={{
325            pointerEvents: 'auto',
326            userSelect: 'none'
327          }}
328        >
329          <div 
330            onKeyDown={handleKeyDown} 
331            data-ui-element="edit-node"
332            onMouseDown={(e) => e.stopPropagation()}
333            onMouseMove={(e) => e.stopPropagation()}
334            onMouseUp={(e) => e.stopPropagation()}
335            onClick={(e) => e.stopPropagation()}
336          >
337            {/* Main Edit Node Circle */}
338            <div
339              style={{
340                width: `${nodeSize}px`,
341                height: `${nodeSize}px`,
342                borderRadius: dreamNodeStyles.dimensions.borderRadius,
343                border: `${borderWidth}px solid ${nodeColors.border}`,
344                background: nodeColors.fill,
345                overflow: 'hidden',
346                position: 'relative',
347                opacity: animatedOpacity,
348                transition: dreamNodeStyles.transitions.creation,
349                boxShadow: getNodeGlow(editingNode.type, 15),
350                fontFamily: dreamNodeStyles.typography.fontFamily
351              }}
352              onDragOver={handleDragOver}
353              onDragLeave={handleDragLeave}
354              onDrop={handleDrop}
355            >
356              {/* DreamTalk Media Area or Drop Zone */}
357              {previewMedia || editingNode.dreamTalkMedia.length > 0 ? (
358                <div
359                  style={{
360                    ...getMediaContainerStyle(),
361                    opacity: animatedOpacity
362                  }}
363                >
364                  {previewMedia ? (
365                    // Show new media preview (from drag/drop or file select)
366                    <img 
367                      src={previewMedia}
368                      alt="DreamTalk preview"
369                      style={{
370                        width: '100%',
371                        height: '100%',
372                        objectFit: 'cover'
373                      }}
374                    />
375                  ) : editingNode.dreamTalkMedia.length > 0 ? (
376                    // Show existing media from DreamNode
377                    <img 
378                      src={editingNode.dreamTalkMedia[0].data}
379                      alt="Current DreamTalk media"
380                      style={{
381                        width: '100%',
382                        height: '100%',
383                        objectFit: 'cover'
384                      }}
385                    />
386                  ) : null}
387                  <div style={getMediaOverlayStyle()} />
388                </div>
389              ) : (
390                <div
391                  style={{
392                    width: '100%',
393                    height: '100%',
394                    position: 'relative',
395                    cursor: 'pointer',
396                    border: isDragOver ? '2px dashed rgba(255,255,255,0.5)' : 'none',
397                    borderRadius: '50%',
398                    zIndex: 1,
399                    pointerEvents: 'auto',
400                    opacity: animatedUIOpacity
401                  }}
402                  onClick={(e) => {
403                    e.stopPropagation();
404                    e.preventDefault();
405                    fileInputRef.current?.click();
406                  }}
407                  onMouseDown={(e) => {
408                    e.stopPropagation();
409                    e.preventDefault();
410                  }}
411                >
412                  <div
413                    style={{
414                      position: 'absolute',
415                      top: '75%',
416                      left: '50%',
417                      transform: 'translate(-50%, -50%)',
418                      color: dreamNodeStyles.colors.text.secondary,
419                      fontSize: '24px',
420                      textAlign: 'center',
421                      whiteSpace: 'nowrap',
422                      pointerEvents: 'none'
423                    }}
424                  >
425                    <div>Drop image here</div>
426                    <div>or click to browse</div>
427                  </div>
428                </div>
429              )}
430              
431              {/* Text Input - Same as ProtoNode3D */}
432              <input
433                ref={titleInputRef}
434                type="text"
435                value={localTitle}
436                onChange={handleTitleChange}
437                placeholder="Name"
438                autoFocus
439                style={{
440                  position: 'absolute',
441                  top: '50%',
442                  left: '50%',
443                  transform: 'translate(-50%, -50%)',
444                  width: `${Math.max(120, nodeSize * 0.7)}px`,
445                  height: `${Math.max(32, nodeSize * 0.12)}px`,
446                  padding: `${Math.max(8, nodeSize * 0.02)}px ${Math.max(12, nodeSize * 0.03)}px`,
447                  background: 'transparent',
448                  border: 'none',
449                  borderRadius: '4px',
450                  color: dreamNodeStyles.colors.text.primary,
451                  fontSize: `${Math.max(14, nodeSize * 0.08)}px`,
452                  fontFamily: dreamNodeStyles.typography.fontFamily,
453                  textAlign: 'center',
454                  outline: 'none',
455                  boxShadow: 'none',
456                  zIndex: 10,
457                  pointerEvents: 'auto',
458                  cursor: 'text'
459                }}
460                onClick={(e) => e.stopPropagation()}
461              />
462              
463              {/* Hidden file input */}
464              <input
465                ref={fileInputRef}
466                type="file"
467                accept="image/*,video/*"
468                onChange={handleFileSelect}
469                style={{ display: 'none' }}
470              />
471            </div>
472            
473            {/* Validation Error Display */}
474            {validationErrors.title && (
475              <div
476                style={{
477                  position: 'absolute',
478                  top: `${nodeSize + 10}px`,
479                  left: '50%',
480                  transform: 'translateX(-50%)',
481                  background: 'rgba(255, 0, 0, 0.8)',
482                  color: 'white',
483                  padding: '4px 8px',
484                  borderRadius: '4px',
485                  fontSize: '12px',
486                  whiteSpace: 'nowrap',
487                  opacity: animatedUIOpacity
488                }}
489              >
490                {validationErrors.title}
491              </div>
492            )}
493            
494            {/* Type Toggle Control - Same as ProtoNode3D */}
495            <div
496              style={{
497                position: 'absolute',
498                top: `${nodeSize + (validationErrors.title ? 40 : 20)}px`,
499                left: '50%',
500                transform: 'translateX(-50%)',
501                display: 'flex',
502                opacity: animatedUIOpacity
503              }}
504            >
505              <button
506                onClick={(e) => {
507                  e.stopPropagation();
508                  handleTypeChange('dream');
509                }}
510                onMouseDown={(e) => e.stopPropagation()}
511                style={{
512                  padding: `${Math.max(8, nodeSize * 0.02)}px ${Math.max(16, nodeSize * 0.04)}px`,
513                  border: `${dreamNodeStyles.dimensions.toggleBorderWidth}px solid ${getNodeColors('dream').border}`,
514                  background: editingNode.type === 'dream' ? getNodeColors('dream').border : 'transparent',
515                  color: 'white',
516                  fontSize: `${Math.max(14, nodeSize * 0.035)}px`,
517                  fontFamily: dreamNodeStyles.typography.fontFamily,
518                  cursor: 'pointer',
519                  transition: 'background 0.2s ease',
520                  flex: '1',
521                  minWidth: '60px',
522                  borderRadius: `${Math.max(4, nodeSize * 0.01)}px 0 0 ${Math.max(4, nodeSize * 0.01)}px`,
523                  boxSizing: 'border-box',
524                  marginRight: '0.5px'
525                }}
526              >
527                Dream
528              </button>
529              <button
530                onClick={(e) => {
531                  e.stopPropagation();
532                  handleTypeChange('dreamer');
533                }}
534                onMouseDown={(e) => e.stopPropagation()}
535                style={{
536                  padding: `${Math.max(8, nodeSize * 0.02)}px ${Math.max(16, nodeSize * 0.04)}px`,
537                  border: `${dreamNodeStyles.dimensions.toggleBorderWidth}px solid ${getNodeColors('dreamer').border}`,
538                  background: editingNode.type === 'dreamer' ? getNodeColors('dreamer').border : 'transparent',
539                  color: 'white',
540                  fontSize: `${Math.max(14, nodeSize * 0.035)}px`,
541                  fontFamily: dreamNodeStyles.typography.fontFamily,
542                  cursor: 'pointer',
543                  transition: 'background 0.2s ease',
544                  flex: '1',
545                  minWidth: '60px',
546                  borderRadius: `0 ${Math.max(4, nodeSize * 0.01)}px ${Math.max(4, nodeSize * 0.01)}px 0`,
547                  boxSizing: 'border-box'
548                }}
549              >
550                Dreamer
551              </button>
552            </div>
553  
554            {/* Contact Info Fields (only for dreamer nodes) */}
555            {editingNode.type === 'dreamer' && (
556              <div
557                style={{
558                  position: 'absolute',
559                  top: `${nodeSize + (validationErrors.title ? 100 : 80)}px`,
560                  left: '50%',
561                  transform: 'translateX(-50%)',
562                  display: 'flex',
563                  flexDirection: 'column',
564                  gap: '10px',
565                  opacity: animatedUIOpacity,
566                  width: '300px'
567                }}
568              >
569                <input
570                  type="email"
571                  value={localEmail}
572                  onChange={handleEmailChange}
573                  placeholder="Email (optional)"
574                  className="contact-field-email"
575                  style={{
576                    padding: '14px 16px',
577                    background: 'rgba(0,0,0,0.6)',
578                    border: '1px solid rgba(255,255,255,0.4)',
579                    borderRadius: '6px',
580                    color: 'white',
581                    fontSize: '24px',
582                    fontFamily: dreamNodeStyles.typography.fontFamily,
583                    textAlign: 'center',
584                    outline: 'none',
585                    height: '48px',
586                    boxSizing: 'border-box'
587                  }}
588                  onClick={(e) => e.stopPropagation()}
589                  onMouseDown={(e) => e.stopPropagation()}
590                />
591                <input
592                  type="tel"
593                  value={localPhone}
594                  onChange={handlePhoneChange}
595                  placeholder="Phone (optional)"
596                  className="contact-field-phone"
597                  style={{
598                    padding: '14px 16px',
599                    background: 'rgba(0,0,0,0.6)',
600                    border: '1px solid rgba(255,255,255,0.4)',
601                    borderRadius: '6px',
602                    color: 'white',
603                    fontSize: '24px',
604                    fontFamily: dreamNodeStyles.typography.fontFamily,
605                    textAlign: 'center',
606                    outline: 'none',
607                    height: '48px',
608                    boxSizing: 'border-box'
609                  }}
610                  onClick={(e) => e.stopPropagation()}
611                  onMouseDown={(e) => e.stopPropagation()}
612                />
613                <input
614                  type="text"
615                  value={localRadicleId}
616                  onChange={handleRadicleIdChange}
617                  placeholder="Radicle ID (optional)"
618                  className="contact-field-radicle"
619                  style={{
620                    padding: '14px 16px',
621                    background: 'rgba(0,0,0,0.6)',
622                    border: '1px solid rgba(255,255,255,0.4)',
623                    borderRadius: '6px',
624                    color: 'white',
625                    fontSize: '24px',
626                    fontFamily: dreamNodeStyles.typography.fontFamily,
627                    textAlign: 'center',
628                    outline: 'none',
629                    height: '48px',
630                    boxSizing: 'border-box'
631                  }}
632                  onClick={(e) => e.stopPropagation()}
633                  onMouseDown={(e) => e.stopPropagation()}
634                />
635              </div>
636            )}
637  
638            {/* Action Buttons */}
639            <div
640              style={{
641                position: 'absolute',
642                top: `${nodeSize + (editingNode.type === 'dreamer' ? (validationErrors.title ? 300 : 280) : (validationErrors.title ? 120 : 100))}px`,
643                left: '50%',
644                transform: 'translateX(-50%)',
645                display: 'flex',
646                gap: '12px',
647                opacity: animatedUIOpacity
648              }}
649            >
650              <button
651                onClick={(e) => {
652                  e.stopPropagation();
653                  handleCancel();
654                }}
655                onMouseDown={(e) => e.stopPropagation()}
656                disabled={isAnimating}
657                style={{
658                  padding: `${Math.max(8, nodeSize * 0.02)}px ${Math.max(16, nodeSize * 0.04)}px`,
659                  border: '1px solid rgba(255,255,255,0.5)',
660                  background: 'transparent',
661                  color: isAnimating ? 'rgba(255,255,255,0.5)' : 'white',
662                  fontSize: `${Math.max(14, nodeSize * 0.035)}px`,
663                  fontFamily: dreamNodeStyles.typography.fontFamily,
664                  borderRadius: `${Math.max(4, nodeSize * 0.01)}px`,
665                  cursor: isAnimating ? 'not-allowed' : 'pointer',
666                  transition: dreamNodeStyles.transitions.default
667                }}
668              >
669                Cancel
670              </button>
671              <button
672                onClick={(e) => {
673                  e.stopPropagation();
674                  handleSave();
675                }}
676                onMouseDown={(e) => e.stopPropagation()}
677                disabled={isSaveDisabled}
678                style={{
679                  padding: `${Math.max(8, nodeSize * 0.02)}px ${Math.max(16, nodeSize * 0.04)}px`,
680                  border: 'none',
681                  background: isSaveDisabled 
682                    ? 'rgba(255,255,255,0.3)' 
683                    : nodeColors.border,
684                  color: isSaveDisabled ? 'rgba(255,255,255,0.5)' : 'white',
685                  fontSize: `${Math.max(14, nodeSize * 0.035)}px`,
686                  fontFamily: dreamNodeStyles.typography.fontFamily,
687                  borderRadius: `${Math.max(4, nodeSize * 0.01)}px`,
688                  cursor: isSaveDisabled ? 'not-allowed' : 'pointer',
689                  transition: dreamNodeStyles.transitions.default
690                }}
691              >
692                {isAnimating ? 'Saving...' : 'Save'}
693              </button>
694              <button
695                onClick={(e) => {
696                  e.stopPropagation();
697                  handleToggleRelationshipSearch();
698                }}
699                onMouseDown={(e) => e.stopPropagation()}
700                disabled={isAnimating}
701                style={{
702                  padding: `${Math.max(8, nodeSize * 0.02)}px ${Math.max(16, nodeSize * 0.04)}px`,
703                  border: `1px solid ${editMode.isSearchingRelationships ? nodeColors.border : 'rgba(255,255,255,0.5)'}`,
704                  background: editMode.isSearchingRelationships ? 'rgba(255,255,255,0.1)' : 'transparent',
705                  color: isAnimating ? 'rgba(255,255,255,0.5)' : 'white',
706                  fontSize: `${Math.max(14, nodeSize * 0.035)}px`,
707                  fontFamily: dreamNodeStyles.typography.fontFamily,
708                  borderRadius: `${Math.max(4, nodeSize * 0.01)}px`,
709                  cursor: isAnimating ? 'not-allowed' : 'pointer',
710                  transition: dreamNodeStyles.transitions.default,
711                  display: 'flex',
712                  alignItems: 'center',
713                  justifyContent: 'center',
714                  gap: '4px'
715                }}
716                title="Toggle relationship search"
717                ref={(el) => {
718                  if (el) {
719                    el.innerHTML = '';
720                    setIcon(el, 'lucide-git-compare-arrows');
721                  }
722                }}
723              >
724              </button>
725            </div>
726          </div>
727        </Html>
728      </group>
729    );
730  }
731  
732  /**
733   * Validate media file types for DreamTalk (same as ProtoNode3D)
734   */
735  function isValidMediaFile(file: globalThis.File): boolean {
736    const validTypes = [
737      'image/png',
738      'image/jpeg',
739      'image/jpg',
740      'image/gif',
741      'image/webp',
742      'video/mp4',
743      'video/webm',
744      // .link files may appear as text/plain or application/octet-stream
745      'text/plain',
746      'application/octet-stream'
747    ];
748  
749    // Check file extension as fallback for unreliable MIME types
750    const fileName = file.name.toLowerCase();
751    if (fileName.endsWith('.link')) {
752      return true;
753    }
754  
755    return validTypes.includes(file.type);
756  }