2 changed files with 453 additions and 220 deletions
@ -0,0 +1,415 @@ |
|||||||
|
/** |
||||||
|
* Command Pattern Implementation |
||||||
|
*
|
||||||
|
* This system encapsulates user actions as command objects, making the codebase |
||||||
|
* more maintainable and providing a foundation for features like undo/redo. |
||||||
|
*/ |
||||||
|
|
||||||
|
import { PluginRegistry } from './pluginRegistry.js'; |
||||||
|
import { generateDefaultProps } from './utils.js'; |
||||||
|
import { ComponentNode } from './node.js'; |
||||||
|
|
||||||
|
// Base Command interface
|
||||||
|
export class Command { |
||||||
|
/** |
||||||
|
* Execute the command |
||||||
|
* @param {CanvasApp} app - The application context |
||||||
|
*/ |
||||||
|
execute(app) { |
||||||
|
throw new Error('Command must implement execute() method'); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Optional: Undo the command (for future undo/redo system) |
||||||
|
* @param {CanvasApp} app - The application context |
||||||
|
*/ |
||||||
|
undo(app) { |
||||||
|
// Optional implementation - most commands won't need this initially
|
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Optional: Get command description for logging/debugging |
||||||
|
*/ |
||||||
|
getDescription() { |
||||||
|
return this.constructor.name; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Command Invoker - manages command execution and history
|
||||||
|
export class CommandInvoker { |
||||||
|
constructor(app) { |
||||||
|
this.app = app; |
||||||
|
this.history = []; |
||||||
|
this.maxHistorySize = 100; // Prevent memory leaks
|
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Execute a command and add it to history |
||||||
|
* @param {Command} command
|
||||||
|
*/ |
||||||
|
execute(command) { |
||||||
|
try { |
||||||
|
command.execute(this.app); |
||||||
|
|
||||||
|
// Add to history (for future undo system)
|
||||||
|
this.history.push(command); |
||||||
|
if (this.history.length > this.maxHistorySize) { |
||||||
|
this.history.shift(); |
||||||
|
} |
||||||
|
|
||||||
|
console.log(`Executed: ${command.getDescription()}`); |
||||||
|
} catch (error) { |
||||||
|
console.error(`Command execution failed: ${command.getDescription()}`, error); |
||||||
|
throw error; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Future: Undo last command |
||||||
|
*/ |
||||||
|
undo() { |
||||||
|
if (this.history.length === 0) return; |
||||||
|
|
||||||
|
const command = this.history.pop(); |
||||||
|
if (command.undo) { |
||||||
|
command.undo(this.app); |
||||||
|
console.log(`Undid: ${command.getDescription()}`); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get command history for debugging |
||||||
|
*/ |
||||||
|
getHistory() { |
||||||
|
return this.history.map(cmd => cmd.getDescription()); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// TAB NAVIGATION COMMANDS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export class SwitchToResourcesTabCommand extends Command { |
||||||
|
execute(app) { |
||||||
|
const requirementstab = app.tabs[1]; |
||||||
|
const resourcestab = app.tabs[2]; |
||||||
|
|
||||||
|
requirementstab.checked = false; |
||||||
|
resourcestab.checked = true; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export class SwitchToDesignTabCommand extends Command { |
||||||
|
execute(app) { |
||||||
|
const requirementstab = app.tabs[1]; |
||||||
|
const designtab = app.tabs[1]; // Note: This looks like a bug in original - should be tabs[0]?
|
||||||
|
|
||||||
|
requirementstab.checked = false; |
||||||
|
designtab.checked = true; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// TOOL COMMANDS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export class ToggleArrowModeCommand extends Command { |
||||||
|
execute(app) { |
||||||
|
app.arrowMode = !app.arrowMode; |
||||||
|
|
||||||
|
if (app.arrowMode) { |
||||||
|
app.arrowToolBtn.classList.add('active'); |
||||||
|
// Use observer to notify that arrow mode is enabled (will hide props panel)
|
||||||
|
app.connectionModeSubject.notifyConnectionModeChanged(true); |
||||||
|
} else { |
||||||
|
app.arrowToolBtn.classList.remove('active'); |
||||||
|
if (app.connectionStart) { |
||||||
|
app.connectionStart.group.classList.remove('selected'); |
||||||
|
app.connectionStart = null; |
||||||
|
} |
||||||
|
// Use observer to notify that arrow mode is disabled
|
||||||
|
app.connectionModeSubject.notifyConnectionModeChanged(false); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// CHAT COMMANDS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export class StartChatCommand extends Command { |
||||||
|
execute(app) { |
||||||
|
const scheme = location.protocol === "https:" ? "wss://" : "ws://"; |
||||||
|
|
||||||
|
app.ws = new WebSocket(scheme + location.host + "/ws"); |
||||||
|
|
||||||
|
app.ws.onopen = () => { |
||||||
|
app.ws.send(JSON.stringify({ |
||||||
|
'designPayload': JSON.stringify(app.exportDesign()), |
||||||
|
'message': '' |
||||||
|
})); |
||||||
|
}; |
||||||
|
|
||||||
|
app.ws.onmessage = (e) => { |
||||||
|
app.chatLoadingIndicator.style.display = 'none'; |
||||||
|
app.chatTextField.disabled = false; |
||||||
|
app.chatTextField.focus(); |
||||||
|
const message = document.createElement('p'); |
||||||
|
message.innerHTML = e.data; |
||||||
|
message.className = "other"; |
||||||
|
app.chatMessages.insertBefore(message, app.chatLoadingIndicator); |
||||||
|
}; |
||||||
|
|
||||||
|
app.ws.onerror = (err) => { |
||||||
|
console.log("ws error:", err); |
||||||
|
app._scheduleReconnect(); |
||||||
|
}; |
||||||
|
|
||||||
|
app.ws.onclose = () => { |
||||||
|
console.log("leaving chat..."); |
||||||
|
app.ws = null; |
||||||
|
app._sentJoin = false; |
||||||
|
delete app.players[app.pageData.username]; |
||||||
|
app._scheduleReconnect(); |
||||||
|
}; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export class SendChatMessageCommand extends Command { |
||||||
|
constructor(message) { |
||||||
|
super(); |
||||||
|
this.message = message; |
||||||
|
} |
||||||
|
|
||||||
|
execute(app) { |
||||||
|
const messageElement = document.createElement('p'); |
||||||
|
messageElement.innerHTML = this.message; |
||||||
|
messageElement.className = "me"; |
||||||
|
app.chatMessages.insertBefore(messageElement, app.chatLoadingIndicator); |
||||||
|
|
||||||
|
app.ws.send(JSON.stringify({ |
||||||
|
'message': this.message, |
||||||
|
'designPayload': JSON.stringify(app.exportDesign()), |
||||||
|
})); |
||||||
|
|
||||||
|
app.chatTextField.value = ''; |
||||||
|
app.chatLoadingIndicator.style.display = 'block'; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// DRAG & DROP COMMANDS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export class HandleDragStartCommand extends Command { |
||||||
|
constructor(event) { |
||||||
|
super(); |
||||||
|
this.event = event; |
||||||
|
} |
||||||
|
|
||||||
|
execute(app) { |
||||||
|
const type = this.event.target.getAttribute('data-type'); |
||||||
|
const plugin = PluginRegistry.get(type); |
||||||
|
|
||||||
|
if (!plugin) return; |
||||||
|
|
||||||
|
this.event.dataTransfer.setData('text/plain', type); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export class HandleDragEndCommand extends Command { |
||||||
|
constructor(event) { |
||||||
|
super(); |
||||||
|
this.event = event; |
||||||
|
} |
||||||
|
|
||||||
|
execute(app) { |
||||||
|
if (this.event.target.classList.contains('component-icon')) { |
||||||
|
this.event.target.classList.remove('dragging'); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export class DropComponentCommand extends Command { |
||||||
|
constructor(event) { |
||||||
|
super(); |
||||||
|
this.event = event; |
||||||
|
} |
||||||
|
|
||||||
|
execute(app) { |
||||||
|
const type = this.event.dataTransfer.getData('text/plain'); |
||||||
|
const plugin = PluginRegistry.get(type); |
||||||
|
if (!plugin) return; |
||||||
|
|
||||||
|
const pt = app.canvas.createSVGPoint(); |
||||||
|
pt.x = this.event.clientX; |
||||||
|
pt.y = this.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 props = generateDefaultProps(plugin); |
||||||
|
const node = new ComponentNode(type, x, y, app, props); |
||||||
|
node.x = x; |
||||||
|
node.y = y; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// SIMULATION COMMANDS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export class RunSimulationCommand extends Command { |
||||||
|
async execute(app) { |
||||||
|
const designData = app.exportDesign(); |
||||||
|
|
||||||
|
// Try to get level info from URL or page context
|
||||||
|
const levelInfo = app.getLevelInfo(); |
||||||
|
|
||||||
|
const requestBody = { |
||||||
|
design: designData, |
||||||
|
...levelInfo |
||||||
|
}; |
||||||
|
|
||||||
|
console.log('Sending design to simulation:', JSON.stringify(requestBody)); |
||||||
|
|
||||||
|
// Disable button and show loading state
|
||||||
|
app.runButton.disabled = true; |
||||||
|
app.runButton.textContent = 'Running Simulation...'; |
||||||
|
|
||||||
|
try { |
||||||
|
const response = await fetch('/simulate', { |
||||||
|
method: 'POST', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/json', |
||||||
|
}, |
||||||
|
body: JSON.stringify(requestBody) |
||||||
|
}); |
||||||
|
|
||||||
|
if (!response.ok) { |
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`); |
||||||
|
} |
||||||
|
|
||||||
|
const result = await response.json(); |
||||||
|
|
||||||
|
if (result.Success) { |
||||||
|
console.log('Simulation successful:', result); |
||||||
|
app.showResults(result); |
||||||
|
} else { |
||||||
|
console.error('Simulation failed:', result.Error); |
||||||
|
app.showError(result.Error || 'Simulation failed'); |
||||||
|
} |
||||||
|
|
||||||
|
} catch (error) { |
||||||
|
console.error('Network error:', error); |
||||||
|
app.showError('Failed to run simulation: ' + error.message); |
||||||
|
} finally { |
||||||
|
// Re-enable button
|
||||||
|
app.runButton.disabled = false; |
||||||
|
app.runButton.textContent = 'Test Design'; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// CANVAS INTERACTION COMMANDS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export class HandleCanvasClickCommand extends Command { |
||||||
|
constructor(event) { |
||||||
|
super(); |
||||||
|
this.event = event; |
||||||
|
} |
||||||
|
|
||||||
|
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; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export class SaveNodePropertiesCommand extends Command { |
||||||
|
execute(app) { |
||||||
|
if (!app.activeNode) return; |
||||||
|
|
||||||
|
const node = app.activeNode; |
||||||
|
const panel = app.nodePropsPanel; |
||||||
|
const plugin = PluginRegistry.get(node.type); |
||||||
|
|
||||||
|
if (!plugin || !plugin.props) { |
||||||
|
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); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export class DeleteSelectionCommand extends Command { |
||||||
|
constructor(key) { |
||||||
|
super(); |
||||||
|
this.key = key; |
||||||
|
} |
||||||
|
|
||||||
|
execute(app) { |
||||||
|
if (this.key === 'Backspace' || this.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; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue