1import { Controller } from "@hotwired/stimulus" 2 3// Main coordinator controller for the graph editor 4// Manages communication between all page components 5export default class extends Controller { 6 static targets = ["graphContent", "iframePanel", "iframeContent"] 7 8 static values = { 9 graphDataUrl: String, 10 presetId: String 11 } 12 13 connect() { 14 15 // Initialize variables for tracking graph state 16 this.selectedNodeId = null 17 this.selectedNodeData = null 18 19 // Initialize array for storing iframe URL history 20 // Maximum length - 5 most recent URLs 21 this.iframeUrlHistory = [] 22 this.maxHistoryLength = 5 23 24 // Initialize array for storing iframe URL history 25 this.iframeUrlHistory = [] 26 this.maxHistoryLength = 5 27 28 this.initializeComponents() 29 } 30 31 // Adds URL to history 32 addUrlToHistory(url) { 33 if (!url || url === 'about:blank') return 34 35 // Add only if URL differs from the last one 36 if (this.iframeUrlHistory.length === 0 || this.iframeUrlHistory[0] !== url) { 37 // Add to the beginning of array 38 this.iframeUrlHistory.unshift(url) 39 40 // Limit history length 41 if (this.iframeUrlHistory.length > this.maxHistoryLength) { 42 this.iframeUrlHistory.pop() 43 } 44 } 45 } 46 47 // Initialize all components 48 async initializeComponents() { 49 await this.loadGraphContent() 50 this.setupGraphObserver() 51 this.bindIframeMessageListener() 52 this.bindGraphEventListeners() 53 54 } 55 56 // 1. GRAPH LOADING AND MONITORING 57 58 // Load graph content from server 59 async loadGraphContent() { 60 if (!this.graphDataUrlValue) { 61 console.warn("No graph data URL provided") 62 return 63 } 64 65 try { 66 67 68 const response = await fetch(this.graphDataUrlValue, { 69 method: 'GET', 70 headers: { 71 'Accept': 'text/html', 72 'X-Requested-With': 'XMLHttpRequest' 73 } 74 }) 75 76 if (response.ok) { 77 const html = await response.text() 78 this.graphContentTarget.innerHTML = html 79 80 // Process links immediately after content loading 81 this.processGraphLinks() 82 83 } else { 84 console.error("Failed to load graph content:", response.status) 85 this.showGraphError("Failed to load graph content") 86 } 87 } catch (error) { 88 console.error("Error loading graph content:", error) 89 this.showGraphError("Error loading graph content") 90 } 91 } 92 93 // Setup DOM changes observer for graph panel 94 setupGraphObserver() { 95 if (!this.hasGraphContentTarget) return 96 97 // MutationObserver for tracking HTML changes 98 this.graphObserver = new MutationObserver((mutations) => { 99 let shouldProcessLinks = false 100 101 mutations.forEach((mutation) => { 102 if (mutation.type === 'childList' && mutation.addedNodes.length > 0) { 103 // Check if new elements with links were added 104 mutation.addedNodes.forEach((node) => { 105 if (node.nodeType === Node.ELEMENT_NODE) { 106 if (node.tagName === 'A' || node.querySelector('a')) { 107 shouldProcessLinks = true 108 } 109 } 110 }) 111 } 112 }) 113 114 if (shouldProcessLinks) { 115 116 this.processGraphLinks() 117 } 118 }) 119 120 // Start observation 121 this.graphObserver.observe(this.graphContentTarget, { 122 childList: true, 123 subtree: true 124 }) 125 126 } 127 128 // 2. LINK PROCESSING 129 130 // Process all links in graph 131 processGraphLinks() { 132 if (!this.hasGraphContentTarget) return 133 134 const links = this.graphContentTarget.querySelectorAll('a') 135 136 137 links.forEach((link) => { 138 this.processLink(link) 139 }) 140 } 141 142 // Process single link 143 processLink(link) { 144 const href = link.getAttribute('href') 145 if (!href) return 146 147 // 1. Check if simple parameter is needed 148 if (this.needsSimpleParameter(href)) { 149 this.ensureSimpleParameter(link) 150 } 151 152 // 2. Check if clicks should be intercepted 153 if (this.shouldInterceptLink(href)) { 154 this.bindLinkClick(link) 155 } 156 } 157 158 // Check if link needs simple parameter 159 needsSimpleParameter(href) { 160 const adminRoutes = [ 161 '/admin/prompts/', 162 '/admin/scenarios/', 163 '/admin/webhooks/', 164 '/admin/session_routers/', 165 '/admin/presets/' 166 ] 167 168 return adminRoutes.some(route => href.includes(route)) && !href.includes('simple=true') 169 } 170 171 // Add simple parameter to link 172 ensureSimpleParameter(link) { 173 const href = link.getAttribute('href') 174 const separator = href.includes('?') ? '&' : '?' 175 const newHref = `${href}${separator}simple=true` 176 177 link.setAttribute('href', newHref) 178 179 } 180 181 // Check if link clicks should be intercepted 182 shouldInterceptLink(href) { 183 const interceptRoutes = [ 184 '/admin/prompts/', 185 '/admin/scenarios/', 186 '/admin/webhooks/', 187 '/admin/session_routers/', 188 '/admin/presets/' 189 ] 190 191 return interceptRoutes.some(route => href.includes(route)) 192 } 193 194 // Bind click handler to link 195 bindLinkClick(link) { 196 // Remove old handler if exists 197 const existingHandler = link.dataset.graphEditorBound 198 if (existingHandler) return 199 200 // Add new handler 201 const clickHandler = (event) => { 202 event.preventDefault() 203 const url = link.getAttribute('href') 204 205 this.openInIframe(url) 206 } 207 208 link.addEventListener('click', clickHandler) 209 link.dataset.graphEditorBound = 'true' 210 } 211 212 // 3. IFRAME PANEL MANAGEMENT 213 214 // Open URL in iframe panel 215 openInIframe(url) {
216 if (!this.hasIframePanelTarget || !this.hasIframeContentTarget) { 217 console.warn("Iframe panel targets not found") 218 return 219 } 220 221 222 // Show panel 223 this.showIframePanel() 224 225 // Show loading 226 this.showIframeLoading() 227 228 // Save current URL for change tracking 229 this.currentIframeUrl = url 230 231 // Load URL 232 this.iframeContentTarget.src = url 233 234 // Bind event listeners after iframe loading 235 this.setupIframeEventListeners() 236 } 237 238 // Show iframe panel with animation 239 showIframePanel() { 240 this.iframePanelTarget.classList.remove('hidden') 241 242 // Show animation 243 setTimeout(() => { 244 this.iframePanelTarget.style.transform = 'translateX(0)' 245 }, 10) 246 } 247 248 // Hide iframe panel 249 hideIframePanel() { 250 // Clean up event listeners 251 this.cleanupIframeEventListeners() 252 253 this.iframePanelTarget.style.transform = 'translateX(100%)' 254 255 setTimeout(() => { 256 this.iframePanelTarget.classList.add('hidden') 257 this.iframePanelTarget.style.transform = 'translateX(0)' 258 259 // Clear iframe 260 if (this.hasIframeContentTarget) {
261 this.iframeContentTarget.src = 'about:blank' 262 } 263 264 // Reset tracked URLs 265 this.currentIframeUrl = null 266 this.previousIframeUrl = null 267 268 // Remove loading indicator 269 this.hideIframeLoading() 270 }, 300) 271 } 272 273 // Show loading indicator in iframe 274 showIframeLoading() { 275 // Remove existing indicator 276 this.hideIframeLoading() 277 278 const loadingOverlay = document.createElement('div') 279 loadingOverlay.className = 'iframe-loading-overlay absolute inset-0 bg-white bg-opacity-75 flex items-center justify-center z-10' 280 loadingOverlay.innerHTML = ` 281 <div class="text-center"> 282 <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div> 283 <span class="text-gray-600">Loading...</span> 284 </div> 285 ` 286 287 const iframeContent = this.iframePanelTarget.querySelector('.iframe-content') 288 if (iframeContent) { 289 iframeContent.style.position = 'relative' 290 iframeContent.appendChild(loadingOverlay) 291 292 // Remove on iframe load 293 if (this.hasIframeContentTarget) { 294 const hideLoading = () => this.hideIframeLoading()
295 this.iframeContentTarget.addEventListener('load', hideLoading, { once: true }) 296 297 // Fallback timer 298 setTimeout(hideLoading, 10000) 299 } 300 } 301 } 302 303 // Setup event listeners for iframe 304 setupIframeEventListeners() { 305 if (!this.hasIframeContentTarget) return 306 307 // Clear old handlers 308 this.cleanupIframeEventListeners() 309 310 // Iframe load handler 311 this.iframeLoadHandler = () => { 312 this.onIframeLoad() 313 } 314 315 this.iframeContentTarget.addEventListener('load', this.iframeLoadHandler) 316 317 } 318 319 // Handle iframe load 320 onIframeLoad() { 321 try { 322 const iframe = this.iframeContentTarget 323 const iframeWindow = iframe.contentWindow 324 const iframeDocument = iframe.contentDocument 325 326 if (!iframeWindow || !iframeDocument) { 327 console.warn("Cannot access iframe content") 328 return 329 } 330 331 const currentUrl = iframeDocument.location.href 332 333 // Skip about:blank 334 if (currentUrl === 'about:blank') { 335 return 336 } 337 338 339 // Add URL to history 340 this.addUrlToHistory(currentUrl) 341 342 // Collect navigation URL history 343 this.addUrlToHistory(currentUrl) 344 345 // Check URL change 346 if (this.currentIframeUrl && this.currentIframeUrl !== currentUrl) { 347 348 this.handleIframeUrlChange(this.currentIframeUrl, currentUrl) 349 } 350 351 // Update current URL 352 this.currentIframeUrl = currentUrl 353 354 // Bind navigation handlers inside iframe 355 this.bindIframeNavigationEvents(iframeWindow) 356 357 // Initialize history observer in iframe if possible 358 this.setupIframeHistoryObserver(iframeWindow) 359 360 } catch (error) { 361 console.warn("Error handling iframe load:", error) 362 } 363 } 364 365 // Setup history observer for iframe 366 setupIframeHistoryObserver(iframeWindow) { 367 try { 368 // Get original history methods for interception 369 const originalPushState = iframeWindow.history.pushState 370 const originalReplaceState = iframeWindow.history.replaceState 371 const self = this 372 373 // Intercept pushState 374 iframeWindow.history.pushState = function() { 375 originalPushState.apply(this, arguments) 376 // Add current URL to history after change 377 const currentUrl = iframeWindow.location.href 378 self.addUrlToHistory(currentUrl) 379 380 } 381 382 // Intercept replaceState 383 iframeWindow.history.replaceState = function() { 384 originalReplaceState.apply(this, arguments) 385 // Add current URL to history after change 386 const currentUrl = iframeWindow.location.href 387 self.addUrlToHistory(currentUrl) 388 389 } 390 391 } catch (error) { 392 console.warn("Unable to setup iframe history observer:", error) 393 } 394 } 395 396 // Bind navigation handlers inside iframe 397 bindIframeNavigationEvents(iframeWindow) { 398 try { 399 // Popstate handler (browser navigation) 400 this.iframePopstateHandler = (event) => { 401 402 this.handleIframeNavigation() 403 } 404 405 // Beforeunload handler (before navigation) 406 this.iframeBeforeUnloadHandler = (event) => { 407 408 // Save current URL as previous 409 this.previousIframeUrl = this.currentIframeUrl 410 } 411 412 // Intercept clicks on links inside iframe 413 this.iframeClickHandler = (event) => { 414 const target = event.target.closest('a') 415 if (target && target.href) { 416 // Small delay for navigation processing 417 setTimeout(() => { 418 this.handleIframeNavigation() 419 }, 100) 420 } 421 } 422 423 // Bind handlers 424 iframeWindow.addEventListener('popstate', this.iframePopstateHandler) 425 iframeWindow.addEventListener('beforeunload', this.iframeBeforeUnloadHandler) 426 iframeWindow.document.addEventListener('click', this.iframeClickHandler, true) 427 428 } catch (error) { 429 console.warn("Error binding iframe navigation events:", error) 430 } 431 } 432 433 // Handle navigation inside iframe 434 handleIframeNavigation() { 435 try {
436 const iframe = this.iframeContentTarget 437 const newUrl = iframe.contentDocument.location.href 438 439 if (newUrl !== this.currentIframeUrl && newUrl !== 'about:blank') { 440 441 442 // Add URL to history 443 this.addUrlToHistory(newUrl) 444 445 // Handle URL change 446 this.handleIframeUrlChange(this.currentIframeUrl, newUrl) 447 448 // Update current URL 449 this.currentIframeUrl = newUrl 450 } 451 } catch (error) { 452 console.warn("Error handling iframe navigation:", error) 453 } 454 } 455 456 // Cleanup iframe event listeners 457 cleanupIframeEventListeners() { 458 if (this.iframeLoadHandler) { 459 this.iframeContentTarget?.removeEventListener('load', this.iframeLoadHandler) 460 this.iframeLoadHandler = null 461 } 462 463 // Clean up navigation handlers inside iframe 464 if (this.hasIframeContentTarget) { 465 try { 466 const iframeWindow = this.iframeContentTarget.contentWindow 467 if (iframeWindow) { 468 if (this.iframePopstateHandler) { 469 iframeWindow.removeEventListener('popstate', this.iframePopstateHandler) 470 this.iframePopstateHandler = null 471 } 472 if (this.iframeBeforeUnloadHandler) { 473 iframeWindow.removeEventListener('beforeunload', this.iframeBeforeUnloadHandler) 474 this.iframeBeforeUnloadHandler = null 475 } 476 if (this.iframeClickHandler) { 477 iframeWindow.document?.removeEventListener('click', this.iframeClickHandler, true) 478 this.iframeClickHandler = null 479 } 480 } 481 } catch (error) { 482 // Ignore errors during cleanup - iframe may be inaccessible 483 } 484 } 485 486 } 487 488 // Handle URL change in iframe 489 handleIframeUrlChange(oldUrl, newUrl) { 490 491 492 // 1. Check if graph needs update (transition from /edit to something else) 493 if (this.shouldUpdateGraphAfterUrlChange(oldUrl, newUrl)) { 494 this.updateGraphAfterIframeNavigation(oldUrl, newUrl) 495 } 496 497 // 2. Check and add simple parameter if needed 498 if (this.needsSimpleParameterAddition(newUrl)) { 499 this.addSimpleParameterToIframe(newUrl) 500 return // After reload, method will be called again 501 } 502 } 503 504 // Check if simple parameter needs to be added 505 needsSimpleParameterAddition(url) { 506 // Check admin routes and absence of simple parameter 507 const adminRoutes = [ 508 '/admin/prompts/', 509 '/admin/scenarios/', 510 '/admin/webhooks/', 511 '/admin/session_routers/', 512 '/admin/presets/' 513 ] 514 515 const isAdminRoute = adminRoutes.some(route => url.includes(route)) 516 const hasSimpleParam = url.includes('simple=true') 517 518 return isAdminRoute && !hasSimpleParam 519 } 520 521 // Add simple parameter and reload iframe 522 addSimpleParameterToIframe(url) { 523 const newUrl = this.ensureSimpleParameterForUrl(url) 524 525 526 // Update tracked URL 527 this.currentIframeUrl = newUrl 528 529 // Reload iframe with new URL
530 this.iframeContentTarget.src = newUrl 531 532 } 533 534 // Check frame URL history to determine if graph update is needed 535 shouldUpdateGraphBasedOnHistory() { 536 // Need at least 2 URLs in history for checking 537 if (this.iframeUrlHistory.length < 2) return false 538 539 // Get current and previous URLs 540 const currentUrl = this.iframeUrlHistory[0] 541 542 // Check last 3 URLs for "edit/new -> show" pattern 543 for (let i = 1; i < Math.min(3, this.iframeUrlHistory.length); i++) { 544 const prevUrl = this.iframeUrlHistory[i] 545 546 // Skip if any URL is empty 547 if (!prevUrl || !currentUrl) continue 548 549 const wasSavePage = this.isEditPage(prevUrl) || this.isNewPage(prevUrl) 550 const isViewPage = this.isViewPage(currentUrl) && !this.isEditPage(currentUrl) && !this.isNewPage(currentUrl) 551 552 if (wasSavePage && isViewPage) { 553 return true 554 } 555 } 556 557 return false 558 } 559 560 // Check if URL is a view page (show) 561 isViewPage(url) { 562 // View page URL usually contains only ID without action 563 // e.g.: /admin/scenarios/123 (without /edit, /new etc.) 564 const isAdminRoute = /\/admin\/(scenarios|session_routers|presets|prompts|webhooks)\/\d+(\?|$)/.test(url) 565 const hasNoAction = !url.includes('/edit') && !url.includes('/new') 566 567 return isAdminRoute && hasNoAction 568 } 569 570 // Keep old method for backward compatibility 571 shouldUpdateGraphAfterUrlChange(oldUrl, newUrl) { 572 if (!oldUrl || !newUrl) return false 573 574 // Check if this was transition from edit page 575 const wasEditPage = this.isEditPage(oldUrl) 576 const isEditPage = this.isEditPage(newUrl) 577 578 // Also check transitions from new (record creation) 579 const wasNewPage = this.isNewPage(oldUrl) 580 581 const shouldUpdate = (wasEditPage && !isEditPage) || (wasNewPage && !isEditPage) 582 583 return shouldUpdate 584 } 585 586 // Check if URL is edit page 587 isEditPage(url) { 588 return url.includes('/edit') || url.includes('/edit?') 589 } 590 591 // Check if URL is new page 592 isNewPage(url) { 593 return url.includes('/new') || url.includes('/new?') 594 } 595 596 // Update graph after iframe navigation 597 async updateGraphAfterIframeNavigation(oldUrl, newUrl) { 598 599 600 try { 601 // Show loading indicator on graph 602 this.showGraphLoadingIndicator() 603 604 // 1. Reload graph HTML content 605 await this.loadGraphContent() 606 607 // 2. Force refresh graph data through graph-with-panel controller 608 await this.forceRefreshGraphVisualization() 609 610 this.hideGraphLoadingIndicator() 611 this.showNotification("Visualisation updated", "success") 612 613 } catch (error) { 614 this.hideGraphLoadingIndicator() 615 console.error("Error updating graph after iframe navigation:", error) 616 this.showNotification("Update error", "error") 617 } 618 } 619 620 // Force refresh graph visualization 621 async forceRefreshGraphVisualization() { 622 // Find graph-with-panel controller 623 const graphPanel = this.element.querySelector('[data-controller*="graph-with-panel"]') 624 625 if (graphPanel) { 626 // Get controller instance 627 const graphController = this.application.getControllerForElementAndIdentifier( 628 graphPanel, 629 'graph-with-panel' 630 ) 631 632 if (graphController && typeof graphController.forceRefreshGraph === 'function') { 633 634 await graphController.forceRefreshGraph() 635 } else if (graphController && typeof graphController.refreshGraphData === 'function') { 636 637 await graphController.refreshGraphData() 638 } else { 639 console.warn("Graph controller not found or refresh method not available, falling back to event dispatch") 640 // Fallback - dispatch event for force refresh 641 this.dispatchGraphRefreshEvent() 642 } 643 } else { 644 console.warn("Graph panel element not found") 645 } 646 } 647 648 // Dispatch event for graph refresh 649 dispatchGraphRefreshEvent() { 650 const refreshEvent = new CustomEvent('graph:forceRefresh', { 651 bubbles: true, 652 detail: { 653 reason: 'iframe-navigation', 654 timestamp: Date.now() 655 } 656 }) 657 658 this.element.dispatchEvent(refreshEvent) 659 660 } 661 662 // Show loading indicator on graph 663 showGraphLoadingIndicator() { 664 if (!this.hasGraphContentTarget) return 665 666 // Remove existing indicator 667 this.hideGraphLoadingIndicator() 668 669 const loadingOverlay = document.createElement('div') 670 loadingOverlay.className = 'graph-loading-overlay absolute inset-0 bg-white bg-opacity-90 flex items-center justify-center z-40' 671 loadingOverlay.innerHTML = ` 672 <div class="text-center"> 673 <div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
674 <span class="text-gray-700 font-medium">Updating graph...</span> 675 </div> 676 ` 677 678 this.graphContentTarget.style.position = 'relative' 679 this.graphContentTarget.appendChild(loadingOverlay) 680 } 681 682 // Hide graph loading indicator 683 hideGraphLoadingIndicator() { 684 const overlay = this.graphContentTarget?.querySelector('.graph-loading-overlay') 685 if (overlay) { 686 overlay.remove() 687 } 688 } 689 690 /** 691 * Force refresh the graph 692 */ 693 forceRefreshGraph() { 694 console.log('GraphEditor: Force refresh graph'); 695 this.loadGraphContent(); 696 this.setupEventListeners(); 697 } 698 699 /** 700 * Setup event listeners 701 */ 702 setupEventListeners() { 703 // Handler for data requests from panel controller 704 this.element.addEventListener('request-graph-data', (event) => { 705 console.log('GraphEditor: Received graph data request'); 706 const graphData = this.getCurrentGraphData(); 707 if (graphData && event.detail.requester) { 708 console.log('GraphEditor: Passing data to requesting controller'); 709 event.detail.requester.updateGraphData(graphData); 710 } 711 }); 712 } 713 714 715 // Hide iframe loading indicator 716 hideIframeLoading() { 717 const overlay = this.iframePanelTarget?.querySelector('.iframe-loading-overlay') 718 if (overlay) { 719 overlay.remove() 720 } 721 } 722 723 // 4. IFRAME MESSAGE HANDLING 724 725 // Message listener from iframe 726 bindIframeMessageListener() { 727 this.messageHandler = (event) => { 728 if (event.data && event.data.type === 'form-saved-successfully') { 729 this.handleFormSaved(event.data) 730 } 731 } 732 733 window.addEventListener('message', this.messageHandler) 734 735 } 736 737 // Bind event listeners from graph component 738 bindGraphEventListeners() { 739 // Graph node click handler 740 this.graphNodeClickHandler = (event) => { 741 742 this.handleGraphNodeClick(event.detail) 743 } 744 745 // Graph panel hidden handler 746 this.graphPanelHiddenHandler = (event) => { 747 748 this.handleGraphPanelHidden(event.detail) 749 } 750 751 // Bind handlers to controller element 752 this.element.addEventListener('graph:nodeSelected', this.graphNodeClickHandler) 753 this.element.addEventListener('graph:panelHidden', this.graphPanelHiddenHandler) 754 755 } 756 757 // Handle graph node click 758 handleGraphNodeClick(eventDetail) { 759 const { nodeId, nodeData, presetId } = eventDetail 760 761 762 // Save selected node information 763 this.selectedNodeId = nodeId 764 this.selectedNodeData = nodeData 765 766 // Handle links in opened panel with delay 767 setTimeout(() => { 768 this.handleInfoPanelLinks() 769 }, 100) // Small delay for full panel rendering 770 } 771 772 // Handle graph panel hidden 773 handleGraphPanelHidden(eventDetail) { 774 const { lastSelectedNode } = eventDetail 775 776 777 // Reset selected node information 778 this.selectedNodeId = null 779 this.selectedNodeData = null 780 781 // Reset panel link handling 782 this.clearInfoPanelLinkHandlers() 783 784 } 785 786 // Clear info panel link handlers 787 clearInfoPanelLinkHandlers() { 788 const panel = this.findInfoPanel() 789 790 if (!panel) return 791 792 // Find all processed links and reset their state 793 const processedLinks = panel.querySelectorAll('a[data-graph-editor-processed="true"]') 794 795 processedLinks.forEach((link) => { 796 // Remove processing marker (event handlers will remain, but on next 797 // panel opening links will be reprocessed with current data) 798 delete link.dataset.graphEditorProcessed 799 }) 800 801 } 802 803 // Handle successful form save in iframe 804 async handleFormSaved(eventData) { 805 806 807 808 try { 809 // Show loading indicator on graph 810 this.showGraphLoadingIndicator() 811 812 // Mark in history that form was saved 813 // This allows to know exactly that graph needs update 814 if (this.iframeUrlHistory.length > 0) { 815 // Add marker that form was saved 816 this.addUrlToHistory("form-saved:" + this.iframeUrlHistory[0]) 817 818 } 819 820 // 1. Reload graph HTML content 821 await this.loadGraphContent() 822 823 // 2. Force refresh graph data through graph-with-panel controller 824 await this.forceRefreshGraphVisualization() 825 826 this.hideGraphLoadingIndicator() 827 this.showNotification("Visualisation updated", "success") 828 829 } catch (error) { 830 this.hideGraphLoadingIndicator() 831 console.error("Error updating graph after form save:", error) 832 this.showNotification("Error loading data", "error") 833 } 834 835 // Optionally we can hide iframe panel 836 // this.hideIframePanel() 837 } 838 839 // 5. UTILITIES AND UI 840 841 // Show notifications 842 showNotification(message, type = 'info') { 843 const notification = document.createElement('div') 844 845 const typeClasses = { 846 'success': 'bg-green-500', 847 'error': 'bg-red-500', 848 'info': 'bg-blue-500', 849 'warning': 'bg-yellow-500' 850 } 851 852 notification.className = `fixed top-4 right-4 ${typeClasses[type]} text-white p-4 rounded shadow-lg z-50 transform transition-transform duration-300` 853 notification.textContent = message 854 855 document.body.appendChild(notification) 856 857 // Auto-remove after 3 seconds 858 setTimeout(() => { 859 notification.style.transform = 'translateX(100%)' 860 setTimeout(() => notification.remove(), 300) 861 }, 3000) 862 } 863 864 // Show graph loading error 865 showGraphError(message) { 866 if (this.hasGraphContentTarget) { 867 this.graphContentTarget.innerHTML = ` 868 <div class="flex items-center justify-center h-full"> 869 <div class="text-center"> 870 <svg class="w-16 h-16 text-red-500 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 871 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.5 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"></path> 872 </svg> 873 <p class="text-red-600 mb-4">${message}</p> 874 <button class="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors" 875 data-action="click->
875graph-editor#refreshGraph"> 876 Try again 877 </button> 878 </div> 879 </div> 880 ` 881 } 882 } 883 884 // 6. PUBLIC ACTIONS (for use from HTML) 885 886 // Force graph refresh 887 refreshGraph() { 888 889 this.loadGraphContent() 890 } 891 892 // Close iframe panel 893 closeIframePanel() { 894 this.hideIframePanel() 895 } 896 897 // Force link reprocessing (for debugging) 898 reprocessLinks() { 899 900 this.processGraphLinks() 901 } 902 903 // Get selected node information (public API) 904 getSelectedNode() { 905 return { 906 nodeId: this.selectedNodeId, 907 nodeData: this.selectedNodeData, 908 hasSelection: this.selectedNodeId !== null 909 } 910 } 911 912 // Check if node is selected (public API) 913 hasSelectedNode() { 914 return this.selectedNodeId !== null 915 } 916 917 // Clear node selection (public API) 918 clearNodeSelection() { 919 this.selectedNodeId = null 920 this.selectedNodeData = null 921 922 } 923 924 // Find graph info panel 925 findInfoPanel() { 926 // First search in controller element 927 let panel = this.element.querySelector('[data-graph-with-panel-target="infoPanel"]') 928 929 if (!panel) { 930 panel = this.element.querySelector('#graph-info-panel') 931 } 932 933 if (!panel) { 934 // If not found in controller element, search globally 935 panel = document.querySelector('[data-graph-with-panel-target="infoPanel"]') 936 } 937 938 if (!panel) { 939 panel = document.getElementById('graph-info-panel') 940 } 941 942 return panel 943 } 944 945 // Handle links in info panel 946 handleInfoPanelLinks() { 947 const panel = this.findInfoPanel() 948 949 if (!panel) { 950 console.warn("Graph editor: Info panel not found for link processing") 951 return 952 } 953 954 // Check if panel is visible 955 const isVisible = panel.style.display !== 'none' && 956 !panel.classList.contains('info-panel-hide') &&
957 (panel.classList.contains('info-panel-show') || 958 getComputedStyle(panel).opacity !== '0') 959 960 if (!isVisible) { 961 962 return 963 } 964 965 966 // Find all links in panel 967 const links = panel.querySelectorAll('a[href]') 968 969 970 links.forEach((link) => { 971 this.processInfoPanelLink(link) 972 }) 973 } 974 975 // Process single link in info panel 976 processInfoPanelLink(link) { 977 const href = link.getAttribute('href') 978 if (!href) return 979 980 // Check if link is already processed 981 if (link.dataset.graphEditorProcessed === 'true') { 982 return 983 } 984 985 986 // Check if link belongs to admin routes 987 const adminRoutes = [ 988 '/admin/scenarios/', 989 '/admin/session_routers/', 990 '/admin/prompts/', 991 '/admin/webhooks/', 992 '/admin/presets/' 993 ] 994 995 const shouldIntercept = adminRoutes.some(route => href.includes(route)) 996 997 if (shouldIntercept) { 998 // Remove default behavior and add our handler 999 link.addEventListener('click', (event) => { 1000 event.preventDefault() 1001 1002 1003 // Add simple parameter if needed 1004 const finalUrl = this.ensureSimpleParameterForUrl(href) 1005 1006 // Open in iframe 1007 this.openInIframe(finalUrl) 1008 1009 }) 1010 1011 // Mark link as processed 1012 link.dataset.graphEditorProcessed = 'true' 1013 1014 // Add visual indicator that link will open in panel 1015 link.title = `${link.title || link.textContent} (will open in editing panel)` 1016 1017 } else { 1018 1019 } 1020 } 1021 1022 // Add simple parameter to URL if needed 1023 ensureSimpleParameterForUrl(url) { 1024 if (url.includes('simple=true')) { 1025 return url 1026 } 1027 1028 const separator = url.includes('?') ? '&' : '?' 1029 return `${url}${separator}simple=true` 1030 } 1031 1032 // 7. CLEANUP 1033 1034 disconnect() { 1035 // Cleanup iframe event listeners 1036 this.cleanupIframeEventListeners() 1037 1038 // Cleanup observer 1039 if (this.graphObserver) { 1040 this.graphObserver.disconnect() 1041 } 1042 1043 // Cleanup message listener 1044 if (this.messageHandler) { 1045 window.removeEventListener('message', this.messageHandler) 1046 } 1047 1048 // Cleanup graph event handlers 1049 if (this.graphNodeClickHandler) { 1050 this.element.removeEventListener('graph:nodeSelected', this.graphNodeClickHandler) 1051 } 1052 if (this.graphPanelHiddenHandler) { 1053 this.element.removeEventListener('graph:panelHidden', this.graphPanelHiddenHandler) 1054 } 1055 1056 // Cleanup loading indicators 1057 this.hideIframeLoading() 1058 this.hideGraphLoadingIndicator() 1059 1060 // Cleanup state variables 1061 this.selectedNodeId = null 1062 this.selectedNodeData = null 1063 this.currentIframeUrl = null 1064 this.previousIframeUrl = null 1065 this.iframeUrlHistory = [] 1066 this.iframeUrlHistory = [] 1067 1068 } 1069}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.