/ src / features / dreamnode-editor / RelationshipEditor3D.tsx
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  }