RelationshipEditor3D.tsx
1 import React, { useState, useRef, useEffect } from 'react'; 2 import { Html } from '@react-three/drei'; 3 import { dreamNodeStyles, getNodeColors } from '../dreamnode/styles/dreamNodeStyles'; 4 import { useInterBrainStore } from '../../core/store/interbrain-store'; 5 import { useOrchestrator } from '../../core/context/orchestrator-context'; 6 import { hybridSearchService } from '../search/services/hybrid-search-service'; 7 import { saveEditModeChanges, cancelEditMode } from './services/editor-service'; 8 import { UIService } from '../../core/services/ui-service'; 9 import { deriveFocusIntent, buildLayoutContext } from '../../core/orchestration/intent-helpers'; 10 11 const uiService = new UIService(); 12 13 /** 14 * RelationshipEditor3D - Standalone relationship editing UI 15 * 16 * This component handles RELATIONSHIP editing only: 17 * - Renders when spatialLayout is 'relationship-edit' 18 * - Shows search input at the top of the screen 19 * - Center DreamNode remains visible (no editor overlay) 20 * - Clicking nodes toggles their relationship status 21 * - Has its own Save/Cancel buttons 22 * 23 * Note: This is a peer-level mode to 'edit' (metadata editing). 24 * Both modes share the editMode state but have different UIs and purposes. 25 */ 26 export default function RelationshipEditor3D() { 27 const inputRef = useRef<globalThis.HTMLInputElement>(null); 28 29 // Store state 30 const { 31 editMode, 32 spatialLayout, 33 setEditModeSearchResults, 34 setSearchResults, 35 exitEditMode 36 } = useInterBrainStore(); 37 38 const { editingNode, pendingRelationships } = editMode; 39 40 // Orchestrator for cleanup 41 const orchestrator = useOrchestrator(); 42 43 // Local UI state 44 const [localQuery, setLocalQuery] = useState(''); 45 const [searchError, setSearchError] = useState<string | null>(null); 46 const [isSaving, setIsSaving] = useState(false); 47 48 // Debounced search 49 const debounceTimeoutRef = useRef<ReturnType<typeof globalThis.setTimeout> | null>(null); 50 51 // Animation state 52 const [animatedOpacity, setAnimatedOpacity] = useState<number>(0); 53 54 // Fade in on mount and focus input 55 useEffect(() => { 56 const timer = globalThis.setTimeout(() => { 57 setAnimatedOpacity(1); 58 }, 50); 59 60 // Focus input after fade-in animation completes 61 const focusTimer = globalThis.setTimeout(() => { 62 inputRef.current?.focus(); 63 }, 150); 64 65 // Ensure focus is maintained 66 const refocusTimer = globalThis.setTimeout(() => { 67 inputRef.current?.focus(); 68 }, 300); 69 70 return () => { 71 globalThis.clearTimeout(timer); 72 globalThis.clearTimeout(focusTimer); 73 globalThis.clearTimeout(refocusTimer); 74 }; 75 }, []); 76 77 // Cleanup debounce on unmount 78 useEffect(() => { 79 return () => { 80 if (debounceTimeoutRef.current) { 81 globalThis.clearTimeout(debounceTimeoutRef.current); 82 } 83 }; 84 }, []); 85 86 const shouldRender = spatialLayout === 'relationship-edit' && editMode.isActive && !!editingNode; 87 88 // On mount: send background constellation nodes home and display initial relationships 89 const hasInitialized = useRef(false); 90 useEffect(() => { 91 if (shouldRender && orchestrator && !hasInitialized.current) { 92 hasInitialized.current = true; 93 94 // Send background constellation nodes to their anchor positions 95 orchestrator.sendConstellationNodesHome(); 96 97 // Display editing node centered with existing relationships in ring 98 if (editingNode) { 99 const store = useInterBrainStore.getState(); 100 const pendingIds = store.editMode.pendingRelationships || []; 101 const context = buildLayoutContext( 102 editingNode.id, 103 store.flipState.flipStates, 104 store.spatialLayout 105 ); 106 const { intent } = deriveFocusIntent(editingNode.id, pendingIds, context); 107 orchestrator.executeLayoutIntent(intent); 108 console.log(`[RelationshipEditor] Entered: centered on "${editingNode.name}" with ${pendingIds.length} existing relationships via unified orchestration`); 109 } 110 } 111 if (!shouldRender) { 112 hasInitialized.current = false; 113 } 114 }, [shouldRender, orchestrator]); 115 116 // Only render in 'relationship-edit' layout mode 117 if (!shouldRender) { 118 return null; 119 } 120 121 // Handle query changes with debounced search 122 const handleQueryChange = (e: React.ChangeEvent<globalThis.HTMLInputElement>) => { 123 const newQuery = e.target.value; 124 setLocalQuery(newQuery); 125 setSearchError(null); 126 127 if (debounceTimeoutRef.current) { 128 globalThis.clearTimeout(debounceTimeoutRef.current); 129 } 130 131 const trimmed = newQuery.trim(); 132 if (trimmed.length < 2) { 133 setEditModeSearchResults([]); 134 // When query is cleared, show only pending relationships in ring 135 if (editingNode && orchestrator) { 136 const store = useInterBrainStore.getState(); 137 const pendingIds = store.editMode.pendingRelationships || []; 138 const context = buildLayoutContext( 139 editingNode.id, 140 store.flipState.flipStates, 141 store.spatialLayout 142 ); 143 const { intent } = deriveFocusIntent(editingNode.id, pendingIds, context); 144 orchestrator.executeLayoutIntent(intent); 145 } 146 return; 147 } 148 149 debounceTimeoutRef.current = globalThis.setTimeout(() => { 150 performSearch(trimmed); 151 }, 150); 152 }; 153 154 // Perform fuzzy name search (instant, no semantic overhead) 155 const performSearch = (query: string) => { 156 if (!editingNode || !query.trim()) return; 157 158 try { 159 setSearchError(null); 160 161 const oppositeType = editingNode.type === 'dream' ? 'dreamer' : 'dream'; 162 const searchResults = hybridSearchService.fuzzyNameSearch(query, { 163 maxResults: 12, 164 nodeTypes: [oppositeType], 165 excludeNodeId: editingNode.id, 166 }); 167 168 const resultNodes = searchResults.map(result => result.node); 169 setEditModeSearchResults(resultNodes); 170 setSearchResults(resultNodes); 171 172 // Display search results spatially via unified orchestration 173 if (resultNodes.length > 0 && orchestrator) { 174 // Combine pending relationships (existing) with new search results (deduped) 175 const store = useInterBrainStore.getState(); 176 const pendingIds = store.editMode.pendingRelationships || []; 177 const searchIds = resultNodes.map(n => n.id).filter(id => !pendingIds.includes(id)); 178 const surroundingNodeIds = [...pendingIds, ...searchIds]; 179 180 const context = buildLayoutContext( 181 editingNode.id, 182 store.flipState.flipStates, 183 store.spatialLayout 184 ); 185 const { intent } = deriveFocusIntent(editingNode.id, surroundingNodeIds, context); 186 orchestrator.executeLayoutIntent(intent); 187 console.log(`[RelationshipEditor] Displaying ${surroundingNodeIds.length} nodes (${pendingIds.length} related + ${searchIds.length} search results) via unified orchestration`); 188 } 189 190 } catch (error) { 191 console.error('RelationshipEditor3D: Search failed:', error); 192 setSearchError(error instanceof Error ? error.message : 'Search failed'); 193 } 194 }; 195 196 // Handle keyboard shortcuts 197 const handleKeyDown = (e: React.KeyboardEvent) => { 198 if (e.key === 'Enter' && localQuery.trim()) { 199 e.preventDefault(); 200 if (debounceTimeoutRef.current) { 201 globalThis.clearTimeout(debounceTimeoutRef.current); 202 } 203 performSearch(localQuery.trim()); 204 } 205 // Escape is handled by useEscapeKeyHandler 206 }; 207 208 // Save handler 209 const handleSave = async () => { 210 setIsSaving(true); 211 212 const result = await saveEditModeChanges(); 213 214 if (!result.success) { 215 uiService.showError(result.error || 'Failed to save relationships'); 216 setIsSaving(false); 217 return; 218 } 219 220 // Clear orchestrator data and exit 221 if (orchestrator) { 222 orchestrator.clearEditModeData(); 223 } 224 225 // Set spatialLayout BEFORE executeLayoutIntent so nodes animate to correct targets 226 const store = useInterBrainStore.getState(); 227 store.setSpatialLayout('liminal-web'); 228 229 // Animate back to liminal-web with editing node centered and updated relationships 230 if (editingNode && orchestrator) { 231 const relatedIds = orchestrator.getRelatedNodeIds(editingNode.id); 232 const context = buildLayoutContext(editingNode.id, store.flipState.flipStates, 'liminal-web'); 233 const { intent } = deriveFocusIntent(editingNode.id, relatedIds, context); 234 orchestrator.executeLayoutIntent(intent); 235 } 236 237 exitEditMode(); 238 setIsSaving(false); 239 240 uiService.showSuccess(`Relationships saved (${pendingRelationships.length} connections)`); 241 }; 242 243 // Cancel handler 244 const handleCancel = () => { 245 if (orchestrator) { 246 orchestrator.clearEditModeData(); 247 } 248 249 // Set spatialLayout BEFORE executeLayoutIntent so nodes animate to correct targets 250 const store = useInterBrainStore.getState(); 251 store.setSpatialLayout('liminal-web'); 252 253 // Animate back to liminal-web with editing node centered and original relationships 254 if (editingNode && orchestrator) { 255 const relatedIds = orchestrator.getRelatedNodeIds(editingNode.id); 256 const context = buildLayoutContext(editingNode.id, store.flipState.flipStates, 'liminal-web'); 257 const { intent } = deriveFocusIntent(editingNode.id, relatedIds, context); 258 orchestrator.executeLayoutIntent(intent); 259 } 260 261 cancelEditMode(); 262 }; 263 264 // Styling - use same nodeSize as DreamNodeEditor3D (from dreamNodeStyles) 265 const nodeColors = getNodeColors(editingNode.type); 266 const nodeSize = dreamNodeStyles.dimensions.nodeSizeThreeD; // 1000 - same as DreamNodeEditor3D 267 const inputWidth = nodeSize * 0.75; // 3/4 of node diameter (750px) 268 const inputHeight = nodeSize * 0.12; // Twice as tall (120px) 269 const inputBorderRadius = inputHeight / 2; // Pill shape - semicircles on ends 270 const inputFontSize = nodeSize * 0.035; // Proportional font size 271 272 // Position at center, slightly in front of the regular DreamNode3D (same as DreamNodeEditor3D) 273 const position: [number, number, number] = [0, 0, -49.9]; 274 275 // Button styling - exactly match DreamNodeEditor3D ActionButtons 276 const basePadding = `${Math.max(8, nodeSize * 0.02)}px ${Math.max(16, nodeSize * 0.04)}px`; 277 const buttonFontSize = `${Math.max(14, nodeSize * 0.035)}px`; 278 const buttonBorderRadius = `${Math.max(4, nodeSize * 0.01)}px`; 279 // Approximate button height (fontSize + padding top/bottom) 280 const approxButtonHeight = nodeSize * 0.035 + nodeSize * 0.02 * 2; // ~75px 281 // Position buttons just below the node, offset by half button height 282 const buttonTopOffset = (nodeSize / 2) + 40 + (approxButtonHeight / 2); 283 284 return ( 285 <group position={position}> 286 <Html 287 center 288 transform 289 sprite 290 distanceFactor={10} 291 style={{ 292 pointerEvents: 'auto', 293 userSelect: 'none', 294 opacity: animatedOpacity, 295 transition: 'opacity 0.3s ease' 296 }} 297 > 298 <div 299 style={{ 300 display: 'flex', 301 flexDirection: 'column', 302 alignItems: 'center', 303 justifyContent: 'center', 304 position: 'relative', 305 pointerEvents: 'none' 306 }} 307 onMouseDown={(e) => e.stopPropagation()} 308 onClick={(e) => e.stopPropagation()} 309 > 310 {/* Search Input - centered on node, pill-shaped */} 311 <input 312 ref={inputRef} 313 type="text" 314 autoFocus 315 value={localQuery} 316 onChange={handleQueryChange} 317 onKeyDown={handleKeyDown} 318 onFocus={() => { 319 if (inputRef.current) { 320 inputRef.current.style.borderColor = nodeColors.border; 321 } 322 }} 323 onBlur={(e) => { 324 // Keep focus in relationship edit mode 325 e.target.focus(); 326 }} 327 placeholder="Search relationships..." 328 style={{ 329 position: 'relative', 330 width: `${inputWidth}px`, 331 height: `${inputHeight}px`, 332 padding: `${inputHeight * 0.15}px ${inputHeight * 0.3}px`, 333 background: 'rgba(0, 0, 0, 1.0)', 334 border: `8px solid ${nodeColors.border}`, 335 borderRadius: `${inputBorderRadius}px`, 336 color: 'white', 337 fontSize: `${inputFontSize}px`, 338 fontFamily: dreamNodeStyles.typography.fontFamily, 339 textAlign: 'center', 340 outline: 'none', 341 boxShadow: 'none', 342 pointerEvents: 'auto' 343 }} 344 /> 345 346 347 {/* Error message - only show errors */} 348 {searchError && ( 349 <div 350 style={{ 351 position: 'absolute', 352 top: `${inputHeight + 20}px`, 353 fontSize: `${inputFontSize * 0.8}px`, 354 color: '#ff6b6b', 355 textAlign: 'center', 356 whiteSpace: 'nowrap', 357 pointerEvents: 'none' 358 }} 359 > 360 {searchError} 361 </div> 362 )} 363 364 {/* Action buttons - positioned below the node (exactly match DreamNodeEditor3D) */} 365 <div 366 style={{ 367 position: 'absolute', 368 top: `${buttonTopOffset}px`, 369 left: '50%', 370 transform: 'translateX(-50%)', 371 display: 'flex', 372 gap: '12px', 373 pointerEvents: 'auto' 374 }} 375 > 376 <button 377 onClick={(e) => { e.stopPropagation(); handleCancel(); }} 378 onMouseDown={(e) => e.stopPropagation()} 379 disabled={isSaving} 380 style={{ 381 padding: basePadding, 382 border: '1px solid rgba(255,255,255,0.5)', 383 background: 'transparent', 384 color: isSaving ? 'rgba(255,255,255,0.5)' : 'white', 385 fontSize: buttonFontSize, 386 fontFamily: dreamNodeStyles.typography.fontFamily, 387 borderRadius: buttonBorderRadius, 388 cursor: isSaving ? 'not-allowed' : 'pointer', 389 transition: dreamNodeStyles.transitions.default 390 }} 391 > 392 Cancel 393 </button> 394 <button 395 onClick={(e) => { e.stopPropagation(); handleSave(); }} 396 onMouseDown={(e) => e.stopPropagation()} 397 disabled={isSaving} 398 style={{ 399 padding: basePadding, 400 border: 'none', 401 background: isSaving ? 'rgba(255,255,255,0.3)' : nodeColors.border, 402 color: isSaving ? 'rgba(255,255,255,0.5)' : 'white', 403 fontSize: buttonFontSize, 404 fontFamily: dreamNodeStyles.typography.fontFamily, 405 borderRadius: buttonBorderRadius, 406 cursor: isSaving ? 'not-allowed' : 'pointer', 407 transition: dreamNodeStyles.transitions.default 408 }} 409 > 410 {isSaving ? 'Saving...' : 'Save'} 411 </button> 412 </div> 413 </div> 414 </Html> 415 </group> 416 ); 417 }