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 }