2132 lines
80 KiB
HTML
2132 lines
80 KiB
HTML
|
|
<!DOCTYPE html>
|
|||
|
|
<html lang="en">
|
|||
|
|
<head>
|
|||
|
|
<meta charset="UTF-8">
|
|||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|||
|
|
<title>Dialog Designer - General Bots</title>
|
|||
|
|
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
|||
|
|
<script src="https://unpkg.com/htmx.org@1.9.10/dist/ext/ws.js"></script>
|
|||
|
|
<link rel="stylesheet" href="css/app.css">
|
|||
|
|
<style>
|
|||
|
|
:root {
|
|||
|
|
--bg-primary: #1e1e2e;
|
|||
|
|
--bg-secondary: #181825;
|
|||
|
|
--bg-tertiary: #313244;
|
|||
|
|
--text-primary: #cdd6f4;
|
|||
|
|
--text-secondary: #a6adc8;
|
|||
|
|
--accent-blue: #89b4fa;
|
|||
|
|
--accent-green: #a6e3a1;
|
|||
|
|
--accent-yellow: #f9e2af;
|
|||
|
|
--accent-red: #f38ba8;
|
|||
|
|
--accent-purple: #cba6f7;
|
|||
|
|
--accent-teal: #94e2d5;
|
|||
|
|
--border-color: #45475a;
|
|||
|
|
--node-talk: #89b4fa;
|
|||
|
|
--node-hear: #a6e3a1;
|
|||
|
|
--node-set: #f9e2af;
|
|||
|
|
--node-if: #cba6f7;
|
|||
|
|
--node-for: #f38ba8;
|
|||
|
|
--node-call: #94e2d5;
|
|||
|
|
--node-send: #fab387;
|
|||
|
|
--grid-size: 20px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
* {
|
|||
|
|
margin: 0;
|
|||
|
|
padding: 0;
|
|||
|
|
box-sizing: border-box;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
body {
|
|||
|
|
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
|||
|
|
background: var(--bg-primary);
|
|||
|
|
color: var(--text-primary);
|
|||
|
|
height: 100vh;
|
|||
|
|
overflow: hidden;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.designer-container {
|
|||
|
|
display: grid;
|
|||
|
|
grid-template-columns: 240px 1fr 280px;
|
|||
|
|
grid-template-rows: 48px 1fr 32px;
|
|||
|
|
height: 100vh;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Header */
|
|||
|
|
.designer-header {
|
|||
|
|
grid-column: 1 / -1;
|
|||
|
|
background: var(--bg-secondary);
|
|||
|
|
border-bottom: 1px solid var(--border-color);
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
padding: 0 16px;
|
|||
|
|
gap: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.designer-logo {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
color: var(--accent-blue);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.designer-logo svg {
|
|||
|
|
width: 24px;
|
|||
|
|
height: 24px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.designer-toolbar {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
margin-left: auto;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.toolbar-btn {
|
|||
|
|
background: var(--bg-tertiary);
|
|||
|
|
border: 1px solid var(--border-color);
|
|||
|
|
color: var(--text-primary);
|
|||
|
|
padding: 6px 12px;
|
|||
|
|
border-radius: 4px;
|
|||
|
|
cursor: pointer;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 6px;
|
|||
|
|
font-size: 13px;
|
|||
|
|
transition: all 0.15s;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.toolbar-btn:hover {
|
|||
|
|
background: var(--border-color);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.toolbar-btn.primary {
|
|||
|
|
background: var(--accent-blue);
|
|||
|
|
color: var(--bg-primary);
|
|||
|
|
border-color: var(--accent-blue);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.toolbar-btn.primary:hover {
|
|||
|
|
background: #7aa8f0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.toolbar-btn svg {
|
|||
|
|
width: 16px;
|
|||
|
|
height: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.toolbar-separator {
|
|||
|
|
width: 1px;
|
|||
|
|
height: 24px;
|
|||
|
|
background: var(--border-color);
|
|||
|
|
margin: 0 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Toolbox Panel */
|
|||
|
|
.toolbox-panel {
|
|||
|
|
background: var(--bg-secondary);
|
|||
|
|
border-right: 1px solid var(--border-color);
|
|||
|
|
overflow-y: auto;
|
|||
|
|
padding: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.toolbox-section {
|
|||
|
|
margin-bottom: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.toolbox-section-title {
|
|||
|
|
font-size: 11px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
text-transform: uppercase;
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
margin-bottom: 8px;
|
|||
|
|
padding: 0 4px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.toolbox-item {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 10px;
|
|||
|
|
padding: 8px 10px;
|
|||
|
|
border-radius: 6px;
|
|||
|
|
cursor: grab;
|
|||
|
|
transition: all 0.15s;
|
|||
|
|
margin-bottom: 4px;
|
|||
|
|
border: 1px solid transparent;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.toolbox-item:hover {
|
|||
|
|
background: var(--bg-tertiary);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.toolbox-item:active {
|
|||
|
|
cursor: grabbing;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.toolbox-item.dragging {
|
|||
|
|
opacity: 0.5;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.toolbox-icon {
|
|||
|
|
width: 32px;
|
|||
|
|
height: 32px;
|
|||
|
|
border-radius: 6px;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
font-size: 14px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
color: var(--bg-primary);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.toolbox-icon.talk { background: var(--node-talk); }
|
|||
|
|
.toolbox-icon.hear { background: var(--node-hear); }
|
|||
|
|
.toolbox-icon.set { background: var(--node-set); }
|
|||
|
|
.toolbox-icon.if { background: var(--node-if); }
|
|||
|
|
.toolbox-icon.for { background: var(--node-for); }
|
|||
|
|
.toolbox-icon.call { background: var(--node-call); }
|
|||
|
|
.toolbox-icon.send { background: var(--node-send); }
|
|||
|
|
.toolbox-icon.get { background: #74c7ec; }
|
|||
|
|
.toolbox-icon.wait { background: #f5c2e7; }
|
|||
|
|
.toolbox-icon.switch { background: #b4befe; }
|
|||
|
|
|
|||
|
|
.toolbox-info {
|
|||
|
|
flex: 1;
|
|||
|
|
min-width: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.toolbox-name {
|
|||
|
|
font-size: 13px;
|
|||
|
|
font-weight: 500;
|
|||
|
|
margin-bottom: 2px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.toolbox-desc {
|
|||
|
|
font-size: 11px;
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
white-space: nowrap;
|
|||
|
|
overflow: hidden;
|
|||
|
|
text-overflow: ellipsis;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Canvas Area */
|
|||
|
|
.canvas-container {
|
|||
|
|
position: relative;
|
|||
|
|
overflow: hidden;
|
|||
|
|
background: var(--bg-primary);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.canvas-grid {
|
|||
|
|
position: absolute;
|
|||
|
|
inset: 0;
|
|||
|
|
background-image:
|
|||
|
|
linear-gradient(var(--border-color) 1px, transparent 1px),
|
|||
|
|
linear-gradient(90deg, var(--border-color) 1px, transparent 1px);
|
|||
|
|
background-size: var(--grid-size) var(--grid-size);
|
|||
|
|
opacity: 0.3;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.canvas {
|
|||
|
|
position: absolute;
|
|||
|
|
inset: 0;
|
|||
|
|
overflow: auto;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.canvas-inner {
|
|||
|
|
position: relative;
|
|||
|
|
width: 3000px;
|
|||
|
|
height: 2000px;
|
|||
|
|
min-width: 100%;
|
|||
|
|
min-height: 100%;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Nodes */
|
|||
|
|
.node {
|
|||
|
|
position: absolute;
|
|||
|
|
min-width: 180px;
|
|||
|
|
max-width: 280px;
|
|||
|
|
background: var(--bg-secondary);
|
|||
|
|
border: 2px solid var(--border-color);
|
|||
|
|
border-radius: 8px;
|
|||
|
|
cursor: move;
|
|||
|
|
user-select: none;
|
|||
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
|||
|
|
transition: box-shadow 0.15s, border-color 0.15s;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.node:hover {
|
|||
|
|
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.node.selected {
|
|||
|
|
border-color: var(--accent-blue);
|
|||
|
|
box-shadow: 0 0 0 3px rgba(137, 180, 250, 0.3);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.node-header {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
padding: 8px 12px;
|
|||
|
|
border-radius: 6px 6px 0 0;
|
|||
|
|
font-weight: 600;
|
|||
|
|
font-size: 12px;
|
|||
|
|
text-transform: uppercase;
|
|||
|
|
letter-spacing: 0.5px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.node-header.talk { background: var(--node-talk); color: var(--bg-primary); }
|
|||
|
|
.node-header.hear { background: var(--node-hear); color: var(--bg-primary); }
|
|||
|
|
.node-header.set { background: var(--node-set); color: var(--bg-primary); }
|
|||
|
|
.node-header.if { background: var(--node-if); color: var(--bg-primary); }
|
|||
|
|
.node-header.for { background: var(--node-for); color: var(--bg-primary); }
|
|||
|
|
.node-header.call { background: var(--node-call); color: var(--bg-primary); }
|
|||
|
|
.node-header.send { background: var(--node-send); color: var(--bg-primary); }
|
|||
|
|
.node-header.get { background: #74c7ec; color: var(--bg-primary); }
|
|||
|
|
.node-header.wait { background: #f5c2e7; color: var(--bg-primary); }
|
|||
|
|
.node-header.switch { background: #b4befe; color: var(--bg-primary); }
|
|||
|
|
|
|||
|
|
.node-header svg {
|
|||
|
|
width: 14px;
|
|||
|
|
height: 14px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.node-body {
|
|||
|
|
padding: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.node-field {
|
|||
|
|
margin-bottom: 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.node-field:last-child {
|
|||
|
|
margin-bottom: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.node-field-label {
|
|||
|
|
font-size: 10px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
text-transform: uppercase;
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
margin-bottom: 4px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.node-field-input {
|
|||
|
|
width: 100%;
|
|||
|
|
background: var(--bg-tertiary);
|
|||
|
|
border: 1px solid var(--border-color);
|
|||
|
|
border-radius: 4px;
|
|||
|
|
padding: 6px 8px;
|
|||
|
|
color: var(--text-primary);
|
|||
|
|
font-size: 12px;
|
|||
|
|
font-family: 'Consolas', 'Monaco', monospace;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.node-field-input:focus {
|
|||
|
|
outline: none;
|
|||
|
|
border-color: var(--accent-blue);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.node-field-select {
|
|||
|
|
width: 100%;
|
|||
|
|
background: var(--bg-tertiary);
|
|||
|
|
border: 1px solid var(--border-color);
|
|||
|
|
border-radius: 4px;
|
|||
|
|
padding: 6px 8px;
|
|||
|
|
color: var(--text-primary);
|
|||
|
|
font-size: 12px;
|
|||
|
|
cursor: pointer;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Connection Ports */
|
|||
|
|
.node-port {
|
|||
|
|
position: absolute;
|
|||
|
|
width: 12px;
|
|||
|
|
height: 12px;
|
|||
|
|
background: var(--bg-tertiary);
|
|||
|
|
border: 2px solid var(--border-color);
|
|||
|
|
border-radius: 50%;
|
|||
|
|
cursor: crosshair;
|
|||
|
|
transition: all 0.15s;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.node-port:hover {
|
|||
|
|
background: var(--accent-blue);
|
|||
|
|
border-color: var(--accent-blue);
|
|||
|
|
transform: scale(1.3);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.node-port.input {
|
|||
|
|
left: -6px;
|
|||
|
|
top: 50%;
|
|||
|
|
transform: translateY(-50%);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.node-port.output {
|
|||
|
|
right: -6px;
|
|||
|
|
top: 50%;
|
|||
|
|
transform: translateY(-50%);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.node-port.output-true {
|
|||
|
|
right: -6px;
|
|||
|
|
top: 35%;
|
|||
|
|
background: var(--accent-green);
|
|||
|
|
border-color: var(--accent-green);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.node-port.output-false {
|
|||
|
|
right: -6px;
|
|||
|
|
top: 65%;
|
|||
|
|
background: var(--accent-red);
|
|||
|
|
border-color: var(--accent-red);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Connections SVG */
|
|||
|
|
.connections-layer {
|
|||
|
|
position: absolute;
|
|||
|
|
inset: 0;
|
|||
|
|
pointer-events: none;
|
|||
|
|
overflow: visible;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.connection {
|
|||
|
|
fill: none;
|
|||
|
|
stroke: var(--border-color);
|
|||
|
|
stroke-width: 2;
|
|||
|
|
pointer-events: stroke;
|
|||
|
|
cursor: pointer;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.connection:hover {
|
|||
|
|
stroke: var(--accent-blue);
|
|||
|
|
stroke-width: 3;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.connection.drawing {
|
|||
|
|
stroke: var(--accent-blue);
|
|||
|
|
stroke-dasharray: 5, 5;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Properties Panel */
|
|||
|
|
.properties-panel {
|
|||
|
|
background: var(--bg-secondary);
|
|||
|
|
border-left: 1px solid var(--border-color);
|
|||
|
|
overflow-y: auto;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.properties-header {
|
|||
|
|
padding: 12px 16px;
|
|||
|
|
border-bottom: 1px solid var(--border-color);
|
|||
|
|
font-weight: 600;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.properties-header svg {
|
|||
|
|
width: 16px;
|
|||
|
|
height: 16px;
|
|||
|
|
color: var(--accent-blue);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.properties-empty {
|
|||
|
|
padding: 24px 16px;
|
|||
|
|
text-align: center;
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
font-size: 13px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.properties-content {
|
|||
|
|
padding: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.property-group {
|
|||
|
|
margin-bottom: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.property-group-title {
|
|||
|
|
font-size: 11px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
text-transform: uppercase;
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
margin-bottom: 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.property-field {
|
|||
|
|
margin-bottom: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.property-label {
|
|||
|
|
font-size: 12px;
|
|||
|
|
margin-bottom: 4px;
|
|||
|
|
color: var(--text-primary);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.property-input {
|
|||
|
|
width: 100%;
|
|||
|
|
background: var(--bg-tertiary);
|
|||
|
|
border: 1px solid var(--border-color);
|
|||
|
|
border-radius: 4px;
|
|||
|
|
padding: 8px 10px;
|
|||
|
|
color: var(--text-primary);
|
|||
|
|
font-size: 13px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.property-input:focus {
|
|||
|
|
outline: none;
|
|||
|
|
border-color: var(--accent-blue);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.property-textarea {
|
|||
|
|
min-height: 80px;
|
|||
|
|
resize: vertical;
|
|||
|
|
font-family: 'Consolas', 'Monaco', monospace;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.property-checkbox {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.property-checkbox input {
|
|||
|
|
width: 16px;
|
|||
|
|
height: 16px;
|
|||
|
|
accent-color: var(--accent-blue);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Status Bar */
|
|||
|
|
.status-bar {
|
|||
|
|
grid-column: 1 / -1;
|
|||
|
|
background: var(--bg-secondary);
|
|||
|
|
border-top: 1px solid var(--border-color);
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
padding: 0 12px;
|
|||
|
|
font-size: 12px;
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
gap: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.status-item {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 6px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.status-item svg {
|
|||
|
|
width: 14px;
|
|||
|
|
height: 14px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.status-spacer {
|
|||
|
|
flex: 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.zoom-controls {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 4px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.zoom-btn {
|
|||
|
|
background: var(--bg-tertiary);
|
|||
|
|
border: 1px solid var(--border-color);
|
|||
|
|
color: var(--text-primary);
|
|||
|
|
width: 24px;
|
|||
|
|
height: 24px;
|
|||
|
|
border-radius: 4px;
|
|||
|
|
cursor: pointer;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.zoom-btn:hover {
|
|||
|
|
background: var(--border-color);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.zoom-value {
|
|||
|
|
min-width: 40px;
|
|||
|
|
text-align: center;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Context Menu */
|
|||
|
|
.context-menu {
|
|||
|
|
position: fixed;
|
|||
|
|
background: var(--bg-secondary);
|
|||
|
|
border: 1px solid var(--border-color);
|
|||
|
|
border-radius: 6px;
|
|||
|
|
padding: 4px;
|
|||
|
|
min-width: 160px;
|
|||
|
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
|||
|
|
z-index: 1000;
|
|||
|
|
display: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.context-menu.visible {
|
|||
|
|
display: block;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.context-menu-item {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
padding: 8px 12px;
|
|||
|
|
border-radius: 4px;
|
|||
|
|
cursor: pointer;
|
|||
|
|
font-size: 13px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.context-menu-item:hover {
|
|||
|
|
background: var(--bg-tertiary);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.context-menu-item.danger {
|
|||
|
|
color: var(--accent-red);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.context-menu-separator {
|
|||
|
|
height: 1px;
|
|||
|
|
background: var(--border-color);
|
|||
|
|
margin: 4px 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* File Browser Modal */
|
|||
|
|
.modal-overlay {
|
|||
|
|
position: fixed;
|
|||
|
|
inset: 0;
|
|||
|
|
background: rgba(0, 0, 0, 0.6);
|
|||
|
|
display: none;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
z-index: 1000;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.modal-overlay.visible {
|
|||
|
|
display: flex;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.modal {
|
|||
|
|
background: var(--bg-secondary);
|
|||
|
|
border: 1px solid var(--border-color);
|
|||
|
|
border-radius: 8px;
|
|||
|
|
width: 90%;
|
|||
|
|
max-width: 600px;
|
|||
|
|
max-height: 80vh;
|
|||
|
|
overflow: hidden;
|
|||
|
|
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.modal-header {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
padding: 16px 20px;
|
|||
|
|
border-bottom: 1px solid var(--border-color);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.modal-title {
|
|||
|
|
font-weight: 600;
|
|||
|
|
font-size: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.modal-close {
|
|||
|
|
background: none;
|
|||
|
|
border: none;
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
cursor: pointer;
|
|||
|
|
padding: 4px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.modal-close:hover {
|
|||
|
|
color: var(--text-primary);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.modal-body {
|
|||
|
|
padding: 20px;
|
|||
|
|
overflow-y: auto;
|
|||
|
|
max-height: 400px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.modal-footer {
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: flex-end;
|
|||
|
|
gap: 8px;
|
|||
|
|
padding: 16px 20px;
|
|||
|
|
border-top: 1px solid var(--border-color);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.file-list {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
gap: 4px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.file-item {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 10px;
|
|||
|
|
padding: 10px 12px;
|
|||
|
|
border-radius: 6px;
|
|||
|
|
cursor: pointer;
|
|||
|
|
transition: background 0.15s;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.file-item:hover {
|
|||
|
|
background: var(--bg-tertiary);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.file-item.selected {
|
|||
|
|
background: var(--accent-blue);
|
|||
|
|
color: var(--bg-primary);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.file-icon {
|
|||
|
|
width: 20px;
|
|||
|
|
height: 20px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.file-name {
|
|||
|
|
flex: 1;
|
|||
|
|
font-size: 14px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.file-path {
|
|||
|
|
font-size: 11px;
|
|||
|
|
color: var(--text-secondary);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.file-item.selected .file-path {
|
|||
|
|
color: var(--bg-tertiary);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* HTMX indicators */
|
|||
|
|
.htmx-indicator {
|
|||
|
|
display: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.htmx-request .htmx-indicator {
|
|||
|
|
display: inline-block;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.htmx-request.htmx-indicator {
|
|||
|
|
display: inline-block;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@keyframes spin {
|
|||
|
|
to { transform: rotate(360deg); }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.spinner {
|
|||
|
|
width: 16px;
|
|||
|
|
height: 16px;
|
|||
|
|
border: 2px solid var(--border-color);
|
|||
|
|
border-top-color: var(--accent-blue);
|
|||
|
|
border-radius: 50%;
|
|||
|
|
animation: spin 0.8s linear infinite;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Minimap */
|
|||
|
|
.minimap {
|
|||
|
|
position: absolute;
|
|||
|
|
bottom: 16px;
|
|||
|
|
right: 16px;
|
|||
|
|
width: 160px;
|
|||
|
|
height: 100px;
|
|||
|
|
background: var(--bg-secondary);
|
|||
|
|
border: 1px solid var(--border-color);
|
|||
|
|
border-radius: 6px;
|
|||
|
|
overflow: hidden;
|
|||
|
|
opacity: 0.9;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.minimap-viewport {
|
|||
|
|
position: absolute;
|
|||
|
|
border: 1px solid var(--accent-blue);
|
|||
|
|
background: rgba(137, 180, 250, 0.1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.minimap-node {
|
|||
|
|
position: absolute;
|
|||
|
|
background: var(--accent-blue);
|
|||
|
|
border-radius: 1px;
|
|||
|
|
}
|
|||
|
|
</style>
|
|||
|
|
</head>
|
|||
|
|
<body hx-ext="ws" ws-connect="/ws/designer">
|
|||
|
|
<div class="designer-container">
|
|||
|
|
<!-- Header -->
|
|||
|
|
<header class="designer-header">
|
|||
|
|
<div class="designer-logo">
|
|||
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|||
|
|
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
|||
|
|
<path d="M2 17l10 5 10-5"/>
|
|||
|
|
<path d="M2 12l10 5 10-5"/>
|
|||
|
|
</svg>
|
|||
|
|
<span>Dialog Designer</span>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="designer-toolbar">
|
|||
|
|
<button class="toolbar-btn" id="btn-new" title="New Dialog (Ctrl+N)">
|
|||
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|||
|
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
|||
|
|
<polyline points="14 2 14 8 20 8"/>
|
|||
|
|
<line x1="12" y1="18" x2="12" y2="12"/>
|
|||
|
|
<line x1="9" y1="15" x2="15" y2="15"/>
|
|||
|
|
</svg>
|
|||
|
|
New
|
|||
|
|
</button>
|
|||
|
|
|
|||
|
|
<button class="toolbar-btn" id="btn-open"
|
|||
|
|
hx-get="/api/v1/designer/files"
|
|||
|
|
hx-target="#file-list-content"
|
|||
|
|
hx-trigger="click"
|
|||
|
|
onclick="showModal('open-modal')"
|
|||
|
|
title="Open Dialog (Ctrl+O)">
|
|||
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|||
|
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
|||
|
|
</svg>
|
|||
|
|
Open
|
|||
|
|
</button>
|
|||
|
|
|
|||
|
|
<button class="toolbar-btn primary" id="btn-save"
|
|||
|
|
hx-post="/api/v1/designer/save"
|
|||
|
|
hx-include="#designer-data"
|
|||
|
|
hx-indicator="#save-spinner"
|
|||
|
|
title="Save (Ctrl+S)">
|
|||
|
|
<span class="htmx-indicator spinner" id="save-spinner"></span>
|
|||
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|||
|
|
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
|
|||
|
|
<polyline points="17 21 17 13 7 13 7 21"/>
|
|||
|
|
<polyline points="7 3 7 8 15 8"/>
|
|||
|
|
</svg>
|
|||
|
|
Save
|
|||
|
|
</button>
|
|||
|
|
|
|||
|
|
<div class="toolbar-separator"></div>
|
|||
|
|
|
|||
|
|
<button class="toolbar-btn" id="btn-undo" title="Undo (Ctrl+Z)">
|
|||
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|||
|
|
<path d="M3 7v6h6"/>
|
|||
|
|
<path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"/>
|
|||
|
|
</svg>
|
|||
|
|
</button>
|
|||
|
|
|
|||
|
|
<button class="toolbar-btn" id="btn-redo" title="Redo (Ctrl+Y)">
|
|||
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|||
|
|
<path d="M21 7v6h-6"/>
|
|||
|
|
<path d="M3 17a9 9 0 0 1 9-9 9 9 0 0 1 6 2.3L21 13"/>
|
|||
|
|
</svg>
|
|||
|
|
</button>
|
|||
|
|
|
|||
|
|
<div class="toolbar-separator"></div>
|
|||
|
|
|
|||
|
|
<button class="toolbar-btn" id="btn-run"
|
|||
|
|
hx-post="/api/v1/designer/validate"
|
|||
|
|
hx-include="#designer-data"
|
|||
|
|
title="Validate Dialog">
|
|||
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|||
|
|
<polygon points="5 3 19 12 5 21 5 3"/>
|
|||
|
|
</svg>
|
|||
|
|
Validate
|
|||
|
|
</button>
|
|||
|
|
|
|||
|
|
<button class="toolbar-btn" id="btn-export"
|
|||
|
|
hx-get="/api/v1/designer/export"
|
|||
|
|
hx-include="#designer-data"
|
|||
|
|
title="Export as .bas">
|
|||
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|||
|
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
|||
|
|
<polyline points="7 10 12 15 17 10"/>
|
|||
|
|
<line x1="12" y1="15" x2="12" y2="3"/>
|
|||
|
|
</svg>
|
|||
|
|
Export
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</header>
|
|||
|
|
|
|||
|
|
<!-- Toolbox Panel -->
|
|||
|
|
<aside class="toolbox-panel">
|
|||
|
|
<div class="toolbox-section">
|
|||
|
|
<div class="toolbox-section-title">Conversation</div>
|
|||
|
|
|
|||
|
|
<div class="toolbox-item" draggable="true" data-node-type="TALK">
|
|||
|
|
<div class="toolbox-icon talk">
|
|||
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
|||
|
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
|||
|
|
</svg>
|
|||
|
|
</div>
|
|||
|
|
<div class="toolbox-info">
|
|||
|
|
<div class="toolbox-name">TALK</div>
|
|||
|
|
<div class="toolbox-desc">Send message to user</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="toolbox-item" draggable="true" data-node-type="HEAR">
|
|||
|
|
<div class="toolbox-icon hear">
|
|||
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
|||
|
|
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
|
|||
|
|
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
|
|||
|
|
<line x1="12" y1="19" x2="12" y2="23"/>
|
|||
|
|
<line x1="8" y1="23" x2="16" y2="23"/>
|
|||
|
|
</svg>
|
|||
|
|
</div>
|
|||
|
|
<div class="toolbox-info">
|
|||
|
|
<div class="toolbox-name">HEAR</div>
|
|||
|
|
<div class="toolbox-desc">Wait for user input</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="toolbox-item" draggable="true" data-node-type="WAIT">
|
|||
|
|
<div class="toolbox-icon wait">
|
|||
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
|||
|
|
<circle cx="12" cy="12" r="10"/>
|
|||
|
|
<polyline points="12 6 12 12 16 14"/>
|
|||
|
|
</svg>
|
|||
|
|
</div>
|
|||
|
|
<div class="toolbox-info">
|
|||
|
|
<div class="toolbox-name">WAIT</div>
|
|||
|
|
<div class="toolbox-desc">Pause execution</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="toolbox-section">
|
|||
|
|
<div class="toolbox-section-title">Logic</div>
|
|||
|
|
|
|||
|
|
<div class="toolbox-item" draggable="true" data-node-type="IF">
|
|||
|
|
<div class="toolbox-icon if">
|
|||
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
|||
|
|
<polyline points="9 18 15 12 9 6"/>
|
|||
|
|
</svg>
|
|||
|
|
</div>
|
|||
|
|
<div class="toolbox-info">
|
|||
|
|
<div class="toolbox-name">IF</div>
|
|||
|
|
<div class="toolbox-desc">Conditional branch</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="toolbox-item" draggable="true" data-node-type="SWITCH">
|
|||
|
|
<div class="toolbox-icon switch">
|
|||
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
|||
|
|
<path d="M18 8L22 12L18 16"/>
|
|||
|
|
<path d="M2 12H22"/>
|
|||
|
|
<path d="M6 8L2 12L6 16"/>
|
|||
|
|
</svg>
|
|||
|
|
</div>
|
|||
|
|
<div class="toolbox-info">
|
|||
|
|
<div class="toolbox-name">SWITCH</div>
|
|||
|
|
<div class="toolbox-desc">Multiple conditions</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="toolbox-item" draggable="true" data-node-type="FOR">
|
|||
|
|
<div class="toolbox-icon for">
|
|||
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
|||
|
|
<polyline points="23 4 23 10 17 10"/>
|
|||
|
|
<polyline points="1 20 1 14 7 14"/>
|
|||
|
|
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
|
|||
|
|
</svg>
|
|||
|
|
</div>
|
|||
|
|
<div class="toolbox-info">
|
|||
|
|
<div class="toolbox-name">FOR EACH</div>
|
|||
|
|
<div class="toolbox-desc">Loop through items</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="toolbox-section">
|
|||
|
|
<div class="toolbox-section-title">Data</div>
|
|||
|
|
|
|||
|
|
<div class="toolbox-item" draggable="true" data-node-type="SET">
|
|||
|
|
<div class="toolbox-icon set">
|
|||
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
|||
|
|
<polyline points="4 7 4 4 20 4 20 7"/>
|
|||
|
|
<line x1="9" y1="20" x2="15" y2="20"/>
|
|||
|
|
<line x1="12" y1="4" x2="12" y2="20"/>
|
|||
|
|
</svg>
|
|||
|
|
</div>
|
|||
|
|
<div class="toolbox-info">
|
|||
|
|
<div class="toolbox-name">SET</div>
|
|||
|
|
<div class="toolbox-desc">Assign variable</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="toolbox-item" draggable="true" data-node-type="GET">
|
|||
|
|
<div class="toolbox-icon get">
|
|||
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
|||
|
|
<circle cx="12" cy="12" r="3"/>
|
|||
|
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
|||
|
|
</svg>
|
|||
|
|
</div>
|
|||
|
|
<div class="toolbox-info">
|
|||
|
|
<div class="toolbox-name">GET</div>
|
|||
|
|
<div class="toolbox-desc">Fetch data</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="toolbox-item" draggable="true" data-node-type="SAVE">
|
|||
|
|
<div class="toolbox-icon send">
|
|||
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
|||
|
|
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
|
|||
|
|
<polyline points="17 21 17 13 7 13 7 21"/>
|
|||
|
|
<polyline points="7 3 7 8 15 8"/>
|
|||
|
|
</svg>
|
|||
|
|
</div>
|
|||
|
|
<div class="toolbox-info">
|
|||
|
|
<div class="toolbox-name">SAVE</div>
|
|||
|
|
<div class="toolbox-desc">Save to storage</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="toolbox-section">
|
|||
|
|
<div class="toolbox-section-title">Actions</div>
|
|||
|
|
|
|||
|
|
<div class="toolbox-item" draggable="true" data-node-type="SEND MAIL">
|
|||
|
|
<div class="toolbox-icon send">
|
|||
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
|||
|
|
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
|
|||
|
|
<polyline points="22,6 12,13 2,6"/>
|
|||
|
|
</svg>
|
|||
|
|
</div>
|
|||
|
|
<div class="toolbox-info">
|
|||
|
|
<div class="toolbox-name">SEND MAIL</div>
|
|||
|
|
<div class="toolbox-desc">Send email</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="toolbox-item" draggable="true" data-node-type="CALL">
|
|||
|
|
<div class="toolbox-icon call">
|
|||
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
|||
|
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
|||
|
|
<line x1="9" y1="9" x2="15" y2="15"/>
|
|||
|
|
<line x1="15" y1="9" x2="9" y2="15"/>
|
|||
|
|
</svg>
|
|||
|
|
</div>
|
|||
|
|
<div class="toolbox-info">
|
|||
|
|
<div class="toolbox-name">CALL</div>
|
|||
|
|
<div class="toolbox-desc">Call procedure</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="toolbox-item" draggable="true" data-node-type="POST">
|
|||
|
|
<div class="toolbox-icon call">
|
|||
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
|||
|
|
<circle cx="12" cy="12" r="10"/>
|
|||
|
|
<line x1="2" y1="12" x2="22" y2="12"/>
|
|||
|
|
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
|
|||
|
|
</svg>
|
|||
|
|
</div>
|
|||
|
|
<div class="toolbox-info">
|
|||
|
|
<div class="toolbox-name">POST</div>
|
|||
|
|
<div class="toolbox-desc">HTTP POST request</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="toolbox-section">
|
|||
|
|
<div class="toolbox-section-title">Memory</div>
|
|||
|
|
|
|||
|
|
<div class="toolbox-item" draggable="true" data-node-type="SET BOT MEMORY">
|
|||
|
|
<div class="toolbox-icon set">
|
|||
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
|||
|
|
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"/>
|
|||
|
|
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"/>
|
|||
|
|
<line x1="6" y1="6" x2="6.01" y2="6"/>
|
|||
|
|
<line x1="6" y1="18" x2="6.01" y2="18"/>
|
|||
|
|
</svg>
|
|||
|
|
</div>
|
|||
|
|
<div class="toolbox-info">
|
|||
|
|
<div class="toolbox-name">SET BOT MEMORY</div>
|
|||
|
|
<div class="toolbox-desc">Store bot data</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="toolbox-item" draggable="true" data-node-type="GET BOT MEMORY">
|
|||
|
|
<div class="toolbox-icon get">
|
|||
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
|||
|
|
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"/>
|
|||
|
|
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"/>
|
|||
|
|
<line x1="6" y1="6" x2="6.01" y2="6"/>
|
|||
|
|
<line x1="6" y1="18" x2="6.01" y2="18"/>
|
|||
|
|
</svg>
|
|||
|
|
</div>
|
|||
|
|
<div class="toolbox-info">
|
|||
|
|
<div class="toolbox-name">GET BOT MEMORY</div>
|
|||
|
|
<div class="toolbox-desc">Retrieve bot data</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="toolbox-item" draggable="true" data-node-type="SET USER MEMORY">
|
|||
|
|
<div class="toolbox-icon set">
|
|||
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
|||
|
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
|||
|
|
<circle cx="12" cy="7" r="4"/>
|
|||
|
|
</svg>
|
|||
|
|
</div>
|
|||
|
|
<div class="toolbox-info">
|
|||
|
|
<div class="toolbox-name">SET USER MEMORY</div>
|
|||
|
|
<div class="toolbox-desc">Store user data</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="toolbox-item" draggable="true" data-node-type="GET USER MEMORY">
|
|||
|
|
<div class="toolbox-icon get">
|
|||
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
|||
|
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
|||
|
|
<circle cx="12" cy="7" r="4"/>
|
|||
|
|
</svg>
|
|||
|
|
</div>
|
|||
|
|
<div class="toolbox-info">
|
|||
|
|
<div class="toolbox-name">GET USER MEMORY</div>
|
|||
|
|
<div class="toolbox-desc">Retrieve user data</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</aside>
|
|||
|
|
|
|||
|
|
<!-- Canvas Area -->
|
|||
|
|
<div class="canvas-container" id="canvas-container">
|
|||
|
|
<div class="canvas-grid"></div>
|
|||
|
|
<div class="canvas" id="canvas">
|
|||
|
|
<svg class="connections-layer" id="connections-layer">
|
|||
|
|
<defs>
|
|||
|
|
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
|||
|
|
<polygon points="0 0, 10 3.5, 0 7" fill="var(--border-color)"/>
|
|||
|
|
</marker>
|
|||
|
|
</defs>
|
|||
|
|
</svg>
|
|||
|
|
<div class="canvas-inner" id="canvas-inner">
|
|||
|
|
<!-- Nodes are rendered here dynamically -->
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Minimap -->
|
|||
|
|
<div class="minimap" id="minimap">
|
|||
|
|
<div class="minimap-viewport" id="minimap-viewport"></div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Properties Panel -->
|
|||
|
|
<aside class="properties-panel" id="properties-panel">
|
|||
|
|
<div class="properties-header">
|
|||
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|||
|
|
<circle cx="12" cy="12" r="3"/>
|
|||
|
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
|||
|
|
</svg>
|
|||
|
|
Properties
|
|||
|
|
</div>
|
|||
|
|
<div class="properties-content" id="properties-content">
|
|||
|
|
<div class="properties-empty">
|
|||
|
|
Select a node to edit its properties
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</aside>
|
|||
|
|
|
|||
|
|
<!-- Status Bar -->
|
|||
|
|
<footer class="status-bar">
|
|||
|
|
<div class="status-item">
|
|||
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|||
|
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
|||
|
|
<polyline points="14 2 14 8 20 8"/>
|
|||
|
|
</svg>
|
|||
|
|
<span id="file-name">Untitled.bas</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="status-item">
|
|||
|
|
<span id="node-count">0 nodes</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="status-item">
|
|||
|
|
<span id="connection-count">0 connections</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="status-spacer"></div>
|
|||
|
|
<div class="status-item" id="save-status">
|
|||
|
|
<span>Not saved</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="zoom-controls">
|
|||
|
|
<button class="zoom-btn" id="zoom-out" title="Zoom Out">−</button>
|
|||
|
|
<span class="zoom-value" id="zoom-value">100%</span>
|
|||
|
|
<button</span> class="zoom-btn" id="zoom-in" title="Zoom In">+</button>
|
|||
|
|
</div>
|
|||
|
|
</footer>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Context Menu -->
|
|||
|
|
<div class="context-menu" id="context-menu">
|
|||
|
|
<div class="context-menu-item" data-action="duplicate">
|
|||
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
|||
|
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
|||
|
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
|||
|
|
</svg>
|
|||
|
|
Duplicate
|
|||
|
|
</div>
|
|||
|
|
<div class="context-menu-item" data-action="copy">
|
|||
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
|||
|
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
|||
|
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
|||
|
|
</svg>
|
|||
|
|
Copy
|
|||
|
|
</div>
|
|||
|
|
<div class="context-menu-separator"></div>
|
|||
|
|
<div class="context-menu-item" data-action="bring-front">
|
|||
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
|||
|
|
<polyline points="17 11 12 6 7 11"/>
|
|||
|
|
<polyline points="17 18 12 13 7 18"/>
|
|||
|
|
</svg>
|
|||
|
|
Bring to Front
|
|||
|
|
</div>
|
|||
|
|
<div class="context-menu-item" data-action="send-back">
|
|||
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
|||
|
|
<polyline points="7 13 12 18 17 13"/>
|
|||
|
|
<polyline points="7 6 12 11 17 6"/>
|
|||
|
|
</svg>
|
|||
|
|
Send to Back
|
|||
|
|
</div>
|
|||
|
|
<div class="context-menu-separator"></div>
|
|||
|
|
<div class="context-menu-item danger" data-action="delete">
|
|||
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
|||
|
|
<polyline points="3 6 5 6 21 6"/>
|
|||
|
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
|||
|
|
</svg>
|
|||
|
|
Delete
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Open File Modal -->
|
|||
|
|
<div class="modal-overlay" id="open-modal">
|
|||
|
|
<div class="modal">
|
|||
|
|
<div class="modal-header">
|
|||
|
|
<span class="modal-title">Open Dialog File</span>
|
|||
|
|
<button class="modal-close" onclick="hideModal('open-modal')">
|
|||
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20">
|
|||
|
|
<line x1="18" y1="6" x2="6" y2="18"/>
|
|||
|
|
<line x1="6" y1="6" x2="18" y2="18"/>
|
|||
|
|
</svg>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-body">
|
|||
|
|
<div class="file-list" id="file-list-content">
|
|||
|
|
<div class="htmx-indicator" style="text-align: center; padding: 20px;">
|
|||
|
|
<div class="spinner" style="margin: 0 auto;"></div>
|
|||
|
|
<div style="margin-top: 8px; color: var(--text-secondary);">Loading files...</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-footer">
|
|||
|
|
<button class="toolbar-btn" onclick="hideModal('open-modal')">Cancel</button>
|
|||
|
|
<button class="toolbar-btn primary" id="btn-open-selected"
|
|||
|
|
hx-get="/api/v1/designer/load"
|
|||
|
|
hx-include="#selected-file"
|
|||
|
|
hx-target="#canvas-inner"
|
|||
|
|
hx-swap="innerHTML"
|
|||
|
|
onclick="hideModal('open-modal')">
|
|||
|
|
Open
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Hidden form for designer data -->
|
|||
|
|
<form id="designer-data" style="display: none;">
|
|||
|
|
<input type="hidden" name="filename" id="current-filename" value="">
|
|||
|
|
<textarea name="nodes" id="nodes-data"></textarea>
|
|||
|
|
<textarea name="connections" id="connections-data"></textarea>
|
|||
|
|
</form>
|
|||
|
|
<input type="hidden" id="selected-file" name="path" value="">
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
// Designer State
|
|||
|
|
const state = {
|
|||
|
|
nodes: new Map(),
|
|||
|
|
connections: [],
|
|||
|
|
selectedNode: null,
|
|||
|
|
selectedConnection: null,
|
|||
|
|
isDragging: false,
|
|||
|
|
isConnecting: false,
|
|||
|
|
connectionStart: null,
|
|||
|
|
zoom: 1,
|
|||
|
|
pan: { x: 0, y: 0 },
|
|||
|
|
history: [],
|
|||
|
|
historyIndex: -1,
|
|||
|
|
clipboard: null,
|
|||
|
|
nextNodeId: 1
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Node Templates
|
|||
|
|
const nodeTemplates = {
|
|||
|
|
'TALK': {
|
|||
|
|
fields: [
|
|||
|
|
{ name: 'message', label: 'Message', type: 'textarea', default: 'Hello!' }
|
|||
|
|
],
|
|||
|
|
hasInput: true,
|
|||
|
|
hasOutput: true
|
|||
|
|
},
|
|||
|
|
'HEAR': {
|
|||
|
|
fields: [
|
|||
|
|
{ name: 'variable', label: 'Variable', type: 'text', default: 'response' },
|
|||
|
|
{ name: 'type', label: 'Type', type: 'select', options: ['STRING', 'NUMBER', 'DATE', 'EMAIL', 'PHONE'], default: 'STRING' }
|
|||
|
|
],
|
|||
|
|
hasInput: true,
|
|||
|
|
hasOutput: true
|
|||
|
|
},
|
|||
|
|
'SET': {
|
|||
|
|
fields: [
|
|||
|
|
{ name: 'variable', label: 'Variable', type: 'text', default: 'value' },
|
|||
|
|
{ name: 'expression', label: 'Expression', type: 'text', default: '' }
|
|||
|
|
],
|
|||
|
|
hasInput: true,
|
|||
|
|
hasOutput: true
|
|||
|
|
},
|
|||
|
|
'IF': {
|
|||
|
|
fields: [
|
|||
|
|
{ name: 'condition', label: 'Condition', type: 'text', default: 'value = 1' }
|
|||
|
|
],
|
|||
|
|
hasInput: true,
|
|||
|
|
hasOutput: false,
|
|||
|
|
hasOutputTrue: true,
|
|||
|
|
hasOutputFalse: true
|
|||
|
|
},
|
|||
|
|
'FOR': {
|
|||
|
|
fields: [
|
|||
|
|
{ name: 'variable', label: 'Item Variable', type: 'text', default: 'item' },
|
|||
|
|
{ name: 'collection', label: 'Collection', type: 'text', default: 'items' }
|
|||
|
|
],
|
|||
|
|
hasInput: true,
|
|||
|
|
hasOutput: true,
|
|||
|
|
hasLoopOutput: true
|
|||
|
|
},
|
|||
|
|
'SWITCH': {
|
|||
|
|
fields: [
|
|||
|
|
{ name: 'expression', label: 'Expression', type: 'text', default: 'value' }
|
|||
|
|
],
|
|||
|
|
hasInput: true,
|
|||
|
|
hasOutput: true
|
|||
|
|
},
|
|||
|
|
'CALL': {
|
|||
|
|
fields: [
|
|||
|
|
{ name: 'procedure', label: 'Procedure', type: 'text', default: '' },
|
|||
|
|
{ name: 'arguments', label: 'Arguments', type: 'text', default: '' }
|
|||
|
|
],
|
|||
|
|
hasInput: true,
|
|||
|
|
hasOutput: true
|
|||
|
|
},
|
|||
|
|
'SEND MAIL': {
|
|||
|
|
fields: [
|
|||
|
|
{ name: 'to', label: 'To', type: 'text', default: '' },
|
|||
|
|
{ name: 'subject', label: 'Subject', type: 'text', default: '' },
|
|||
|
|
{ name: 'body', label: 'Body', type: 'textarea', default: '' }
|
|||
|
|
],
|
|||
|
|
hasInput: true,
|
|||
|
|
hasOutput: true
|
|||
|
|
},
|
|||
|
|
'GET': {
|
|||
|
|
fields: [
|
|||
|
|
{ name: 'url', label: 'URL', type: 'text', default: '' },
|
|||
|
|
{ name: 'variable', label: 'Result Variable', type: 'text', default: 'result' }
|
|||
|
|
],
|
|||
|
|
hasInput: true,
|
|||
|
|
hasOutput: true
|
|||
|
|
},
|
|||
|
|
'POST': {
|
|||
|
|
fields: [
|
|||
|
|
{ name: 'url', label: 'URL', type: 'text', default: '' },
|
|||
|
|
{ name: 'body', label: 'Body', type: 'textarea', default: '' },
|
|||
|
|
{ name: 'variable', label: 'Result Variable', type: 'text', default: 'result' }
|
|||
|
|
],
|
|||
|
|
hasInput: true,
|
|||
|
|
hasOutput: true
|
|||
|
|
},
|
|||
|
|
'SAVE': {
|
|||
|
|
fields: [
|
|||
|
|
{ name: 'filename', label: 'Filename', type: 'text', default: 'data.csv' },
|
|||
|
|
{ name: 'data', label: 'Data', type: 'text', default: '' }
|
|||
|
|
],
|
|||
|
|
hasInput: true,
|
|||
|
|
hasOutput: true
|
|||
|
|
},
|
|||
|
|
'WAIT': {
|
|||
|
|
fields: [
|
|||
|
|
{ name: 'duration', label: 'Duration (seconds)', type: 'text', default: '5' }
|
|||
|
|
],
|
|||
|
|
hasInput: true,
|
|||
|
|
hasOutput: true
|
|||
|
|
},
|
|||
|
|
'SET BOT MEMORY': {
|
|||
|
|
fields: [
|
|||
|
|
{ name: 'key', label: 'Key', type: 'text', default: '' },
|
|||
|
|
{ name: 'value', label: 'Value', type: 'text', default: '' }
|
|||
|
|
],
|
|||
|
|
hasInput: true,
|
|||
|
|
hasOutput: true
|
|||
|
|
},
|
|||
|
|
'GET BOT MEMORY': {
|
|||
|
|
fields: [
|
|||
|
|
{ name: 'key', label: 'Key', type: 'text', default: '' },
|
|||
|
|
{ name: 'variable', label: 'Variable', type: 'text', default: 'value' }
|
|||
|
|
],
|
|||
|
|
hasInput: true,
|
|||
|
|
hasOutput: true
|
|||
|
|
},
|
|||
|
|
'SET USER MEMORY': {
|
|||
|
|
fields: [
|
|||
|
|
{ name: 'key', label: 'Key', type: 'text', default: '' },
|
|||
|
|
{ name: 'value', label: 'Value', type: 'text', default: '' }
|
|||
|
|
],
|
|||
|
|
hasInput: true,
|
|||
|
|
hasOutput: true
|
|||
|
|
},
|
|||
|
|
'GET USER MEMORY': {
|
|||
|
|
fields: [
|
|||
|
|
{ name: 'key', label: 'Key', type: 'text', default: '' },
|
|||
|
|
{ name: 'variable', label: 'Variable', type: 'text', default: 'value' }
|
|||
|
|
],
|
|||
|
|
hasInput: true,
|
|||
|
|
hasOutput: true
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Initialize
|
|||
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|||
|
|
initDragAndDrop();
|
|||
|
|
initCanvasInteraction();
|
|||
|
|
initKeyboardShortcuts();
|
|||
|
|
initContextMenu();
|
|||
|
|
updateStatusBar();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Drag and Drop from Toolbox
|
|||
|
|
function initDragAndDrop() {
|
|||
|
|
const toolboxItems = document.querySelectorAll('.toolbox-item');
|
|||
|
|
const canvas = document.getElementById('canvas-inner');
|
|||
|
|
|
|||
|
|
toolboxItems.forEach(item => {
|
|||
|
|
item.addEventListener('dragstart', (e) => {
|
|||
|
|
e.dataTransfer.setData('nodeType', item.dataset.nodeType);
|
|||
|
|
item.classList.add('dragging');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
item.addEventListener('dragend', () => {
|
|||
|
|
item.classList.remove('dragging');
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
canvas.addEventListener('dragover', (e) => {
|
|||
|
|
e.preventDefault();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
canvas.addEventListener('drop', (e) => {
|
|||
|
|
e.preventDefault();
|
|||
|
|
const nodeType = e.dataTransfer.getData('nodeType');
|
|||
|
|
if (nodeType) {
|
|||
|
|
const rect = canvas.getBoundingClientRect();
|
|||
|
|
const x = (e.clientX - rect.left) / state.zoom;
|
|||
|
|
const y = (e.clientY - rect.top) / state.zoom;
|
|||
|
|
createNode(nodeType, snapToGrid(x), snapToGrid(y));
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Canvas Interaction
|
|||
|
|
function initCanvasInteraction() {
|
|||
|
|
const canvas = document.getElementById('canvas');
|
|||
|
|
const container = document.getElementById('canvas-container');
|
|||
|
|
|
|||
|
|
// Pan with middle mouse or space+drag
|
|||
|
|
let isPanning = false;
|
|||
|
|
let panStart = { x: 0, y: 0 };
|
|||
|
|
|
|||
|
|
canvas.addEventListener('mousedown', (e) => {
|
|||
|
|
if (e.button === 1 || (e.button === 0 && e.target === canvas)) {
|
|||
|
|
isPanning = true;
|
|||
|
|
panStart = { x: e.clientX - state.pan.x, y: e.clientY - state.pan.y };
|
|||
|
|
canvas.style.cursor = 'grabbing';
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
document.addEventListener('mousemove', (e) => {
|
|||
|
|
if (isPanning) {
|
|||
|
|
state.pan.x = e.clientX - panStart.x;
|
|||
|
|
state.pan.y = e.clientY - panStart.y;
|
|||
|
|
updateCanvasTransform();
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
document.addEventListener('mouseup', () => {
|
|||
|
|
isPanning = false;
|
|||
|
|
canvas.style.cursor = 'default';
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Zoom with scroll
|
|||
|
|
container.addEventListener('wheel', (e) => {
|
|||
|
|
e.preventDefault();
|
|||
|
|
const delta = e.deltaY > 0 ? -0.1 : 0.1;
|
|||
|
|
const newZoom = Math.min(Math.max(state.zoom + delta, 0.25), 2);
|
|||
|
|
state.zoom = newZoom;
|
|||
|
|
updateCanvasTransform();
|
|||
|
|
updateZoomDisplay();
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updateCanvasTransform() {
|
|||
|
|
const inner = document.getElementById('canvas-inner');
|
|||
|
|
inner.style.transform = `translate(${state.pan.x}px, ${state.pan.y}px) scale(${state.zoom})`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updateZoomDisplay() {
|
|||
|
|
document.getElementById('zoom-value').textContent = Math.round(state.zoom * 100) + '%';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Grid snapping
|
|||
|
|
function snapToGrid(value, gridSize = 20) {
|
|||
|
|
return Math.round(value / gridSize) * gridSize;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Create Node
|
|||
|
|
function createNode(type, x, y) {
|
|||
|
|
const template = nodeTemplates[type];
|
|||
|
|
if (!template) return;
|
|||
|
|
|
|||
|
|
const id = 'node-' + state.nextNodeId++;
|
|||
|
|
const node = {
|
|||
|
|
id,
|
|||
|
|
type,
|
|||
|
|
x,
|
|||
|
|
y,
|
|||
|
|
fields: {}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Initialize field values
|
|||
|
|
template.fields.forEach(field => {
|
|||
|
|
node.fields[field.name] = field.default;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
state.nodes.set(id, node);
|
|||
|
|
renderNode(node);
|
|||
|
|
saveToHistory();
|
|||
|
|
updateStatusBar();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Render Node
|
|||
|
|
function renderNode(node) {
|
|||
|
|
const template = nodeTemplates[node.type];
|
|||
|
|
const typeClass = node.type.toLowerCase().replace(/\s+/g, '-');
|
|||
|
|
|
|||
|
|
let fieldsHtml = '';
|
|||
|
|
template.fields.forEach(field => {
|
|||
|
|
const value = node.fields[field.name] || '';
|
|||
|
|
if (field.type === 'textarea') {
|
|||
|
|
fieldsHtml += `
|
|||
|
|
<div class="node-field">
|
|||
|
|
<label class="node-field-label">${field.label}</label>
|
|||
|
|
<textarea class="node-field-input" data-field="${field.name}" rows="2">${value}</textarea>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
} else if (field.type === 'select') {
|
|||
|
|
const options = field.options.map(opt =>
|
|||
|
|
`<option value="${opt}" ${opt === value ? 'selected' : ''}>${opt}</option>`
|
|||
|
|
).join('');
|
|||
|
|
fieldsHtml += `
|
|||
|
|
<div class="node-field">
|
|||
|
|
<label class="node-field-label">${field.label}</label>
|
|||
|
|
<select class="node-field-select" data-field="${field.name}">${options}</select>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
} else {
|
|||
|
|
fieldsHtml += `
|
|||
|
|
<div class="node-field">
|
|||
|
|
<label class="node-field-label">${field.label}</label>
|
|||
|
|
<input type="text" class="node-field-input" data-field="${field.name}" value="${value}">
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
let portsHtml = '';
|
|||
|
|
if (template.hasInput) {
|
|||
|
|
portsHtml += `<div class="node-port input" data-port="input"></div>`;
|
|||
|
|
}
|
|||
|
|
if (template.hasOutput) {
|
|||
|
|
portsHtml += `<div class="node-port output" data-port="output"></div>`;
|
|||
|
|
}
|
|||
|
|
if (template.hasOutputTrue) {
|
|||
|
|
portsHtml += `<div class="node-port output-true" data-port="true" title="True"></div>`;
|
|||
|
|
}
|
|||
|
|
if (template.hasOutputFalse) {
|
|||
|
|
portsHtml += `<div class="node-port output-false" data-port="false" title="False"></div>`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const nodeEl = document.createElement('div');
|
|||
|
|
nodeEl.className = 'node';
|
|||
|
|
nodeEl.id = node.id;
|
|||
|
|
nodeEl.style.left = node.x + 'px';
|
|||
|
|
nodeEl.style.top = node.y + 'px';
|
|||
|
|
nodeEl.innerHTML = `
|
|||
|
|
<div class="node-header ${typeClass}">
|
|||
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
|||
|
|
${getNodeIcon(node.type)}
|
|||
|
|
</svg>
|
|||
|
|
${node.type}
|
|||
|
|
</div>
|
|||
|
|
<div class="node-body">
|
|||
|
|
${fieldsHtml}
|
|||
|
|
</div>
|
|||
|
|
${portsHtml}
|
|||
|
|
`;
|
|||
|
|
|
|||
|
|
// Make draggable
|
|||
|
|
nodeEl.addEventListener('mousedown', (e) => {
|
|||
|
|
if (e.target.classList.contains('node-port')) return;
|
|||
|
|
selectNode(node.id);
|
|||
|
|
startNodeDrag(e, node);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Field change handlers
|
|||
|
|
nodeEl.querySelectorAll('.node-field-input, .node-field-select').forEach(input => {
|
|||
|
|
input.addEventListener('change', (e) => {
|
|||
|
|
node.fields[e.target.dataset.field] = e.target.value;
|
|||
|
|
saveToHistory();
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Port handlers for connections
|
|||
|
|
nodeEl.querySelectorAll('.node-port').forEach(port => {
|
|||
|
|
port.addEventListener('mousedown', (e) => {
|
|||
|
|
e.stopPropagation();
|
|||
|
|
startConnection(node.id, port.dataset.port);
|
|||
|
|
});
|
|||
|
|
port.addEventListener('mouseup', (e) => {
|
|||
|
|
e.stopPropagation();
|
|||
|
|
endConnection(node.id, port.dataset.port);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
document.getElementById('canvas-inner').appendChild(nodeEl);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getNodeIcon(type) {
|
|||
|
|
const icons = {
|
|||
|
|
'TALK': '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
|
|||
|
|
'HEAR': '<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/>',
|
|||
|
|
'SET': '<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>',
|
|||
|
|
'IF': '<path d="M16 3h5v5"/><path d="M8 3H3v5"/><path d="M12 22v-8.3a4 4 0 0 0-1.172-2.872L3 3"/><path d="m15 9 6-6"/>',
|
|||
|
|
'FOR': '<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/>',
|
|||
|
|
'SWITCH': '<path d="M18 20V10"/><path d="M12 20V4"/><path d="M6 20v-6"/>',
|
|||
|
|
'CALL': '<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.362 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.338 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/>',
|
|||
|
|
'SEND MAIL': '<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/>',
|
|||
|
|
'GET': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>',
|
|||
|
|
'POST': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>',
|
|||
|
|
'SAVE': '<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/>',
|
|||
|
|
'WAIT': '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
|
|||
|
|
'SET BOT MEMORY': '<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/>',
|
|||
|
|
'GET BOT MEMORY': '<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="7 10 12 15 17 10"/>',
|
|||
|
|
'SET USER MEMORY': '<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
|
|||
|
|
'GET USER MEMORY': '<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>'
|
|||
|
|
};
|
|||
|
|
return icons[type] || '<circle cx="12" cy="12" r="10"/>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Node dragging
|
|||
|
|
function startNodeDrag(e, node) {
|
|||
|
|
state.isDragging = true;
|
|||
|
|
const nodeEl = document.getElementById(node.id);
|
|||
|
|
const startX = e.clientX;
|
|||
|
|
const startY = e.clientY;
|
|||
|
|
const origX = node.x;
|
|||
|
|
const origY = node.y;
|
|||
|
|
|
|||
|
|
function onMove(e) {
|
|||
|
|
const dx = (e.clientX - startX) / state.zoom;
|
|||
|
|
const dy = (e.clientY - startY) / state.zoom;
|
|||
|
|
node.x = snapToGrid(origX + dx);
|
|||
|
|
node.y = snapToGrid(origY + dy);
|
|||
|
|
nodeEl.style.left = node.x + 'px';
|
|||
|
|
nodeEl.style.top = node.y + 'px';
|
|||
|
|
updateConnections();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function onUp() {
|
|||
|
|
state.isDragging = false;
|
|||
|
|
document.removeEventListener('mousemove', onMove);
|
|||
|
|
document.removeEventListener('mouseup', onUp);
|
|||
|
|
saveToHistory();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
document.addEventListener('mousemove', onMove);
|
|||
|
|
document.addEventListener('mouseup', onUp);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Node selection
|
|||
|
|
function selectNode(id) {
|
|||
|
|
if (state.selectedNode) {
|
|||
|
|
const prevEl = document.getElementById(state.selectedNode);
|
|||
|
|
if (prevEl) prevEl.classList.remove('selected');
|
|||
|
|
}
|
|||
|
|
state.selectedNode = id;
|
|||
|
|
const nodeEl = document.getElementById(id);
|
|||
|
|
if (nodeEl) nodeEl.classList.add('selected');
|
|||
|
|
updatePropertiesPanel();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function deselectAll() {
|
|||
|
|
if (state.selectedNode) {
|
|||
|
|
const el = document.getElementById(state.selectedNode);
|
|||
|
|
if (el) el.classList.remove('selected');
|
|||
|
|
}
|
|||
|
|
state.selectedNode = null;
|
|||
|
|
state.selectedConnection = null;
|
|||
|
|
updatePropertiesPanel();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Connections
|
|||
|
|
function startConnection(nodeId, portType) {
|
|||
|
|
state.isConnecting = true;
|
|||
|
|
state.connectionStart = { nodeId, portType };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function endConnection(nodeId, portType) {
|
|||
|
|
if (!state.isConnecting || !state.connectionStart) return;
|
|||
|
|
if (state.connectionStart.nodeId === nodeId) {
|
|||
|
|
state.isConnecting = false;
|
|||
|
|
state.connectionStart = null;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Only allow output to input connections
|
|||
|
|
if (state.connectionStart.portType === 'input' || portType !== 'input') {
|
|||
|
|
state.isConnecting = false;
|
|||
|
|
state.connectionStart = null;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const connection = {
|
|||
|
|
from: state.connectionStart.nodeId,
|
|||
|
|
fromPort: state.connectionStart.portType,
|
|||
|
|
to: nodeId,
|
|||
|
|
toPort: portType
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
state.connections.push(connection);
|
|||
|
|
state.isConnecting = false;
|
|||
|
|
state.connectionStart = null;
|
|||
|
|
updateConnections();
|
|||
|
|
saveToHistory();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updateConnections() {
|
|||
|
|
const svg = document.getElementById('connections-svg');
|
|||
|
|
let paths = '';
|
|||
|
|
|
|||
|
|
state.connections.forEach((conn, index) => {
|
|||
|
|
const fromEl = document.getElementById(conn.from);
|
|||
|
|
const toEl = document.getElementById(conn.to);
|
|||
|
|
if (!fromEl || !toEl) return;
|
|||
|
|
|
|||
|
|
const fromPort = fromEl.querySelector(`[data-port="${conn.fromPort}"]`);
|
|||
|
|
const toPort = toEl.querySelector(`[data-port="${conn.toPort}"]`);
|
|||
|
|
if (!fromPort || !toPort) return;
|
|||
|
|
|
|||
|
|
const fromRect = fromPort.getBoundingClientRect();
|
|||
|
|
const toRect = toPort.getBoundingClientRect();
|
|||
|
|
const canvasRect = document.getElementById('canvas-inner').getBoundingClientRect();
|
|||
|
|
|
|||
|
|
const x1 = (fromRect.left + fromRect.width / 2 - canvasRect.left) / state.zoom;
|
|||
|
|
const y1 = (fromRect.top + fromRect.height / 2 - canvasRect.top) / state.zoom;
|
|||
|
|
const x2 = (toRect.left + toRect.width / 2 - canvasRect.left) / state.zoom;
|
|||
|
|
const y2 = (toRect.top + toRect.height / 2 - canvasRect.top) / state.zoom;
|
|||
|
|
|
|||
|
|
const midX = (x1 + x2) / 2;
|
|||
|
|
const d = `M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}`;
|
|||
|
|
|
|||
|
|
paths += `<path class="connection" d="${d}" data-index="${index}"/>`;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
svg.innerHTML = `
|
|||
|
|
<defs>
|
|||
|
|
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
|||
|
|
<polygon points="0 0, 10 3.5, 0 7" fill="var(--primary)"/>
|
|||
|
|
</marker>
|
|||
|
|
</defs>
|
|||
|
|
${paths}
|
|||
|
|
`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Properties Panel
|
|||
|
|
function updatePropertiesPanel() {
|
|||
|
|
const content = document.getElementById('properties-content');
|
|||
|
|
const empty = document.getElementById('properties-empty');
|
|||
|
|
|
|||
|
|
if (!state.selectedNode) {
|
|||
|
|
content.style.display = 'none';
|
|||
|
|
empty.style.display = 'block';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const node = state.nodes.get(state.selectedNode);
|
|||
|
|
if (!node) return;
|
|||
|
|
|
|||
|
|
const template = nodeTemplates[node.type];
|
|||
|
|
empty.style.display = 'none';
|
|||
|
|
content.style.display = 'block';
|
|||
|
|
|
|||
|
|
let html = `
|
|||
|
|
<div class="property-group">
|
|||
|
|
<div class="property-group-title">Node Info</div>
|
|||
|
|
<div class="property-field">
|
|||
|
|
<label class="property-label">Type</label>
|
|||
|
|
<input type="text" class="property-input" value="${node.type}" readonly>
|
|||
|
|
</div>
|
|||
|
|
<div class="property-field">
|
|||
|
|
<label class="property-label">ID</label>
|
|||
|
|
<input type="text" class="property-input" value="${node.id}" readonly>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="property-group">
|
|||
|
|
<div class="property-group-title">Properties</div>
|
|||
|
|
`;
|
|||
|
|
|
|||
|
|
template.fields.forEach(field => {
|
|||
|
|
const value = node.fields[field.name] || '';
|
|||
|
|
if (field.type === 'textarea') {
|
|||
|
|
html += `
|
|||
|
|
<div class="property-field">
|
|||
|
|
<label class="property-label">${field.label}</label>
|
|||
|
|
<textarea class="property-textarea" data-node="${node.id}" data-field="${field.name}">${value}</textarea>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
} else {
|
|||
|
|
html += `
|
|||
|
|
<div class="property-field">
|
|||
|
|
<label class="property-label">${field.label}</label>
|
|||
|
|
<input type="text" class="property-input" data-node="${node.id}" data-field="${field.name}" value="${value}">
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
html += '</div>';
|
|||
|
|
content.innerHTML = html;
|
|||
|
|
|
|||
|
|
// Add change handlers
|
|||
|
|
content.querySelectorAll('.property-input:not([readonly]), .property-textarea').forEach(input => {
|
|||
|
|
input.addEventListener('change', (e) => {
|
|||
|
|
const n = state.nodes.get(e.target.dataset.node);
|
|||
|
|
if (n) {
|
|||
|
|
n.fields[e.target.dataset.field] = e.target.value;
|
|||
|
|
// Update node view
|
|||
|
|
const nodeInput = document.querySelector(`#${n.id} [data-field="${e.target.dataset.field}"]`);
|
|||
|
|
if (nodeInput) nodeInput.value = e.target.value;
|
|||
|
|
saveToHistory();
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// History (Undo/Redo)
|
|||
|
|
function saveToHistory() {
|
|||
|
|
const snapshot = {
|
|||
|
|
nodes: Array.from(state.nodes.entries()).map(([id, node]) => ({...node})),
|
|||
|
|
connections: [...state.connections]
|
|||
|
|
};
|
|||
|
|
state.history = state.history.slice(0, state.historyIndex + 1);
|
|||
|
|
state.history.push(snapshot);
|
|||
|
|
state.historyIndex = state.history.length - 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function undo() {
|
|||
|
|
if (state.historyIndex > 0) {
|
|||
|
|
state.historyIndex--;
|
|||
|
|
restoreSnapshot(state.history[state.historyIndex]);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function redo() {
|
|||
|
|
if (state.historyIndex < state.history.length - 1) {
|
|||
|
|
state.historyIndex++;
|
|||
|
|
restoreSnapshot(state.history[state.historyIndex]);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function restoreSnapshot(snapshot) {
|
|||
|
|
// Clear canvas
|
|||
|
|
document.getElementById('canvas-inner').innerHTML = '';
|
|||
|
|
state.nodes.clear();
|
|||
|
|
state.connections = [];
|
|||
|
|
|
|||
|
|
// Restore nodes
|
|||
|
|
snapshot.nodes.forEach(node => {
|
|||
|
|
state.nodes.set(node.id, {...node});
|
|||
|
|
renderNode(node);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Restore connections
|
|||
|
|
state.connections = [...snapshot.connections];
|
|||
|
|
updateConnections();
|
|||
|
|
updateStatusBar();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Keyboard Shortcuts
|
|||
|
|
function initKeyboardShortcuts() {
|
|||
|
|
document.addEventListener('keydown', (e) => {
|
|||
|
|
if (e.ctrlKey || e.metaKey) {
|
|||
|
|
switch (e.key) {
|
|||
|
|
case 's':
|
|||
|
|
e.preventDefault();
|
|||
|
|
saveDesign();
|
|||
|
|
break;
|
|||
|
|
case 'o':
|
|||
|
|
e.preventDefault();
|
|||
|
|
showModal('open-modal');
|
|||
|
|
break;
|
|||
|
|
case 'z':
|
|||
|
|
e.preventDefault();
|
|||
|
|
if (e.shiftKey) redo();
|
|||
|
|
else undo();
|
|||
|
|
break;
|
|||
|
|
case 'y':
|
|||
|
|
e.preventDefault();
|
|||
|
|
redo();
|
|||
|
|
break;
|
|||
|
|
case 'c':
|
|||
|
|
if (state.selectedNode) {
|
|||
|
|
e.preventDefault();
|
|||
|
|
state.clipboard = {...state.nodes.get(state.selectedNode)};
|
|||
|
|
}
|
|||
|
|
break;
|
|||
|
|
case 'v':
|
|||
|
|
if (state.clipboard) {
|
|||
|
|
e.preventDefault();
|
|||
|
|
const newNode = {...state.clipboard};
|
|||
|
|
newNode.id = 'node-' + state.nextNodeId++;
|
|||
|
|
newNode.x += 40;
|
|||
|
|
newNode.y += 40;
|
|||
|
|
state.nodes.set(newNode.id, newNode);
|
|||
|
|
renderNode(newNode);
|
|||
|
|
selectNode(newNode.id);
|
|||
|
|
saveToHistory();
|
|||
|
|
}
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (e.key === 'Delete' && state.selectedNode) {
|
|||
|
|
deleteSelectedNode();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (e.key === 'Escape') {
|
|||
|
|
deselectAll();
|
|||
|
|
hideContextMenu();
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function deleteSelectedNode() {
|
|||
|
|
if (!state.selectedNode) return;
|
|||
|
|
const nodeEl = document.getElementById(state.selectedNode);
|
|||
|
|
if (nodeEl) nodeEl.remove();
|
|||
|
|
|
|||
|
|
// Remove connections
|
|||
|
|
state.connections = state.connections.filter(
|
|||
|
|
conn => conn.from !== state.selectedNode && conn.to !== state.selectedNode
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
state.nodes.delete(state.selectedNode);
|
|||
|
|
state.selectedNode = null;
|
|||
|
|
updateConnections();
|
|||
|
|
updatePropertiesPanel();
|
|||
|
|
updateStatusBar();
|
|||
|
|
saveToHistory();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Context Menu
|
|||
|
|
function initContextMenu() {
|
|||
|
|
const canvas = document.getElementById('canvas');
|
|||
|
|
const contextMenu = document.getElementById('context-menu');
|
|||
|
|
|
|||
|
|
canvas.addEventListener('contextmenu', (e) => {
|
|||
|
|
e.preventDefault();
|
|||
|
|
const nodeEl = e.target.closest('.node');
|
|||
|
|
if (nodeEl) {
|
|||
|
|
selectNode(nodeEl.id);
|
|||
|
|
}
|
|||
|
|
contextMenu.style.left = e.clientX + 'px';
|
|||
|
|
contextMenu.style.top = e.clientY + 'px';
|
|||
|
|
contextMenu.classList.add('visible');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
document.addEventListener('click', () => {
|
|||
|
|
hideContextMenu();
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function hideContextMenu() {
|
|||
|
|
document.getElementById('context-menu').classList.remove('visible');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Context Menu Actions
|
|||
|
|
function duplicateNode() {
|
|||
|
|
if (!state.selectedNode) return;
|
|||
|
|
const node = state.nodes.get(state.selectedNode);
|
|||
|
|
if (!node) return;
|
|||
|
|
|
|||
|
|
const newNode = {...node, fields: {...node.fields}};
|
|||
|
|
newNode.id = 'node-' + state.nextNodeId++;
|
|||
|
|
newNode.x += 40;
|
|||
|
|
newNode.y += 40;
|
|||
|
|
state.nodes.set(newNode.id, newNode);
|
|||
|
|
renderNode(newNode);
|
|||
|
|
selectNode(newNode.id);
|
|||
|
|
saveToHistory();
|
|||
|
|
hideContextMenu();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Status Bar
|
|||
|
|
function updateStatusBar() {
|
|||
|
|
document.getElementById('node-count').textContent = state.nodes.size + ' nodes';
|
|||
|
|
document.getElementById('connection-count').textContent = state.connections.length + ' connections';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Zoom Controls
|
|||
|
|
function zoomIn() {
|
|||
|
|
state.zoom = Math.min(state.zoom + 0.1, 2);
|
|||
|
|
updateCanvasTransform();
|
|||
|
|
updateZoomDisplay();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function zoomOut() {
|
|||
|
|
state.zoom = Math.max(state.zoom - 0.1, 0.25);
|
|||
|
|
updateCanvasTransform();
|
|||
|
|
updateZoomDisplay();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Modal Management
|
|||
|
|
function showModal(id) {
|
|||
|
|
document.getElementById(id).classList.add('visible');
|
|||
|
|
if (id === 'open-modal') {
|
|||
|
|
htmx.trigger('#file-list-content', 'load');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function hideModal(id) {
|
|||
|
|
document.getElementById(id).classList.remove('visible');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Save Design
|
|||
|
|
function saveDesign() {
|
|||
|
|
const nodesData = Array.from(state.nodes.values());
|
|||
|
|
document.getElementById('nodes-data').value = JSON.stringify(nodesData);
|
|||
|
|
document.getElementById('connections-data').value = JSON.stringify(state.connections);
|
|||
|
|
|
|||
|
|
// Trigger HTMX save
|
|||
|
|
htmx.ajax('POST', '/api/v1/designer/save', {
|
|||
|
|
source: document.getElementById('designer-data'),
|
|||
|
|
target: '#status-message'
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Export to .bas
|
|||
|
|
function exportToBas() {
|
|||
|
|
let basCode = "' Generated by General Bots Designer\n";
|
|||
|
|
basCode += "' " + new Date().toISOString() + "\n\n";
|
|||
|
|
|
|||
|
|
// Sort nodes by position (top to bottom, left to right)
|
|||
|
|
const sortedNodes = Array.from(state.nodes.values()).sort((a, b) => {
|
|||
|
|
if (Math.abs(a.y - b.y) < 30) return a.x - b.x;
|
|||
|
|
return a.y - b.y;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
sortedNodes.forEach(node => {
|
|||
|
|
const template = nodeTemplates[node.type];
|
|||
|
|
switch (node.type) {
|
|||
|
|
case 'TALK':
|
|||
|
|
basCode += `TALK "${node.fields.message}"\n`;
|
|||
|
|
break;
|
|||
|
|
case 'HEAR':
|
|||
|
|
basCode += `HEAR ${node.fields.variable} AS ${node.fields.type}\n`;
|
|||
|
|
break;
|
|||
|
|
case 'SET':
|
|||
|
|
basCode += `SET ${node.fields.variable} = ${node.fields.expression}\n`;
|
|||
|
|
break;
|
|||
|
|
case 'IF':
|
|||
|
|
basCode += `IF ${node.fields.condition} THEN\n`;
|
|||
|
|
break;
|
|||
|
|
case 'FOR':
|
|||
|
|
basCode += `FOR EACH ${node.fields.variable} IN ${node.fields.collection}\n`;
|
|||
|
|
break;
|
|||
|
|
case 'CALL':
|
|||
|
|
basCode += `CALL ${node.fields.procedure}(${node.fields.arguments})\n`;
|
|||
|
|
break;
|
|||
|
|
case 'SEND MAIL':
|
|||
|
|
basCode += `SEND MAIL TO "${node.fields.to}" SUBJECT "${node.fields.subject}" BODY "${node.fields.body}"\n`;
|
|||
|
|
break;
|
|||
|
|
case 'GET':
|
|||
|
|
basCode += `GET ${node.fields.url} TO ${node.fields.variable}\n`;
|
|||
|
|
break;
|
|||
|
|
case 'POST':
|
|||
|
|
basCode += `POST ${node.fields.url} WITH ${node.fields.body} TO ${node.fields.variable}\n`;
|
|||
|
|
break;
|
|||
|
|
case 'SAVE':
|
|||
|
|
basCode += `SAVE ${node.fields.data} TO "${node.fields.filename}"\n`;
|
|||
|
|
break;
|
|||
|
|
case 'WAIT':
|
|||
|
|
basCode += `WAIT ${node.fields.duration}\n`;
|
|||
|
|
break;
|
|||
|
|
case 'SET BOT MEMORY':
|
|||
|
|
basCode += `SET BOT MEMORY "${node.fields.key}", ${node.fields.value}\n`;
|
|||
|
|
break;
|
|||
|
|
case 'GET BOT MEMORY':
|
|||
|
|
basCode += `GET BOT MEMORY "${node.fields.key}" AS ${node.fields.variable}\n`;
|
|||
|
|
break;
|
|||
|
|
case 'SET USER MEMORY':
|
|||
|
|
basCode += `SET USER MEMORY "${node.fields.key}", ${node.fields.value}\n`;
|
|||
|
|
break;
|
|||
|
|
case 'GET USER MEMORY':
|
|||
|
|
basCode += `GET USER MEMORY "${node.fields.key}" AS ${node.fields.variable}\n`;
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Download as file
|
|||
|
|
const blob = new Blob([basCode], { type: 'text/plain' });
|
|||
|
|
const url = URL.createObjectURL(blob);
|
|||
|
|
const a = document.createElement('a');
|
|||
|
|
a.href = url;
|
|||
|
|
a.download = (document.getElementById('current-filename').value || 'dialog') + '.bas';
|
|||
|
|
a.click();
|
|||
|
|
URL.revokeObjectURL(url);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// New Design
|
|||
|
|
function newDesign() {
|
|||
|
|
if (state.nodes.size > 0) {
|
|||
|
|
if (!confirm('Clear current design? Unsaved changes will be lost.')) return;
|
|||
|
|
}
|
|||
|
|
document.getElementById('canvas-inner').innerHTML = '';
|
|||
|
|
state.nodes.clear();
|
|||
|
|
state.connections = [];
|
|||
|
|
state.selectedNode = null;
|
|||
|
|
state.history = [];
|
|||
|
|
state.historyIndex = -1;
|
|||
|
|
state.nextNodeId = 1;
|
|||
|
|
document.getElementById('current-filename').value = '';
|
|||
|
|
document.getElementById('file-name').textContent = 'Untitled';
|
|||
|
|
updateConnections();
|
|||
|
|
updatePropertiesPanel();
|
|||
|
|
updateStatusBar();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// File selection in open modal
|
|||
|
|
document.addEventListener('click', (e) => {
|
|||
|
|
const fileItem = e.target.closest('.file-item');
|
|||
|
|
if (fileItem) {
|
|||
|
|
document.querySelectorAll('.file-item').forEach(f => f.classList.remove('selected'));
|
|||
|
|
fileItem.classList.add('selected');
|
|||
|
|
document.getElementById('selected-file').value = fileItem.dataset.path;
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
</script>
|
|||
|
|
</body>
|
|||
|
|
</html>
|