Browse Source

Add in canvas state machine

main
Stephanie Gredell 5 months ago
parent
commit
5259d3f44b
  1. 4
      static/app.js
  2. 25
      static/commands.js
  3. 158
      static/states/CanvasState.js
  4. 164
      static/states/CanvasStateMachine.js
  5. 119
      static/states/ConnectionState.js
  6. 79
      static/states/DesignState.js

4
static/app.js

@ -28,6 +28,7 @@ import { @@ -28,6 +28,7 @@ import {
SaveNodePropertiesCommand,
DeleteSelectionCommand
} from './commands.js';
import { CanvasStateMachine } from './states/CanvasStateMachine.js';
export class CanvasApp {
constructor() {
@ -80,6 +81,9 @@ export class CanvasApp { @@ -80,6 +81,9 @@ export class CanvasApp {
// Initialize command system
this.commandInvoker = new CommandInvoker(this);
// Initialize state machine
this.stateMachine = new CanvasStateMachine(this);
this.initEventHandlers();
}

25
static/commands.js

@ -319,29 +319,8 @@ export class HandleCanvasClickCommand extends Command { @@ -319,29 +319,8 @@ export class HandleCanvasClickCommand extends Command {
}
execute(app) {
// If this is part of a double-click sequence (detail > 1), ignore it
if (this.event.detail > 1) {
return;
}
if (app.connectionStart) {
app.connectionStart.group.classList.remove('selected');
app.connectionStart = null;
}
// Don't hide props panel if clicking on it
if (!app.nodePropsPanel.contains(this.event.target)) {
// Use observer to notify that properties panel should be closed
if (app.selectedNode) {
app.propertiesPanelSubject.notifyPropertiesPanelClosed(app.selectedNode);
}
}
// Use observer to notify that current node should be deselected
if (app.selectedNode) {
app.nodeSelectionSubject.notifyNodeDeselected(app.selectedNode);
app.selectedNode = null;
}
// Delegate to current state
app.stateMachine.handleCanvasClick(this.event);
}
}

158
static/states/CanvasState.js

@ -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;
}
}

164
static/states/CanvasStateMachine.js

@ -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');
}
}

119
static/states/ConnectionState.js

@ -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';
}
}

79
static/states/DesignState.js

@ -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…
Cancel
Save