6 changed files with 526 additions and 23 deletions
@ -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 @@ |
|||||||
|
/** |
||||||
|
* 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 @@ |
|||||||
|
/** |
||||||
|
* 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 @@ |
|||||||
|
/** |
||||||
|
* 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