You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
411 lines
12 KiB
411 lines
12 KiB
/** |
|
* 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. |
|
*/
|
|
|