diff --git a/static/app.js b/static/app.js index ebc5865..e8e32fc 100644 --- a/static/app.js +++ b/static/app.js @@ -12,6 +12,7 @@ import './plugins/datapipeline.js'; import './plugins/monitorAlerting.js'; import './plugins/thirdPartyService.js'; import { PluginRegistry } from './pluginRegistry.js'; +import { initializeObservers } from './observers.js'; export class CanvasApp { constructor() { @@ -55,6 +56,12 @@ export class CanvasApp { this._maxReconnectDelay = 15000; this._reconnectTimer = null; + // Initialize observer system (alongside existing event handling) + const observers = initializeObservers(this.nodePropsPanel, this.propsSaveBtn); + this.propertiesPanelSubject = observers.propertiesPanel; + this.nodeSelectionSubject = observers.nodeSelection; + this.connectionModeSubject = observers.connectionMode; + this.initEventHandlers(); } @@ -77,13 +84,16 @@ export class CanvasApp { this.arrowMode = !this.arrowMode; if (this.arrowMode) { this.arrowToolBtn.classList.add('active'); - this.hidePropsPanel(); + // Use observer to notify that arrow mode is enabled (will hide props panel) + this.connectionModeSubject.notifyConnectionModeChanged(true); } else { this.arrowToolBtn.classList.remove('active'); if (this.connectionStart) { this.connectionStart.group.classList.remove('selected'); this.connectionStart = null; } + // Use observer to notify that arrow mode is disabled + this.connectionModeSubject.notifyConnectionModeChanged(false); } }); this.startChatBtn.addEventListener('click', () => { @@ -245,9 +255,17 @@ export class CanvasApp { // Don't hide props panel if clicking on it if (!this.nodePropsPanel.contains(e.target)) { - this.hidePropsPanel(); + // Use observer to notify that properties panel should be closed + if (this.selectedNode) { + this.propertiesPanelSubject.notifyPropertiesPanelClosed(this.selectedNode); + } + } + + // Use observer to notify that current node should be deselected + if (this.selectedNode) { + this.nodeSelectionSubject.notifyNodeDeselected(this.selectedNode); + this.selectedNode = null; } - this.clearSelection(); }); this.propsSaveBtn.addEventListener('click', () => { @@ -258,7 +276,6 @@ export class CanvasApp { const plugin = PluginRegistry.get(node.type); if (!plugin || !plugin.props) { - this.hidePropsPanel(); return; } @@ -280,8 +297,6 @@ export class CanvasApp { node.updateLabel(value); } } - - this.hidePropsPanel(); }); // Prevent props panel from closing when clicking inside it @@ -310,95 +325,14 @@ export class CanvasApp { }); this.selectedNode = null; this.activeNode = null; - this.hidePropsPanel(); } } }); } - showPropsPanel(node) { - this.activeNode = node; - const plugin = PluginRegistry.get(node.type); - const panel = this.nodePropsPanel; - - if (!plugin || this.arrowMode) { - this.hidePropsPanel(); - return; - } - - // Get the node's actual screen position using getBoundingClientRect - const nodeRect = node.group.getBoundingClientRect(); - const containerRect = this.canvasContainer.getBoundingClientRect(); - const panelWidth = 220; // From CSS: #node-props-panel width - const panelHeight = 400; // Estimated height for boundary checking - - // Try to position dialog to the right of the node - let dialogX = nodeRect.right + 10; - let dialogY = nodeRect.top; - - // Check if dialog would go off the right edge of the screen - if (dialogX + panelWidth > window.innerWidth) { - // Position to the left of the node instead - dialogX = nodeRect.left - panelWidth - 10; - } - - // Check if dialog would go off the bottom of the screen - if (dialogY + panelHeight > window.innerHeight) { - // Move up to keep it visible - dialogY = window.innerHeight - panelHeight - 10; - } - - // Ensure dialog doesn't go above the top of the screen - if (dialogY < 10) { - dialogY = 10; - } - - panel.style.left = dialogX + 'px'; - panel.style.top = dialogY + 'px'; - - // Hide all groups first - const allGroups = panel.querySelectorAll('.prop-group, #label-group, #compute-group, #lb-group'); - allGroups.forEach(g => g.style.display = 'none'); - const shownGroups = new Set(); - for (const prop of plugin.props) { - const group = panel.querySelector(`[data-group='${prop.group}']`); - const input = panel.querySelector(`[name='${prop.name}']`); - // Show group once - if (group && !shownGroups.has(group)) { - group.style.display = 'block'; - shownGroups.add(group); - } - - // Set value - if (input) { - input.value = node.props[prop.name] ?? prop.default; - } - } - - this.propsSaveBtn.disabled = false; - panel.style.display = 'block'; - - // Trigger smooth animation - setTimeout(() => { - panel.classList.add('visible'); - }, 10); - } - - hidePropsPanel() { - const panel = this.nodePropsPanel; - panel.classList.remove('visible'); - - // Hide after animation completes - setTimeout(() => { - panel.style.display = 'none'; - }, 200); - - this.propsSaveBtn.disabled = true; - this.activeNode = null; - } updateConnectionsFor(movedNode) { this.connections.forEach(conn => { @@ -408,17 +342,7 @@ export class CanvasApp { }); } - clearSelection() { - if (this.selectedConnection) { - this.selectedConnection.deselect(); - this.selectedConnection = null; - } - if (this.selectedNode) { - this.selectedNode.deselect(); - this.selectedNode = null; - } - } exportDesign() { const nodes = this.placedComponents diff --git a/static/connection.js b/static/connection.js index 2d01263..69a33d8 100644 --- a/static/connection.js +++ b/static/connection.js @@ -41,7 +41,15 @@ export class Connection { this.selected = false; this.line.addEventListener('click', (e) => { e.stopPropagation(); - this.app.clearSelection(); + // Clear node selection via observer + if (this.app.selectedNode) { + this.app.nodeSelectionSubject.notifyNodeDeselected(this.app.selectedNode); + this.app.selectedNode = null; + } + // Clear any previously selected connection + if (this.app.selectedConnection) { + this.app.selectedConnection.deselect(); + } this.select(); }); @@ -117,7 +125,15 @@ export class Connection { } select() { - this.app.clearSelection(); + // Clear node selection via observer + if (this.app.selectedNode) { + this.app.nodeSelectionSubject.notifyNodeDeselected(this.app.selectedNode); + this.app.selectedNode = null; + } + // Clear any previously selected connection + if (this.app.selectedConnection) { + this.app.selectedConnection.deselect(); + } this.selected = true; this.line.setAttribute('stroke', '#007bff'); this.line.setAttribute('stroke-width', 3); diff --git a/static/node.js b/static/node.js index 3aeb85f..fbf2a3a 100644 --- a/static/node.js +++ b/static/node.js @@ -62,15 +62,17 @@ export class ComponentNode { if (app.arrowMode) { Connection.handleClick(this, app); } else { - app.clearSelection(); - this.select(); + // Use observer to notify node selection + app.nodeSelectionSubject.notifyNodeSelected(this); + app.selectedNode = this; // Keep app state in sync for now } }); this.group.addEventListener('dblclick', (e) => { e.stopPropagation(); if (!app.arrowMode) { - app.showPropsPanel(this); + // Use observer pattern instead of direct call + app.propertiesPanelSubject.notifyPropertiesPanelRequested(this); } }); @@ -144,7 +146,10 @@ export class ComponentNode { } select() { - this.app.clearSelection(); + // Use observer to clear previous selection and select this node + if (this.app.selectedNode && this.app.selectedNode !== this) { + this.app.nodeSelectionSubject.notifyNodeDeselected(this.app.selectedNode); + } this.group.classList.add('selected'); this.app.selectedNode = this; } diff --git a/static/observers.js b/static/observers.js new file mode 100644 index 0000000..17dbb68 --- /dev/null +++ b/static/observers.js @@ -0,0 +1,411 @@ +/** + * Dedicated Observer Pattern Implementation + * + * Each observer is dedicated to a particular concern and is type-safe. + * This provides clean separation of concerns and maintainable event handling. + */ + +import { PluginRegistry } from './pluginRegistry.js'; + +/** + * NodeSelectionObserver - Dedicated to node selection events only + */ +export class NodeSelectionObserver { + constructor() { + this.observers = []; + } + + // Add a specific observer for node selection changes + addObserver(observer) { + if (typeof observer.onNodeSelected !== 'function' || + typeof observer.onNodeDeselected !== 'function') { + throw new Error('Observer must implement onNodeSelected and onNodeDeselected methods'); + } + this.observers.push(observer); + } + + removeObserver(observer) { + const index = this.observers.indexOf(observer); + if (index !== -1) { + this.observers.splice(index, 1); + } + } + + // Notify all observers about node selection + notifyNodeSelected(node) { + for (const observer of this.observers) { + observer.onNodeSelected(node); + } + } + + // Notify all observers about node deselection + notifyNodeDeselected(node) { + for (const observer of this.observers) { + observer.onNodeDeselected(node); + } + } +} + +/** + * PropertiesPanelObserver - Dedicated to properties panel events only + */ +export class PropertiesPanelObserver { + constructor() { + this.observers = []; + } + + addObserver(observer) { + if (typeof observer.onPropertiesPanelRequested !== 'function') { + throw new Error('Observer must implement onPropertiesPanelRequested method'); + } + this.observers.push(observer); + } + + removeObserver(observer) { + const index = this.observers.indexOf(observer); + if (index !== -1) { + this.observers.splice(index, 1); + } + } + + notifyPropertiesPanelRequested(node) { + for (const observer of this.observers) { + observer.onPropertiesPanelRequested(node); + } + } + + notifyPropertiesPanelClosed(node) { + for (const observer of this.observers) { + if (observer.onPropertiesPanelClosed) { + observer.onPropertiesPanelClosed(node); + } + } + } +} + +/** + * ConnectionModeObserver - Dedicated to connection/arrow mode events + */ +export class ConnectionModeObserver { + constructor() { + this.observers = []; + } + + addObserver(observer) { + if (typeof observer.onConnectionModeChanged !== 'function') { + throw new Error('Observer must implement onConnectionModeChanged method'); + } + this.observers.push(observer); + } + + removeObserver(observer) { + const index = this.observers.indexOf(observer); + if (index !== -1) { + this.observers.splice(index, 1); + } + } + + notifyConnectionModeChanged(isEnabled) { + for (const observer of this.observers) { + observer.onConnectionModeChanged(isEnabled); + } + } +} + +/** + * Properties Panel Manager - Implements observer interfaces + */ +export class PropertiesPanelManager { + constructor(panelElement, saveButton) { + this.panel = panelElement; + this.saveButton = saveButton; + this.activeNode = null; + + this.setupDOMEventListeners(); + } + + // Implement the observer interface for properties panel events + onPropertiesPanelRequested(node) { + const plugin = PluginRegistry.get(node.type); + + if (!plugin) { + this.hidePanel(); + return; + } + + this.showPanel(node, plugin); + } + + onPropertiesPanelClosed(node) { + if (this.activeNode === node) { + this.hidePanel(); + } + } + + // Implement the observer interface for connection mode events + onConnectionModeChanged(isEnabled) { + if (isEnabled && this.activeNode) { + this.hidePanel(); + } + } + + // Implement the observer interface for node selection events + onNodeSelected(node) { + // Properties panel doesn't need to do anything special when nodes are selected + // The double-click handler takes care of showing the panel + } + + onNodeDeselected(node) { + // When a node is deselected, close the properties panel if it's for that node + if (this.activeNode === node) { + this.hidePanel(); + } + } + + setupDOMEventListeners() { + this.saveButton.addEventListener('click', () => { + this.saveProperties(); + }); + + this.panel.addEventListener('click', (e) => { + e.stopPropagation(); + }); + } + + showPanel(node, plugin) { + this.activeNode = node; + + // Calculate position for optimal placement + const nodeRect = node.group.getBoundingClientRect(); + const panelWidth = 220; + const panelHeight = 400; + + let dialogX = nodeRect.right + 10; + let dialogY = nodeRect.top; + + if (dialogX + panelWidth > window.innerWidth) { + dialogX = nodeRect.left - panelWidth - 10; + } + + if (dialogY + panelHeight > window.innerHeight) { + dialogY = window.innerHeight - panelHeight - 10; + } + + if (dialogY < 10) { + dialogY = 10; + } + + this.panel.style.left = dialogX + 'px'; + this.panel.style.top = dialogY + 'px'; + + // Hide all groups first + const allGroups = this.panel.querySelectorAll('.prop-group, #label-group, #compute-group, #lb-group'); + allGroups.forEach(g => g.style.display = 'none'); + + const shownGroups = new Set(); + + // Set up properties based on plugin definition + for (const prop of plugin.props) { + const group = this.panel.querySelector(`[data-group='${prop.group}']`); + const input = this.panel.querySelector(`[name='${prop.name}']`); + + // Show group once + if (group && !shownGroups.has(group)) { + group.style.display = 'block'; + shownGroups.add(group); + } + + // Set value + if (input) { + input.value = node.props[prop.name] ?? prop.default; + } + } + + this.saveButton.disabled = false; + this.panel.style.display = 'block'; + + setTimeout(() => { + this.panel.classList.add('visible'); + }, 10); + } + + hidePanel() { + if (!this.activeNode) return; + + this.panel.classList.remove('visible'); + + setTimeout(() => { + this.panel.style.display = 'none'; + }, 200); + + this.activeNode = null; + } + + saveProperties() { + if (!this.activeNode) return; + + const node = this.activeNode; + const panel = this.panel; + const plugin = PluginRegistry.get(node.type); + + if (!plugin || !plugin.props) { + this.hidePanel(); + return; + } + + // Loop through plugin-defined props and update the node + for (const prop of plugin.props) { + const input = panel.querySelector(`[name='${prop.name}']`); + if (!input) continue; + + let value; + if (prop.type === 'number') { + value = parseFloat(input.value); + if (isNaN(value)) value = prop.default ?? 0; + } else { + value = input.value; + } + + node.props[prop.name] = value; + if (prop.name === 'label') { + node.updateLabel(value); + } + } + + this.hidePanel(); + } +} + +/** + * Selection Manager - Implements node selection observer interface + */ +export class SelectionManager { + constructor() { + this.selectedNode = null; + this.selectedConnection = null; + } + + // Implement the observer interface for node selection + onNodeSelected(node) { + this.clearSelection(); + this.selectedNode = node; + node.select(); // Visual feedback + } + + onNodeDeselected(node) { + if (this.selectedNode === node) { + node.deselect(); + this.selectedNode = null; + } + } + + clearSelection() { + if (this.selectedNode) { + this.selectedNode.deselect(); + this.selectedNode = null; + } + + if (this.selectedConnection) { + this.selectedConnection.deselect(); + this.selectedConnection = null; + } + } +} + +/** + * Initialize the observer system + */ +export function initializeObservers(nodePropsPanel, propsSaveBtn) { + // Create the specific observers (subjects) + const nodeSelectionSubject = new NodeSelectionObserver(); + const propertiesPanelSubject = new PropertiesPanelObserver(); + const connectionModeSubject = new ConnectionModeObserver(); + + // Create the specific observers (listeners) + const propertiesPanel = new PropertiesPanelManager(nodePropsPanel, propsSaveBtn); + + const selectionManager = new SelectionManager(); + + // Wire them together - each observer registers for what it cares about + nodeSelectionSubject.addObserver(selectionManager); + nodeSelectionSubject.addObserver(propertiesPanel); // Properties panel cares about selection too + + propertiesPanelSubject.addObserver(propertiesPanel); + + connectionModeSubject.addObserver(propertiesPanel); // Panel hides when arrow mode enabled + + // Return the subjects so the main app can notify them + return { + nodeSelection: nodeSelectionSubject, + propertiesPanel: propertiesPanelSubject, + connectionMode: connectionModeSubject + }; +} + +/** + * How the main CanvasApp would use these observers + */ +export class CanvasAppWithObservers { + constructor() { + // Initialize observers + const observers = initializeObservers(); + this.nodeSelectionSubject = observers.nodeSelection; + this.propertiesPanelSubject = observers.propertiesPanel; + this.connectionModeSubject = observers.connectionMode; + + this.selectedNode = null; + this.arrowMode = false; + + this.setupEventListeners(); + } + + setupEventListeners() { + // Canvas click - clear selection + this.canvas.addEventListener('click', (e) => { + if (e.detail > 1) return; // Ignore double-clicks + + if (this.selectedNode) { + this.nodeSelectionSubject.notifyNodeDeselected(this.selectedNode); + this.selectedNode = null; + } + }); + + // Arrow mode toggle + this.arrowToolBtn.addEventListener('click', () => { + this.arrowMode = !this.arrowMode; + this.connectionModeSubject.notifyConnectionModeChanged(this.arrowMode); + }); + } + + // When a node is double-clicked + onNodeDoubleClick(node) { + if (!this.arrowMode) { + this.propertiesPanelSubject.notifyPropertiesPanelRequested(node); + } + } + + // When a node is single-clicked + onNodeSingleClick(node) { + if (this.selectedNode !== node) { + if (this.selectedNode) { + this.nodeSelectionSubject.notifyNodeDeselected(this.selectedNode); + } + this.selectedNode = node; + this.nodeSelectionSubject.notifyNodeSelected(node); + } + } +} + +/** + * Key Benefits of This Approach: + * + * 1. TYPE SAFETY: Each observer has a specific interface + * 2. SINGLE RESPONSIBILITY: Each observer handles ONE concern + * 3. NO MAGIC STRINGS: No event type constants that can be mistyped + * 4. COMPILE-TIME CHECKING: TypeScript/IDE can validate observer interfaces + * 5. FOCUSED: PropertiesPanelObserver only knows about properties panels + * 6. TESTABLE: Each observer can be tested with mock implementations + * + * This approach is more verbose but much safer and clearer about + * what each component is responsible for. + */