6 changed files with 526 additions and 23 deletions
@ -0,0 +1,158 @@
@@ -0,0 +1,158 @@
|
||||
/** |
||||
* Base Canvas State - Nystrom's State Pattern Implementation |
||||
*
|
||||
* This abstract base class defines the interface that all canvas states must implement. |
||||
* Each state handles user interactions differently, eliminating the need for mode checking. |
||||
*/ |
||||
|
||||
export class CanvasState { |
||||
/** |
||||
* Called when entering this state |
||||
* @param {CanvasApp} app - The canvas application context |
||||
*/ |
||||
enter(app) { |
||||
// Override in concrete states
|
||||
console.log(`Entering ${this.constructor.name}`); |
||||
} |
||||
|
||||
/** |
||||
* Called when exiting this state
|
||||
* @param {CanvasApp} app - The canvas application context |
||||
*/ |
||||
exit(app) { |
||||
// Override in concrete states
|
||||
console.log(`Exiting ${this.constructor.name}`); |
||||
} |
||||
|
||||
/** |
||||
* Handle clicks on the canvas background |
||||
* @param {CanvasApp} app - The canvas application context |
||||
* @param {MouseEvent} event - The click event |
||||
*/ |
||||
handleCanvasClick(app, event) { |
||||
// Default: clear selections
|
||||
if (event.detail > 1) return; // Ignore double-clicks
|
||||
|
||||
// Clear any connection start
|
||||
if (app.connectionStart) { |
||||
app.connectionStart.group.classList.remove('selected'); |
||||
app.connectionStart = null; |
||||
} |
||||
|
||||
// Clear node selection via observer
|
||||
if (app.selectedNode) { |
||||
app.nodeSelectionSubject.notifyNodeDeselected(app.selectedNode); |
||||
app.selectedNode = null; |
||||
} |
||||
|
||||
// Clear connection selection
|
||||
if (app.selectedConnection) { |
||||
app.selectedConnection.deselect(); |
||||
app.selectedConnection = null; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Handle single clicks on nodes |
||||
* @param {CanvasApp} app - The canvas application context
|
||||
* @param {ComponentNode} node - The clicked node |
||||
* @param {MouseEvent} event - The click event |
||||
*/ |
||||
handleNodeClick(app, node, event) { |
||||
// Override in concrete states
|
||||
throw new Error(`${this.constructor.name} must implement handleNodeClick()`); |
||||
} |
||||
|
||||
/** |
||||
* Handle double clicks on nodes |
||||
* @param {CanvasApp} app - The canvas application context |
||||
* @param {ComponentNode} node - The double-clicked node
|
||||
*/ |
||||
handleNodeDoubleClick(app, node) { |
||||
// Override in concrete states
|
||||
throw new Error(`${this.constructor.name} must implement handleNodeDoubleClick()`); |
||||
} |
||||
|
||||
/** |
||||
* Handle component drops from sidebar |
||||
* @param {CanvasApp} app - The canvas application context |
||||
* @param {DragEvent} event - The drop event |
||||
*/ |
||||
async handleDrop(app, event) { |
||||
// Default implementation - most states allow dropping
|
||||
const type = event.dataTransfer.getData('text/plain'); |
||||
|
||||
// Import PluginRegistry dynamically to avoid circular imports
|
||||
const { PluginRegistry } = await import('../pluginRegistry.js'); |
||||
const plugin = PluginRegistry.get(type); |
||||
if (!plugin) return; |
||||
|
||||
const pt = app.canvas.createSVGPoint(); |
||||
pt.x = event.clientX; |
||||
pt.y = event.clientY; |
||||
|
||||
const svgP = pt.matrixTransform(app.canvas.getScreenCTM().inverse()); |
||||
const x = svgP.x - app.componentSize.width / 2; |
||||
const y = svgP.y - app.componentSize.height / 2; |
||||
|
||||
const { generateDefaultProps } = await import('../utils.js'); |
||||
const { ComponentNode } = await import('../node.js'); |
||||
|
||||
const props = generateDefaultProps(plugin); |
||||
const node = new ComponentNode(type, x, y, app, props); |
||||
node.x = x; |
||||
node.y = y; |
||||
} |
||||
|
||||
/** |
||||
* Handle keyboard events |
||||
* @param {CanvasApp} app - The canvas application context |
||||
* @param {KeyboardEvent} event - The keyboard event |
||||
*/ |
||||
handleKeyDown(app, event) { |
||||
// Default: handle delete key
|
||||
if (event.key === 'Backspace' || event.key === 'Delete') { |
||||
if (app.selectedConnection) { |
||||
app.canvas.removeChild(app.selectedConnection.line); |
||||
app.canvas.removeChild(app.selectedConnection.text); |
||||
const index = app.connections.indexOf(app.selectedConnection); |
||||
if (index !== -1) app.connections.splice(index, 1); |
||||
app.selectedConnection = null; |
||||
} else if (app.selectedNode) { |
||||
app.canvas.removeChild(app.selectedNode.group); |
||||
app.placedComponents = app.placedComponents.filter(n => n !== app.selectedNode); |
||||
app.connections = app.connections.filter(conn => { |
||||
if (conn.start === app.selectedNode || conn.end === app.selectedNode) { |
||||
app.canvas.removeChild(conn.line); |
||||
app.canvas.removeChild(conn.text); |
||||
return false; |
||||
} |
||||
return true; |
||||
}); |
||||
app.selectedNode = null; |
||||
app.activeNode = null; |
||||
} |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Get the display name of this state |
||||
*/ |
||||
getStateName() { |
||||
return this.constructor.name.replace('State', ''); |
||||
} |
||||
|
||||
/** |
||||
* Get the cursor style for this state |
||||
*/ |
||||
getCursor() { |
||||
return 'default'; |
||||
} |
||||
|
||||
/** |
||||
* Whether this state allows properties panel to open |
||||
*/ |
||||
allowsPropertiesPanel() { |
||||
return true; |
||||
} |
||||
} |
||||
@ -0,0 +1,164 @@
@@ -0,0 +1,164 @@
|
||||
/** |
||||
* Canvas State Machine - Manages state transitions for the canvas |
||||
*
|
||||
* This class coordinates state changes and ensures proper enter/exit calls. |
||||
* It follows Nystrom's State Pattern implementation guidelines. |
||||
*/ |
||||
|
||||
import { DesignState } from './DesignState.js'; |
||||
import { ConnectionState } from './ConnectionState.js'; |
||||
|
||||
export class CanvasStateMachine { |
||||
constructor(app) { |
||||
this.app = app; |
||||
this.currentState = null; |
||||
|
||||
// Pre-create state instances for reuse
|
||||
this.states = { |
||||
design: new DesignState(), |
||||
connection: new ConnectionState() |
||||
}; |
||||
|
||||
// Start in design state
|
||||
this.changeState('design'); |
||||
} |
||||
|
||||
/** |
||||
* Change to a new state |
||||
* @param {string} stateName - Name of the state to change to |
||||
*/ |
||||
changeState(stateName) { |
||||
const newState = this.states[stateName]; |
||||
|
||||
if (!newState) { |
||||
console.error(`Unknown state: ${stateName}`); |
||||
return; |
||||
} |
||||
|
||||
if (this.currentState === newState) { |
||||
console.log(`Already in ${stateName} state`); |
||||
return; |
||||
} |
||||
|
||||
// Exit current state
|
||||
if (this.currentState) { |
||||
this.currentState.exit(this.app); |
||||
} |
||||
|
||||
// Enter new state
|
||||
const previousState = this.currentState; |
||||
this.currentState = newState; |
||||
this.currentState.enter(this.app); |
||||
|
||||
console.log(`State transition: ${previousState?.getStateName() || 'none'} -> ${newState.getStateName()}`); |
||||
|
||||
// Notify any listeners about state change
|
||||
this.onStateChanged(previousState, newState); |
||||
} |
||||
|
||||
/** |
||||
* Toggle between design and connection states |
||||
*/ |
||||
toggleConnectionMode() { |
||||
const currentStateName = this.getCurrentStateName(); |
||||
|
||||
if (currentStateName === 'design') { |
||||
this.changeState('connection'); |
||||
} else { |
||||
this.changeState('design'); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Get the current state name |
||||
*/ |
||||
getCurrentStateName() { |
||||
return this.currentState ? this.currentState.getStateName().toLowerCase() : 'none'; |
||||
} |
||||
|
||||
/** |
||||
* Get the current state instance |
||||
*/ |
||||
getCurrentState() { |
||||
return this.currentState; |
||||
} |
||||
|
||||
/** |
||||
* Check if currently in a specific state |
||||
* @param {string} stateName
|
||||
*/ |
||||
isInState(stateName) { |
||||
return this.getCurrentStateName() === stateName.toLowerCase(); |
||||
} |
||||
|
||||
/** |
||||
* Delegate canvas click to current state |
||||
*/ |
||||
handleCanvasClick(event) { |
||||
if (this.currentState) { |
||||
this.currentState.handleCanvasClick(this.app, event); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Delegate node click to current state |
||||
*/ |
||||
handleNodeClick(node, event) { |
||||
if (this.currentState) { |
||||
this.currentState.handleNodeClick(this.app, node, event); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Delegate node double-click to current state |
||||
*/ |
||||
handleNodeDoubleClick(node) { |
||||
if (this.currentState) { |
||||
this.currentState.handleNodeDoubleClick(this.app, node); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Delegate drop event to current state |
||||
*/ |
||||
handleDrop(event) { |
||||
if (this.currentState) { |
||||
this.currentState.handleDrop(this.app, event); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Delegate keyboard event to current state |
||||
*/ |
||||
handleKeyDown(event) { |
||||
if (this.currentState) { |
||||
this.currentState.handleKeyDown(this.app, event); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Called when state changes - override for custom behavior |
||||
*/ |
||||
onStateChanged(previousState, newState) { |
||||
// Could emit events, update analytics, etc.
|
||||
|
||||
// Update any debug UI
|
||||
if (this.app.debugStateDisplay) { |
||||
this.app.debugStateDisplay.textContent = `State: ${newState.getStateName()}`; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Get available states for debugging/UI |
||||
*/ |
||||
getAvailableStates() { |
||||
return Object.keys(this.states); |
||||
} |
||||
|
||||
/** |
||||
* Force change to design state (safe reset) |
||||
*/ |
||||
resetToDesignState() { |
||||
this.changeState('design'); |
||||
} |
||||
} |
||||
@ -0,0 +1,119 @@
@@ -0,0 +1,119 @@
|
||||
/** |
||||
* Connection State - Arrow mode for connecting components |
||||
*
|
||||
* In this state, users can: |
||||
* - Click nodes to start/end connections |
||||
* - See visual feedback for connection process |
||||
* - Cannot edit properties (properties panel disabled) |
||||
*/ |
||||
|
||||
import { CanvasState } from './CanvasState.js'; |
||||
import { Connection } from '../connection.js'; |
||||
|
||||
export class ConnectionState extends CanvasState { |
||||
enter(app) { |
||||
super.enter(app); |
||||
|
||||
// Update UI to reflect connection mode
|
||||
app.arrowToolBtn.classList.add('active'); |
||||
app.canvas.style.cursor = this.getCursor(); |
||||
|
||||
// Hide properties panel (connection mode disables editing)
|
||||
if (app.selectedNode) { |
||||
app.propertiesPanelSubject.notifyPropertiesPanelClosed(app.selectedNode); |
||||
} |
||||
|
||||
// Notify observers that connection mode is enabled
|
||||
app.connectionModeSubject.notifyConnectionModeChanged(true); |
||||
} |
||||
|
||||
exit(app) { |
||||
super.exit(app); |
||||
|
||||
// Clear any pending connection
|
||||
if (app.connectionStart) { |
||||
app.connectionStart.group.classList.remove('selected'); |
||||
app.connectionStart = null; |
||||
} |
||||
} |
||||
|
||||
handleNodeClick(app, node, event) { |
||||
event.stopPropagation(); |
||||
|
||||
// Clear any selected connection when starting a new connection
|
||||
if (app.selectedConnection) { |
||||
app.selectedConnection.deselect(); |
||||
app.selectedConnection = null; |
||||
} |
||||
|
||||
if (!app.connectionStart) { |
||||
// First click - start connection
|
||||
app.connectionStart = node; |
||||
node.group.classList.add('selected'); |
||||
console.log('Connection started from:', node.type); |
||||
|
||||
} else if (app.connectionStart === node) { |
||||
// Clicked same node - cancel connection
|
||||
app.connectionStart.group.classList.remove('selected'); |
||||
app.connectionStart = null; |
||||
console.log('Connection cancelled'); |
||||
|
||||
} else { |
||||
// Second click - complete connection
|
||||
this.createConnection(app, app.connectionStart, node); |
||||
app.connectionStart.group.classList.remove('selected'); |
||||
app.connectionStart = null; |
||||
} |
||||
} |
||||
|
||||
handleNodeDoubleClick(app, node) { |
||||
// In connection mode, double-click does nothing
|
||||
// Properties panel is disabled in this state
|
||||
console.log('Properties panel disabled in connection mode'); |
||||
} |
||||
|
||||
handleCanvasClick(app, event) { |
||||
// Cancel any pending connection when clicking canvas
|
||||
if (app.connectionStart) { |
||||
app.connectionStart.group.classList.remove('selected'); |
||||
app.connectionStart = null; |
||||
console.log('Connection cancelled by canvas click'); |
||||
} |
||||
|
||||
// Don't clear node selections in connection mode
|
||||
// Users should be able to see what's selected while connecting
|
||||
} |
||||
|
||||
/** |
||||
* Create a connection between two nodes |
||||
* @param {CanvasApp} app
|
||||
* @param {ComponentNode} startNode
|
||||
* @param {ComponentNode} endNode
|
||||
*/ |
||||
createConnection(app, startNode, endNode) { |
||||
// Set up pending connection for modal
|
||||
app.pendingConnection = { start: startNode, end: endNode }; |
||||
|
||||
// Setup connection modal (reuse existing modal logic)
|
||||
Connection.setupModal(app); |
||||
Connection.labelInput.value = 'Read traffic'; |
||||
Connection.protocolInput.value = 'HTTP'; |
||||
Connection.tlsCheckbox.checked = false; |
||||
Connection.capacityInput.value = '1000'; |
||||
Connection.modal.style.display = 'block'; |
||||
|
||||
console.log(`Connection setup: ${startNode.type} -> ${endNode.type}`); |
||||
} |
||||
|
||||
getCursor() { |
||||
return 'crosshair'; |
||||
} |
||||
|
||||
allowsPropertiesPanel() { |
||||
return false; // Disable properties panel in connection mode
|
||||
} |
||||
|
||||
getStateName() { |
||||
return 'Connection'; |
||||
} |
||||
} |
||||
@ -0,0 +1,79 @@
@@ -0,0 +1,79 @@
|
||||
/** |
||||
* Design State - Default canvas interaction mode |
||||
*
|
||||
* In this state, users can: |
||||
* - Place components from sidebar |
||||
* - Select/deselect components |
||||
* - Edit component properties |
||||
* - Delete components |
||||
*/ |
||||
|
||||
import { CanvasState } from './CanvasState.js'; |
||||
|
||||
export class DesignState extends CanvasState { |
||||
enter(app) { |
||||
super.enter(app); |
||||
|
||||
// Update UI to reflect design mode
|
||||
app.arrowToolBtn.classList.remove('active'); |
||||
app.canvas.style.cursor = this.getCursor(); |
||||
|
||||
// Clear any connection state
|
||||
if (app.connectionStart) { |
||||
app.connectionStart.group.classList.remove('selected'); |
||||
app.connectionStart = null; |
||||
} |
||||
|
||||
// Notify observers that connection mode is disabled
|
||||
app.connectionModeSubject.notifyConnectionModeChanged(false); |
||||
} |
||||
|
||||
handleNodeClick(app, node, event) { |
||||
event.stopPropagation(); |
||||
|
||||
// Clear any selected connection when clicking a node
|
||||
if (app.selectedConnection) { |
||||
app.selectedConnection.deselect(); |
||||
app.selectedConnection = null; |
||||
} |
||||
|
||||
// Clear previous node selection and select this node
|
||||
if (app.selectedNode && app.selectedNode !== node) { |
||||
app.nodeSelectionSubject.notifyNodeDeselected(app.selectedNode); |
||||
} |
||||
|
||||
// Select the clicked node
|
||||
node.select(); |
||||
app.nodeSelectionSubject.notifyNodeSelected(node); |
||||
} |
||||
|
||||
handleNodeDoubleClick(app, node) { |
||||
// Show properties panel for the node
|
||||
app.propertiesPanelSubject.notifyPropertiesPanelRequested(node); |
||||
} |
||||
|
||||
handleCanvasClick(app, event) { |
||||
// Don't hide props panel if clicking on it
|
||||
if (!app.nodePropsPanel.contains(event.target)) { |
||||
// Use observer to notify that properties panel should be closed
|
||||
if (app.selectedNode) { |
||||
app.propertiesPanelSubject.notifyPropertiesPanelClosed(app.selectedNode); |
||||
} |
||||
} |
||||
|
||||
// Use base implementation for other clearing logic
|
||||
super.handleCanvasClick(app, event); |
||||
} |
||||
|
||||
getCursor() { |
||||
return 'default'; |
||||
} |
||||
|
||||
allowsPropertiesPanel() { |
||||
return true; |
||||
} |
||||
|
||||
getStateName() { |
||||
return 'Design'; |
||||
} |
||||
} |
||||
Loading…
Reference in new issue