4 changed files with 459 additions and 103 deletions
@ -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. |
||||||
|
*/ |
||||||
Loading…
Reference in new issue