From 5259d3f44b9535dd16088ffe9769a0a0ba591ec7 Mon Sep 17 00:00:00 2001 From: Stephanie Gredell Date: Thu, 21 Aug 2025 12:09:48 -0700 Subject: [PATCH] Add in canvas state machine --- static/app.js | 4 + static/commands.js | 25 +---- static/states/CanvasState.js | 158 +++++++++++++++++++++++++++ static/states/CanvasStateMachine.js | 164 ++++++++++++++++++++++++++++ static/states/ConnectionState.js | 119 ++++++++++++++++++++ static/states/DesignState.js | 79 ++++++++++++++ 6 files changed, 526 insertions(+), 23 deletions(-) create mode 100644 static/states/CanvasState.js create mode 100644 static/states/CanvasStateMachine.js create mode 100644 static/states/ConnectionState.js create mode 100644 static/states/DesignState.js diff --git a/static/app.js b/static/app.js index 555be1c..0b543e8 100644 --- a/static/app.js +++ b/static/app.js @@ -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 { // Initialize command system this.commandInvoker = new CommandInvoker(this); + // Initialize state machine + this.stateMachine = new CanvasStateMachine(this); + this.initEventHandlers(); } diff --git a/static/commands.js b/static/commands.js index 65e7121..8913cc7 100644 --- a/static/commands.js +++ b/static/commands.js @@ -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); } } diff --git a/static/states/CanvasState.js b/static/states/CanvasState.js new file mode 100644 index 0000000..8ae403f --- /dev/null +++ b/static/states/CanvasState.js @@ -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; + } +} diff --git a/static/states/CanvasStateMachine.js b/static/states/CanvasStateMachine.js new file mode 100644 index 0000000..bbc0db6 --- /dev/null +++ b/static/states/CanvasStateMachine.js @@ -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'); + } +} diff --git a/static/states/ConnectionState.js b/static/states/ConnectionState.js new file mode 100644 index 0000000..6c36429 --- /dev/null +++ b/static/states/ConnectionState.js @@ -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'; + } +} diff --git a/static/states/DesignState.js b/static/states/DesignState.js new file mode 100644 index 0000000..e84f5a1 --- /dev/null +++ b/static/states/DesignState.js @@ -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'; + } +}