devglide 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +338 -0
- package/bin/claude-md-template.js +94 -0
- package/bin/devglide.js +387 -0
- package/package.json +85 -0
- package/pnpm-workspace.yaml +3 -0
- package/src/apps/coder/.turbo/turbo-lint.log +5 -0
- package/src/apps/coder/package.json +16 -0
- package/src/apps/coder/public/favicon.svg +7 -0
- package/src/apps/coder/public/page.css +275 -0
- package/src/apps/coder/public/page.js +528 -0
- package/src/apps/coder/server.js +3 -0
- package/src/apps/documentation/public/page.css +597 -0
- package/src/apps/documentation/public/page.js +609 -0
- package/src/apps/kanban/.turbo/turbo-lint.log +97 -0
- package/src/apps/kanban/.turbo/turbo-typecheck.log +5 -0
- package/src/apps/kanban/package.json +32 -0
- package/src/apps/kanban/public/favicon.svg +7 -0
- package/src/apps/kanban/public/page.css +1010 -0
- package/src/apps/kanban/public/page.js +1730 -0
- package/src/apps/kanban/public/vendor/marked.min.js +6 -0
- package/src/apps/kanban/public/vendor/sortable.min.js +2 -0
- package/src/apps/kanban/src/db.ts +319 -0
- package/src/apps/kanban/src/index.ts +14 -0
- package/src/apps/kanban/src/mcp-helpers.test.ts +88 -0
- package/src/apps/kanban/src/mcp-helpers.ts +60 -0
- package/src/apps/kanban/src/mcp.ts +59 -0
- package/src/apps/kanban/src/routes/attachments.ts +161 -0
- package/src/apps/kanban/src/routes/features.ts +233 -0
- package/src/apps/kanban/src/routes/issues.ts +373 -0
- package/src/apps/kanban/src/tools/feature-tools.ts +164 -0
- package/src/apps/kanban/src/tools/item-tools.ts +307 -0
- package/src/apps/kanban/src/tools/versioned-entry-tools.ts +72 -0
- package/src/apps/kanban/tsconfig.check.json +9 -0
- package/src/apps/kanban/tsconfig.json +9 -0
- package/src/apps/keymap/.turbo/turbo-lint.log +5 -0
- package/src/apps/keymap/package.json +16 -0
- package/src/apps/keymap/public/page.css +275 -0
- package/src/apps/keymap/public/page.js +294 -0
- package/src/apps/keymap/server.js +25 -0
- package/src/apps/log/.turbo/turbo-build.log +5 -0
- package/src/apps/log/.turbo/turbo-lint.log +45 -0
- package/src/apps/log/.turbo/turbo-typecheck.log +5 -0
- package/src/apps/log/node_modules/.bin/tsc +21 -0
- package/src/apps/log/node_modules/.bin/tsserver +21 -0
- package/src/apps/log/node_modules/.bin/tsx +21 -0
- package/src/apps/log/package.json +36 -0
- package/src/apps/log/public/console-sniffer.js +221 -0
- package/src/apps/log/public/favicon.svg +7 -0
- package/src/apps/log/public/page.css +322 -0
- package/src/apps/log/public/page.js +463 -0
- package/src/apps/log/src/index.ts +9 -0
- package/src/apps/log/src/mcp.ts +122 -0
- package/src/apps/log/src/routes/log.ts +333 -0
- package/src/apps/log/src/routes/status.ts +25 -0
- package/src/apps/log/src/server-sniffer.ts +118 -0
- package/src/apps/log/src/services/file-patterns.ts +39 -0
- package/src/apps/log/src/services/file-tailer.ts +228 -0
- package/src/apps/log/src/services/line-parser.ts +94 -0
- package/src/apps/log/src/services/log-writer.ts +39 -0
- package/src/apps/log/tsconfig.json +8 -0
- package/src/apps/prompts/.turbo/turbo-build.log +5 -0
- package/src/apps/prompts/.turbo/turbo-lint.log +24 -0
- package/src/apps/prompts/.turbo/turbo-typecheck.log +5 -0
- package/src/apps/prompts/mcp.ts +175 -0
- package/src/apps/prompts/node_modules/.bin/tsc +21 -0
- package/src/apps/prompts/node_modules/.bin/tsserver +21 -0
- package/src/apps/prompts/node_modules/.bin/tsx +21 -0
- package/src/apps/prompts/package.json +25 -0
- package/src/apps/prompts/public/page.css +315 -0
- package/src/apps/prompts/public/page.js +541 -0
- package/src/apps/prompts/services/prompt-store.ts +212 -0
- package/src/apps/prompts/src/index.ts +9 -0
- package/src/apps/prompts/tsconfig.json +8 -0
- package/src/apps/prompts/types.ts +27 -0
- package/src/apps/shell/.turbo/turbo-build.log +5 -0
- package/src/apps/shell/.turbo/turbo-lint.log +34 -0
- package/src/apps/shell/.turbo/turbo-typecheck.log +5 -0
- package/src/apps/shell/package.json +35 -0
- package/src/apps/shell/public/favicon.svg +7 -0
- package/src/apps/shell/public/page.css +407 -0
- package/src/apps/shell/public/page.js +1577 -0
- package/src/apps/shell/src/index.ts +150 -0
- package/src/apps/shell/src/mcp.ts +398 -0
- package/src/apps/shell/src/shell-types.ts +41 -0
- package/src/apps/shell/tsconfig.json +8 -0
- package/src/apps/test/.turbo/turbo-build.log +5 -0
- package/src/apps/test/.turbo/turbo-lint.log +27 -0
- package/src/apps/test/.turbo/turbo-typecheck.log +5 -0
- package/src/apps/test/node_modules/.bin/tsc +21 -0
- package/src/apps/test/node_modules/.bin/tsserver +21 -0
- package/src/apps/test/node_modules/.bin/tsx +21 -0
- package/src/apps/test/node_modules/.bin/uuid +21 -0
- package/src/apps/test/package.json +35 -0
- package/src/apps/test/public/favicon.svg +7 -0
- package/src/apps/test/public/page.css +499 -0
- package/src/apps/test/public/page.js +417 -0
- package/src/apps/test/public/scenario-runner.js +450 -0
- package/src/apps/test/src/index.ts +9 -0
- package/src/apps/test/src/mcp.ts +192 -0
- package/src/apps/test/src/routes/trigger.ts +285 -0
- package/src/apps/test/src/services/scenario-broadcaster.ts +60 -0
- package/src/apps/test/src/services/scenario-manager.ts +361 -0
- package/src/apps/test/src/services/scenario-store.ts +145 -0
- package/src/apps/test/tsconfig.json +8 -0
- package/src/apps/vocabulary/.turbo/turbo-build.log +5 -0
- package/src/apps/vocabulary/.turbo/turbo-lint.log +25 -0
- package/src/apps/vocabulary/.turbo/turbo-typecheck.log +5 -0
- package/src/apps/vocabulary/mcp.ts +173 -0
- package/src/apps/vocabulary/node_modules/.bin/tsc +21 -0
- package/src/apps/vocabulary/node_modules/.bin/tsserver +21 -0
- package/src/apps/vocabulary/node_modules/.bin/tsx +21 -0
- package/src/apps/vocabulary/package.json +25 -0
- package/src/apps/vocabulary/public/page.css +247 -0
- package/src/apps/vocabulary/public/page.js +444 -0
- package/src/apps/vocabulary/services/vocabulary-store.ts +179 -0
- package/src/apps/vocabulary/src/index.ts +10 -0
- package/src/apps/vocabulary/tsconfig.json +8 -0
- package/src/apps/vocabulary/types.ts +22 -0
- package/src/apps/voice/.turbo/turbo-build.log +5 -0
- package/src/apps/voice/.turbo/turbo-lint.log +43 -0
- package/src/apps/voice/.turbo/turbo-typecheck.log +5 -0
- package/src/apps/voice/node_modules/.bin/openai +21 -0
- package/src/apps/voice/node_modules/.bin/tsc +21 -0
- package/src/apps/voice/node_modules/.bin/tsserver +21 -0
- package/src/apps/voice/node_modules/.bin/tsx +21 -0
- package/src/apps/voice/package.json +35 -0
- package/src/apps/voice/public/favicon.svg +7 -0
- package/src/apps/voice/public/page.css +388 -0
- package/src/apps/voice/public/page.js +718 -0
- package/src/apps/voice/src/index.ts +10 -0
- package/src/apps/voice/src/mcp.ts +70 -0
- package/src/apps/voice/src/providers/index.ts +85 -0
- package/src/apps/voice/src/providers/openai-compatible.ts +94 -0
- package/src/apps/voice/src/providers/types.ts +27 -0
- package/src/apps/voice/src/routes/config.ts +118 -0
- package/src/apps/voice/src/routes/transcribe.ts +90 -0
- package/src/apps/voice/src/services/config-store.ts +129 -0
- package/src/apps/voice/src/services/stats.ts +108 -0
- package/src/apps/voice/src/transcribe.ts +11 -0
- package/src/apps/voice/src/utils/mime.ts +16 -0
- package/src/apps/voice/tsconfig.json +8 -0
- package/src/apps/workflow/.turbo/turbo-build.log +5 -0
- package/src/apps/workflow/.turbo/turbo-lint.log +96 -0
- package/src/apps/workflow/.turbo/turbo-typecheck.log +5 -0
- package/src/apps/workflow/engine/executors/decision-executor.ts +87 -0
- package/src/apps/workflow/engine/executors/file-executor.ts +90 -0
- package/src/apps/workflow/engine/executors/git-executor.ts +137 -0
- package/src/apps/workflow/engine/executors/http-executor.ts +65 -0
- package/src/apps/workflow/engine/executors/index.ts +28 -0
- package/src/apps/workflow/engine/executors/kanban-executor.ts +154 -0
- package/src/apps/workflow/engine/executors/llm-executor.ts +46 -0
- package/src/apps/workflow/engine/executors/log-executor.ts +62 -0
- package/src/apps/workflow/engine/executors/loop-executor.ts +14 -0
- package/src/apps/workflow/engine/executors/shell-executor.ts +107 -0
- package/src/apps/workflow/engine/executors/sub-workflow-executor.ts +61 -0
- package/src/apps/workflow/engine/executors/test-executor.ts +73 -0
- package/src/apps/workflow/engine/executors/trigger-executor.ts +39 -0
- package/src/apps/workflow/engine/expression-evaluator.ts +117 -0
- package/src/apps/workflow/engine/graph-runner.ts +438 -0
- package/src/apps/workflow/engine/node-executor.ts +104 -0
- package/src/apps/workflow/engine/node-registry.ts +15 -0
- package/src/apps/workflow/engine/variable-resolver.ts +109 -0
- package/src/apps/workflow/mcp.ts +223 -0
- package/src/apps/workflow/node_modules/.bin/tsc +21 -0
- package/src/apps/workflow/node_modules/.bin/tsserver +21 -0
- package/src/apps/workflow/node_modules/.bin/tsx +21 -0
- package/src/apps/workflow/package.json +25 -0
- package/src/apps/workflow/public/editor/canvas.js +366 -0
- package/src/apps/workflow/public/editor/drag-manager.js +326 -0
- package/src/apps/workflow/public/editor/edge-renderer.js +235 -0
- package/src/apps/workflow/public/editor/history-manager.js +147 -0
- package/src/apps/workflow/public/editor/layout-engine.js +159 -0
- package/src/apps/workflow/public/editor/node-renderer.js +199 -0
- package/src/apps/workflow/public/editor/selection-manager.js +193 -0
- package/src/apps/workflow/public/favicon.svg +7 -0
- package/src/apps/workflow/public/models/node-types.js +300 -0
- package/src/apps/workflow/public/models/workflow-model.js +257 -0
- package/src/apps/workflow/public/page.css +406 -0
- package/src/apps/workflow/public/page.js +658 -0
- package/src/apps/workflow/public/panels/inspector.js +360 -0
- package/src/apps/workflow/public/panels/palette.js +106 -0
- package/src/apps/workflow/public/panels/run-view.js +275 -0
- package/src/apps/workflow/public/panels/toolbar.js +232 -0
- package/src/apps/workflow/public/panels/workflow-list.js +237 -0
- package/src/apps/workflow/public/state/store.js +47 -0
- package/src/apps/workflow/services/custom-node-loader.ts +48 -0
- package/src/apps/workflow/services/legacy-converter.ts +72 -0
- package/src/apps/workflow/services/run-manager.ts +190 -0
- package/src/apps/workflow/services/workflow-store.ts +424 -0
- package/src/apps/workflow/services/workflow-validator.test.ts +103 -0
- package/src/apps/workflow/services/workflow-validator.ts +98 -0
- package/src/apps/workflow/src/index.ts +10 -0
- package/src/apps/workflow/templates/ci-pipeline.json +18 -0
- package/src/apps/workflow/templates/code-review.json +22 -0
- package/src/apps/workflow/templates/kanban-testing.json +24 -0
- package/src/apps/workflow/tsconfig.json +8 -0
- package/src/apps/workflow/types.ts +268 -0
- package/src/packages/auth-middleware.ts +14 -0
- package/src/packages/design-tokens/.turbo/turbo-build.log +10 -0
- package/src/packages/design-tokens/STYLEGUIDE.md +414 -0
- package/src/packages/design-tokens/build.js +413 -0
- package/src/packages/design-tokens/demo/index.html +1367 -0
- package/src/packages/design-tokens/demo/proposition-a.html +717 -0
- package/src/packages/design-tokens/demo/proposition-b.html +1239 -0
- package/src/packages/design-tokens/demo/proposition-c.html +1049 -0
- package/src/packages/design-tokens/dist/tailwind-preset.js +115 -0
- package/src/packages/design-tokens/dist/tokens.css +345 -0
- package/src/packages/design-tokens/dist/tokens.d.ts +229 -0
- package/src/packages/design-tokens/dist/tokens.js +386 -0
- package/src/packages/design-tokens/package.json +25 -0
- package/src/packages/design-tokens/tokens.json +228 -0
- package/src/packages/devtools-middleware.ts +22 -0
- package/src/packages/eslint-config/index.js +63 -0
- package/src/packages/eslint-config/node_modules/.bin/eslint +21 -0
- package/src/packages/eslint-config/package.json +18 -0
- package/src/packages/json-file-store.ts +232 -0
- package/src/packages/mcp-utils/.turbo/turbo-build.log +5 -0
- package/src/packages/mcp-utils/dist/index.d.ts +33 -0
- package/src/packages/mcp-utils/dist/index.d.ts.map +1 -0
- package/src/packages/mcp-utils/dist/index.js +126 -0
- package/src/packages/mcp-utils/dist/index.js.map +1 -0
- package/src/packages/mcp-utils/node_modules/.bin/tsc +21 -0
- package/src/packages/mcp-utils/node_modules/.bin/tsserver +21 -0
- package/src/packages/mcp-utils/package.json +32 -0
- package/src/packages/mcp-utils/src/index.ts +171 -0
- package/src/packages/mcp-utils/tsconfig.json +9 -0
- package/src/packages/paths.ts +18 -0
- package/src/packages/project-context/index.js +55 -0
- package/src/packages/project-context/package.json +13 -0
- package/src/packages/project-store.ts +127 -0
- package/src/packages/server-sniffer.ts +132 -0
- package/src/packages/shared-assets/favicon.svg +7 -0
- package/src/packages/shared-assets/keymap-registry.js +512 -0
- package/src/packages/shared-assets/logo.svg +6 -0
- package/src/packages/shared-assets/package.json +11 -0
- package/src/packages/shared-assets/ui-utils.js +48 -0
- package/src/packages/shared-assets/voice-widget.d.ts +37 -0
- package/src/packages/shared-assets/voice-widget.js +695 -0
- package/src/packages/shared-types/.turbo/turbo-build.log +5 -0
- package/src/packages/shared-types/dist/index.d.ts +39 -0
- package/src/packages/shared-types/dist/index.d.ts.map +1 -0
- package/src/packages/shared-types/node_modules/.bin/tsc +21 -0
- package/src/packages/shared-types/node_modules/.bin/tsserver +21 -0
- package/src/packages/shared-types/package.json +25 -0
- package/src/packages/shared-types/src/index.ts +41 -0
- package/src/packages/shared-types/tsconfig.json +11 -0
- package/src/packages/tsconfig/base.json +15 -0
- package/src/packages/tsconfig/next.json +14 -0
- package/src/packages/tsconfig/node.json +11 -0
- package/src/packages/tsconfig/package.json +10 -0
- package/turbo.json +25 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
// ── Workflow Editor — Drag & Drop Manager ──────────────────────────────
|
|
2
|
+
// Handles palette drops, node moves, and port-to-port edge creation
|
|
3
|
+
// using the Pointer Events API with setPointerCapture.
|
|
4
|
+
|
|
5
|
+
import { store } from '../state/store.js';
|
|
6
|
+
import { WorkflowModel } from '../models/workflow-model.js';
|
|
7
|
+
import { NODE_TYPES, resolveOutputPorts } from '../models/node-types.js';
|
|
8
|
+
|
|
9
|
+
const GRID_SIZE = 20;
|
|
10
|
+
|
|
11
|
+
let _canvas = null; // Canvas module reference
|
|
12
|
+
let _nodeContainer = null; // World element (nodes)
|
|
13
|
+
let _svgLayer = null; // SVG world group (edges)
|
|
14
|
+
let _snapEnabled = true;
|
|
15
|
+
|
|
16
|
+
// Active drag state
|
|
17
|
+
let _drag = null; // { type: 'node'|'palette'|'edge', ... }
|
|
18
|
+
|
|
19
|
+
// ── Snap helper ─────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
function snap(x, y) {
|
|
22
|
+
if (!_snapEnabled) return { x, y };
|
|
23
|
+
return {
|
|
24
|
+
x: Math.round(x / GRID_SIZE) * GRID_SIZE,
|
|
25
|
+
y: Math.round(y / GRID_SIZE) * GRID_SIZE,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ── Palette drag ────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
function startPaletteDrag(e, nodeType) {
|
|
32
|
+
const typeDef = NODE_TYPES[nodeType];
|
|
33
|
+
if (!typeDef) return;
|
|
34
|
+
|
|
35
|
+
// Create ghost element
|
|
36
|
+
const ghost = document.createElement('div');
|
|
37
|
+
ghost.className = 'wfb-drag-ghost';
|
|
38
|
+
ghost.textContent = `${typeDef.icon} ${typeDef.label}`;
|
|
39
|
+
ghost.style.cssText = `
|
|
40
|
+
position: fixed;
|
|
41
|
+
left: ${e.clientX - 60}px;
|
|
42
|
+
top: ${e.clientY - 20}px;
|
|
43
|
+
width: 120px;
|
|
44
|
+
padding: var(--df-space-2) var(--df-space-3);
|
|
45
|
+
background: var(--df-color-bg-surface);
|
|
46
|
+
border: 1px solid ${typeDef.color};
|
|
47
|
+
border-left: 4px solid ${typeDef.color};
|
|
48
|
+
font-family: var(--df-font-mono);
|
|
49
|
+
font-size: var(--df-font-size-xs);
|
|
50
|
+
color: var(--df-color-text-primary);
|
|
51
|
+
opacity: 0.85;
|
|
52
|
+
pointer-events: none;
|
|
53
|
+
z-index: 9999;
|
|
54
|
+
white-space: nowrap;
|
|
55
|
+
overflow: hidden;
|
|
56
|
+
text-overflow: ellipsis;
|
|
57
|
+
`;
|
|
58
|
+
document.body.appendChild(ghost);
|
|
59
|
+
|
|
60
|
+
_drag = { type: 'palette', nodeType, ghost, pointerId: e.pointerId };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function movePaletteDrag(e) {
|
|
64
|
+
if (_drag?.ghost) {
|
|
65
|
+
_drag.ghost.style.left = `${e.clientX - 60}px`;
|
|
66
|
+
_drag.ghost.style.top = `${e.clientY - 20}px`;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function endPaletteDrag(e) {
|
|
71
|
+
if (!_drag || _drag.type !== 'palette') return;
|
|
72
|
+
|
|
73
|
+
// Remove ghost
|
|
74
|
+
_drag.ghost.remove();
|
|
75
|
+
|
|
76
|
+
// Check if dropped over canvas
|
|
77
|
+
if (_canvas) {
|
|
78
|
+
const root = _canvas.getRootElement?.();
|
|
79
|
+
if (root) {
|
|
80
|
+
const rect = root.getBoundingClientRect();
|
|
81
|
+
if (e.clientX >= rect.left && e.clientX <= rect.right &&
|
|
82
|
+
e.clientY >= rect.top && e.clientY <= rect.bottom) {
|
|
83
|
+
const worldPos = _canvas.screenToWorld(e.clientX, e.clientY);
|
|
84
|
+
const snapped = snap(worldPos.x, worldPos.y);
|
|
85
|
+
const typeDef = NODE_TYPES[_drag.nodeType];
|
|
86
|
+
WorkflowModel.addNode(_drag.nodeType, typeDef?.label ?? _drag.nodeType, snapped);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
_drag = null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Node move ───────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
function startNodeMove(e, nodeId, nodeEl) {
|
|
97
|
+
const wf = store.get('workflow');
|
|
98
|
+
const node = wf?.nodes.find(n => n.id === nodeId);
|
|
99
|
+
if (!node) return;
|
|
100
|
+
|
|
101
|
+
const zoom = store.get('zoom');
|
|
102
|
+
const selectedIds = store.get('selectedNodeIds');
|
|
103
|
+
|
|
104
|
+
// If this node is not selected, select only it (unless shift is held)
|
|
105
|
+
if (!selectedIds.has(nodeId) && !e.shiftKey) {
|
|
106
|
+
store.set('selectedNodeIds', new Set([nodeId]));
|
|
107
|
+
} else if (!selectedIds.has(nodeId) && e.shiftKey) {
|
|
108
|
+
selectedIds.add(nodeId);
|
|
109
|
+
store.set('selectedNodeIds', new Set(selectedIds));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Capture start positions of all selected nodes
|
|
113
|
+
const movingIds = store.get('selectedNodeIds');
|
|
114
|
+
const startPositions = new Map();
|
|
115
|
+
for (const id of movingIds) {
|
|
116
|
+
const n = wf.nodes.find(nd => nd.id === id);
|
|
117
|
+
if (n) startPositions.set(id, { x: n.position.x, y: n.position.y });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
_drag = {
|
|
121
|
+
type: 'node',
|
|
122
|
+
nodeId,
|
|
123
|
+
startX: e.clientX,
|
|
124
|
+
startY: e.clientY,
|
|
125
|
+
startPositions,
|
|
126
|
+
zoom,
|
|
127
|
+
pointerId: e.pointerId,
|
|
128
|
+
moved: false,
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
nodeEl.style.cursor = 'grabbing';
|
|
132
|
+
nodeEl.setPointerCapture(e.pointerId);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function moveNodeMove(e) {
|
|
136
|
+
if (!_drag || _drag.type !== 'node') return;
|
|
137
|
+
_drag.moved = true;
|
|
138
|
+
|
|
139
|
+
const dx = (e.clientX - _drag.startX) / _drag.zoom;
|
|
140
|
+
const dy = (e.clientY - _drag.startY) / _drag.zoom;
|
|
141
|
+
|
|
142
|
+
for (const [id, startPos] of _drag.startPositions) {
|
|
143
|
+
const snapped = snap(startPos.x + dx, startPos.y + dy);
|
|
144
|
+
WorkflowModel.moveNode(id, snapped.x, snapped.y);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function endNodeMove(e) {
|
|
149
|
+
if (!_drag || _drag.type !== 'node') return;
|
|
150
|
+
const nodeEl = _nodeContainer?.querySelector(`[data-node-id="${_drag.nodeId}"]`);
|
|
151
|
+
if (nodeEl) {
|
|
152
|
+
nodeEl.style.cursor = 'grab';
|
|
153
|
+
nodeEl.releasePointerCapture(_drag.pointerId);
|
|
154
|
+
}
|
|
155
|
+
_drag = null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── Edge creation ───────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
function startEdgeDrag(e, nodeId, portId, portType) {
|
|
161
|
+
if (portType !== 'out') return; // Only drag from output ports
|
|
162
|
+
|
|
163
|
+
const wf = store.get('workflow');
|
|
164
|
+
const node = wf?.nodes.find(n => n.id === nodeId);
|
|
165
|
+
if (!node) return;
|
|
166
|
+
|
|
167
|
+
// Import EdgeRenderer dynamically to avoid circular deps
|
|
168
|
+
import('./edge-renderer.js').then(({ EdgeRenderer }) => {
|
|
169
|
+
const tempPath = EdgeRenderer.createTempEdge();
|
|
170
|
+
_svgLayer?.appendChild(tempPath);
|
|
171
|
+
|
|
172
|
+
const outPorts = resolveOutputPorts(node);
|
|
173
|
+
let portIdx = outPorts.findIndex(p => p.id === portId);
|
|
174
|
+
if (portIdx === -1) portIdx = 0;
|
|
175
|
+
|
|
176
|
+
const NODE_HEIGHT = 80;
|
|
177
|
+
const spacing = outPorts.length <= 1
|
|
178
|
+
? NODE_HEIGHT / 2
|
|
179
|
+
: NODE_HEIGHT / (outPorts.length + 1);
|
|
180
|
+
const startX = node.position.x + 220; // NODE_WIDTH
|
|
181
|
+
const startY = outPorts.length <= 1
|
|
182
|
+
? node.position.y + NODE_HEIGHT / 2
|
|
183
|
+
: node.position.y + spacing * (portIdx + 1);
|
|
184
|
+
|
|
185
|
+
_drag = {
|
|
186
|
+
type: 'edge',
|
|
187
|
+
sourceNodeId: nodeId,
|
|
188
|
+
sourcePort: portId,
|
|
189
|
+
tempPath,
|
|
190
|
+
startX,
|
|
191
|
+
startY,
|
|
192
|
+
pointerId: e.pointerId,
|
|
193
|
+
EdgeRenderer,
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
EdgeRenderer.updateTempEdge(tempPath, startX, startY, startX, startY);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function moveEdgeDrag(e) {
|
|
201
|
+
if (!_drag || _drag.type !== 'edge') return;
|
|
202
|
+
const worldPos = _canvas?.screenToWorld(e.clientX, e.clientY);
|
|
203
|
+
if (worldPos && _drag.EdgeRenderer) {
|
|
204
|
+
_drag.EdgeRenderer.updateTempEdge(_drag.tempPath, _drag.startX, _drag.startY, worldPos.x, worldPos.y);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function endEdgeDrag(e) {
|
|
209
|
+
if (!_drag || _drag.type !== 'edge') return;
|
|
210
|
+
|
|
211
|
+
// Remove temp edge
|
|
212
|
+
if (_drag.EdgeRenderer) {
|
|
213
|
+
_drag.EdgeRenderer.removeTempEdge(_drag.tempPath);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Check if we released over an input port
|
|
217
|
+
const targetEl = document.elementFromPoint(e.clientX, e.clientY);
|
|
218
|
+
if (targetEl?.classList?.contains('wfb-port-in') ||
|
|
219
|
+
(targetEl?.dataset?.portType === 'in')) {
|
|
220
|
+
const targetNodeId = targetEl.dataset.nodeId;
|
|
221
|
+
if (targetNodeId && targetNodeId !== _drag.sourceNodeId) {
|
|
222
|
+
WorkflowModel.addEdge(_drag.sourceNodeId, targetNodeId, _drag.sourcePort);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
_drag = null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ── Unified event handlers ──────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
function onPointerDown(e) {
|
|
232
|
+
// Check if clicking a port
|
|
233
|
+
const portEl = e.target.closest?.('.wfb-port');
|
|
234
|
+
if (portEl) {
|
|
235
|
+
e.preventDefault();
|
|
236
|
+
e.stopPropagation();
|
|
237
|
+
startEdgeDrag(e, portEl.dataset.nodeId, portEl.dataset.portId, portEl.dataset.portType);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Check if clicking a node body (not port)
|
|
242
|
+
const nodeEl = e.target.closest?.('.wfb-node');
|
|
243
|
+
if (nodeEl && e.button === 0) {
|
|
244
|
+
e.preventDefault();
|
|
245
|
+
startNodeMove(e, nodeEl.dataset.nodeId, nodeEl);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function onPointerMove(e) {
|
|
251
|
+
if (!_drag) return;
|
|
252
|
+
switch (_drag.type) {
|
|
253
|
+
case 'palette': movePaletteDrag(e); break;
|
|
254
|
+
case 'node': moveNodeMove(e); break;
|
|
255
|
+
case 'edge': moveEdgeDrag(e); break;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function onPointerUp(e) {
|
|
260
|
+
if (!_drag) return;
|
|
261
|
+
switch (_drag.type) {
|
|
262
|
+
case 'palette': endPaletteDrag(e); break;
|
|
263
|
+
case 'node': endNodeMove(e); break;
|
|
264
|
+
case 'edge': endEdgeDrag(e); break;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ── Public API ──────────────────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
export const DragManager = {
|
|
271
|
+
/**
|
|
272
|
+
* Initialize drag handling.
|
|
273
|
+
* @param {object} canvas - Canvas module
|
|
274
|
+
* @param {HTMLElement} nodeContainer - World element
|
|
275
|
+
* @param {SVGGElement} svgLayer - SVG world group
|
|
276
|
+
*/
|
|
277
|
+
init(canvas, nodeContainer, svgLayer) {
|
|
278
|
+
_canvas = canvas;
|
|
279
|
+
_nodeContainer = nodeContainer;
|
|
280
|
+
_svgLayer = svgLayer;
|
|
281
|
+
|
|
282
|
+
// Node/port interactions on the world element
|
|
283
|
+
_nodeContainer.addEventListener('pointerdown', onPointerDown);
|
|
284
|
+
document.addEventListener('pointermove', onPointerMove);
|
|
285
|
+
document.addEventListener('pointerup', onPointerUp);
|
|
286
|
+
document.addEventListener('pointercancel', onPointerUp);
|
|
287
|
+
},
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Remove all event listeners.
|
|
291
|
+
*/
|
|
292
|
+
destroy() {
|
|
293
|
+
if (_nodeContainer) {
|
|
294
|
+
_nodeContainer.removeEventListener('pointerdown', onPointerDown);
|
|
295
|
+
}
|
|
296
|
+
document.removeEventListener('pointermove', onPointerMove);
|
|
297
|
+
document.removeEventListener('pointerup', onPointerUp);
|
|
298
|
+
document.removeEventListener('pointercancel', onPointerUp);
|
|
299
|
+
|
|
300
|
+
if (_drag?.type === 'palette' && _drag.ghost) {
|
|
301
|
+
_drag.ghost.remove();
|
|
302
|
+
}
|
|
303
|
+
_drag = null;
|
|
304
|
+
_canvas = null;
|
|
305
|
+
_nodeContainer = null;
|
|
306
|
+
_svgLayer = null;
|
|
307
|
+
},
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Toggle snap-to-grid.
|
|
311
|
+
* @param {boolean} enabled
|
|
312
|
+
*/
|
|
313
|
+
setSnapToGrid(enabled) {
|
|
314
|
+
_snapEnabled = enabled;
|
|
315
|
+
},
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Begin a palette drag from an external element (e.g., node palette).
|
|
319
|
+
* Call this from a pointerdown handler on a palette item.
|
|
320
|
+
* @param {PointerEvent} e
|
|
321
|
+
* @param {string} nodeType
|
|
322
|
+
*/
|
|
323
|
+
startPaletteDrag(e, nodeType) {
|
|
324
|
+
startPaletteDrag(e, nodeType);
|
|
325
|
+
},
|
|
326
|
+
};
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// ── Workflow Editor — SVG Bezier Edge Rendering ────────────────────────
|
|
2
|
+
// Creates and updates SVG cubic bezier paths between node ports.
|
|
3
|
+
|
|
4
|
+
import { store } from '../state/store.js';
|
|
5
|
+
import { resolveOutputPorts } from '../models/node-types.js';
|
|
6
|
+
|
|
7
|
+
const NODE_WIDTH = 220;
|
|
8
|
+
const NODE_HEIGHT_ESTIMATE = 80;
|
|
9
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Get output port position for a source node.
|
|
13
|
+
* For single-output nodes, port is at the right-center.
|
|
14
|
+
* For multi-output nodes, ports are spaced vertically.
|
|
15
|
+
*/
|
|
16
|
+
function getSourcePoint(node, sourcePort, _typeDef) {
|
|
17
|
+
const x = node.position.x + NODE_WIDTH;
|
|
18
|
+
const outPorts = resolveOutputPorts(node);
|
|
19
|
+
if (outPorts.length <= 1) {
|
|
20
|
+
return { x, y: node.position.y + NODE_HEIGHT_ESTIMATE / 2 };
|
|
21
|
+
}
|
|
22
|
+
// Multi-output: find port index
|
|
23
|
+
let idx = outPorts.findIndex(p => p.id === sourcePort);
|
|
24
|
+
if (idx === -1) idx = 0;
|
|
25
|
+
const spacing = NODE_HEIGHT_ESTIMATE / (outPorts.length + 1);
|
|
26
|
+
return { x, y: node.position.y + spacing * (idx + 1) };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Get input port position for a target node.
|
|
31
|
+
* Input port is at the left-center.
|
|
32
|
+
*/
|
|
33
|
+
function getTargetPoint(node, targetPort, typeDef) {
|
|
34
|
+
const x = node.position.x;
|
|
35
|
+
const inCount = typeDef?.ports?.in ?? 1;
|
|
36
|
+
if (inCount <= 1) {
|
|
37
|
+
return { x, y: node.position.y + NODE_HEIGHT_ESTIMATE / 2 };
|
|
38
|
+
}
|
|
39
|
+
let idx = 0; // Default to first port
|
|
40
|
+
if (targetPort) {
|
|
41
|
+
const match = targetPort.match(/in-(\d+)/);
|
|
42
|
+
if (match) idx = parseInt(match[1], 10);
|
|
43
|
+
}
|
|
44
|
+
const spacing = NODE_HEIGHT_ESTIMATE / (inCount + 1);
|
|
45
|
+
return { x, y: node.position.y + spacing * (idx + 1) };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Compute the cubic bezier 'd' attribute for a path.
|
|
50
|
+
*/
|
|
51
|
+
function computePathD(x1, y1, x2, y2) {
|
|
52
|
+
const dx = Math.max(50, Math.abs(x2 - x1) * 0.4);
|
|
53
|
+
return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Public API ──────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
export const EdgeRenderer = {
|
|
59
|
+
/**
|
|
60
|
+
* Create an SVG path element for a workflow edge.
|
|
61
|
+
* @param {object} edge - { id, source, target, sourcePort, condition }
|
|
62
|
+
* @param {object} sourceNode
|
|
63
|
+
* @param {object} targetNode
|
|
64
|
+
* @param {object} [sourceTypeDef] - NODE_TYPES entry for source
|
|
65
|
+
* @param {object} [targetTypeDef] - NODE_TYPES entry for target
|
|
66
|
+
* @returns {SVGGElement} An SVG group containing the path and optional label
|
|
67
|
+
*/
|
|
68
|
+
createEdgePath(edge, sourceNode, targetNode, sourceTypeDef, targetTypeDef) {
|
|
69
|
+
const group = document.createElementNS(SVG_NS, 'g');
|
|
70
|
+
group.setAttribute('class', 'wfb-edge');
|
|
71
|
+
group.dataset.edgeId = edge.id;
|
|
72
|
+
|
|
73
|
+
// Hit area (wider, invisible path for easier selection)
|
|
74
|
+
const hitPath = document.createElementNS(SVG_NS, 'path');
|
|
75
|
+
hitPath.setAttribute('class', 'wfb-edge-hit');
|
|
76
|
+
hitPath.setAttribute('fill', 'none');
|
|
77
|
+
hitPath.setAttribute('stroke', 'transparent');
|
|
78
|
+
hitPath.setAttribute('stroke-width', '12');
|
|
79
|
+
hitPath.style.cursor = 'pointer';
|
|
80
|
+
hitPath.style.pointerEvents = 'stroke';
|
|
81
|
+
|
|
82
|
+
// Visible path
|
|
83
|
+
const path = document.createElementNS(SVG_NS, 'path');
|
|
84
|
+
path.setAttribute('class', 'wfb-edge-path');
|
|
85
|
+
path.setAttribute('fill', 'none');
|
|
86
|
+
path.setAttribute('stroke', 'var(--df-color-border-default)');
|
|
87
|
+
path.setAttribute('stroke-width', '2');
|
|
88
|
+
path.setAttribute('marker-end', 'url(#wfb-arrowhead)');
|
|
89
|
+
path.style.transition = `stroke var(--df-duration-fast) ease`;
|
|
90
|
+
path.style.pointerEvents = 'none';
|
|
91
|
+
|
|
92
|
+
// Compute positions
|
|
93
|
+
const src = getSourcePoint(sourceNode, edge.sourcePort, sourceTypeDef);
|
|
94
|
+
const tgt = getTargetPoint(targetNode, null, targetTypeDef);
|
|
95
|
+
const d = computePathD(src.x, src.y, tgt.x, tgt.y);
|
|
96
|
+
path.setAttribute('d', d);
|
|
97
|
+
hitPath.setAttribute('d', d);
|
|
98
|
+
|
|
99
|
+
group.appendChild(hitPath);
|
|
100
|
+
group.appendChild(path);
|
|
101
|
+
|
|
102
|
+
// Label
|
|
103
|
+
if (edge.condition) {
|
|
104
|
+
const midX = (src.x + tgt.x) / 2;
|
|
105
|
+
const midY = (src.y + tgt.y) / 2 - 8;
|
|
106
|
+
const text = document.createElementNS(SVG_NS, 'text');
|
|
107
|
+
text.setAttribute('class', 'wfb-edge-label');
|
|
108
|
+
text.setAttribute('x', String(midX));
|
|
109
|
+
text.setAttribute('y', String(midY));
|
|
110
|
+
text.setAttribute('text-anchor', 'middle');
|
|
111
|
+
text.setAttribute('fill', 'var(--df-color-text-muted)');
|
|
112
|
+
text.setAttribute('font-size', '11');
|
|
113
|
+
text.setAttribute('font-family', 'var(--df-font-mono)');
|
|
114
|
+
text.style.pointerEvents = 'none';
|
|
115
|
+
text.textContent = edge.condition;
|
|
116
|
+
group.appendChild(text);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return group;
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Update the path 'd' attribute for an existing edge group.
|
|
124
|
+
* @param {SVGGElement} groupEl
|
|
125
|
+
* @param {object} sourceNode
|
|
126
|
+
* @param {object} targetNode
|
|
127
|
+
* @param {object} [sourceTypeDef]
|
|
128
|
+
* @param {object} [targetTypeDef]
|
|
129
|
+
* @param {string} [sourcePort]
|
|
130
|
+
*/
|
|
131
|
+
updateEdgePath(groupEl, sourceNode, targetNode, sourceTypeDef, targetTypeDef, sourcePort) {
|
|
132
|
+
const src = getSourcePoint(sourceNode, sourcePort, sourceTypeDef);
|
|
133
|
+
const tgt = getTargetPoint(targetNode, null, targetTypeDef);
|
|
134
|
+
const d = computePathD(src.x, src.y, tgt.x, tgt.y);
|
|
135
|
+
|
|
136
|
+
const hitPath = groupEl.querySelector('.wfb-edge-hit');
|
|
137
|
+
const path = groupEl.querySelector('.wfb-edge-path');
|
|
138
|
+
if (hitPath) hitPath.setAttribute('d', d);
|
|
139
|
+
if (path) path.setAttribute('d', d);
|
|
140
|
+
|
|
141
|
+
// Update label position
|
|
142
|
+
const label = groupEl.querySelector('.wfb-edge-label');
|
|
143
|
+
if (label) {
|
|
144
|
+
label.setAttribute('x', String((src.x + tgt.x) / 2));
|
|
145
|
+
label.setAttribute('y', String((src.y + tgt.y) / 2 - 8));
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Create a temporary edge path for edge creation drag.
|
|
151
|
+
* @returns {SVGPathElement}
|
|
152
|
+
*/
|
|
153
|
+
createTempEdge() {
|
|
154
|
+
const path = document.createElementNS(SVG_NS, 'path');
|
|
155
|
+
path.setAttribute('class', 'wfb-temp-edge');
|
|
156
|
+
path.setAttribute('fill', 'none');
|
|
157
|
+
path.setAttribute('stroke', 'var(--df-color-accent-default)');
|
|
158
|
+
path.setAttribute('stroke-width', '2');
|
|
159
|
+
path.setAttribute('stroke-dasharray', '6 3');
|
|
160
|
+
path.setAttribute('marker-end', 'url(#wfb-arrowhead-selected)');
|
|
161
|
+
path.style.pointerEvents = 'none';
|
|
162
|
+
path.style.opacity = '0.7';
|
|
163
|
+
return path;
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Update the temporary edge path during drag.
|
|
168
|
+
* @param {SVGPathElement} pathEl
|
|
169
|
+
* @param {number} x1
|
|
170
|
+
* @param {number} y1
|
|
171
|
+
* @param {number} x2
|
|
172
|
+
* @param {number} y2
|
|
173
|
+
*/
|
|
174
|
+
updateTempEdge(pathEl, x1, y1, x2, y2) {
|
|
175
|
+
pathEl.setAttribute('d', computePathD(x1, y1, x2, y2));
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Remove a temporary edge path from the SVG layer.
|
|
180
|
+
* @param {SVGPathElement} pathEl
|
|
181
|
+
*/
|
|
182
|
+
removeTempEdge(pathEl) {
|
|
183
|
+
pathEl?.remove();
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Set execution status highlight on an edge.
|
|
188
|
+
* @param {SVGGElement} groupEl
|
|
189
|
+
* @param {'running'|'passed'|'failed'|null} status
|
|
190
|
+
*/
|
|
191
|
+
setEdgeStatus(groupEl, status) {
|
|
192
|
+
const path = groupEl?.querySelector('.wfb-edge-path');
|
|
193
|
+
if (!path) return;
|
|
194
|
+
|
|
195
|
+
switch (status) {
|
|
196
|
+
case 'running':
|
|
197
|
+
path.setAttribute('stroke', 'var(--df-color-state-recording)');
|
|
198
|
+
path.setAttribute('stroke-width', '3');
|
|
199
|
+
path.setAttribute('marker-end', 'url(#wfb-arrowhead-selected)');
|
|
200
|
+
break;
|
|
201
|
+
case 'passed':
|
|
202
|
+
path.setAttribute('stroke', 'var(--df-color-state-success)');
|
|
203
|
+
path.setAttribute('stroke-width', '2');
|
|
204
|
+
break;
|
|
205
|
+
case 'failed':
|
|
206
|
+
path.setAttribute('stroke', 'var(--df-color-state-error)');
|
|
207
|
+
path.setAttribute('stroke-width', '2');
|
|
208
|
+
break;
|
|
209
|
+
default:
|
|
210
|
+
path.setAttribute('stroke', 'var(--df-color-border-default)');
|
|
211
|
+
path.setAttribute('stroke-width', '2');
|
|
212
|
+
path.setAttribute('marker-end', 'url(#wfb-arrowhead)');
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Mark an edge as selected or deselected.
|
|
219
|
+
* @param {SVGGElement} groupEl
|
|
220
|
+
* @param {boolean} selected
|
|
221
|
+
*/
|
|
222
|
+
setSelected(groupEl, selected) {
|
|
223
|
+
const path = groupEl?.querySelector('.wfb-edge-path');
|
|
224
|
+
if (!path) return;
|
|
225
|
+
if (selected) {
|
|
226
|
+
path.setAttribute('stroke', 'var(--df-color-accent-default)');
|
|
227
|
+
path.setAttribute('stroke-width', '3');
|
|
228
|
+
path.setAttribute('marker-end', 'url(#wfb-arrowhead-selected)');
|
|
229
|
+
} else {
|
|
230
|
+
path.setAttribute('stroke', 'var(--df-color-border-default)');
|
|
231
|
+
path.setAttribute('stroke-width', '2');
|
|
232
|
+
path.setAttribute('marker-end', 'url(#wfb-arrowhead)');
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// ── Workflow Editor — Undo/Redo History Manager ───────────────────────
|
|
2
|
+
// Command stack storing JSON snapshots, capped at 50 entries.
|
|
3
|
+
|
|
4
|
+
import { store } from '../state/store.js';
|
|
5
|
+
|
|
6
|
+
const MAX_HISTORY = 50;
|
|
7
|
+
|
|
8
|
+
let _stack = []; // Array of JSON strings (workflow snapshots)
|
|
9
|
+
let _index = -1; // Current position in the stack
|
|
10
|
+
let _unsub = null; // Store subscription teardown
|
|
11
|
+
let _skipNext = false; // Prevent re-push during undo/redo restore
|
|
12
|
+
|
|
13
|
+
// ── Public API ──────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export const HistoryManager = {
|
|
16
|
+
/**
|
|
17
|
+
* Initialize history tracking by subscribing to workflow changes.
|
|
18
|
+
*/
|
|
19
|
+
init() {
|
|
20
|
+
_stack = [];
|
|
21
|
+
_index = -1;
|
|
22
|
+
_skipNext = false;
|
|
23
|
+
|
|
24
|
+
_unsub = store.on('workflow', (wf) => {
|
|
25
|
+
if (_skipNext) {
|
|
26
|
+
_skipNext = false;
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (!wf) return;
|
|
30
|
+
this.push(wf);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Keyboard shortcuts
|
|
34
|
+
this._onKeyDown = (e) => {
|
|
35
|
+
// Ctrl/Cmd+Z — undo
|
|
36
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
|
37
|
+
if (isInputFocused()) return;
|
|
38
|
+
e.preventDefault();
|
|
39
|
+
this.undo();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
// Ctrl/Cmd+Shift+Z or Ctrl/Cmd+Y — redo
|
|
43
|
+
if (((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'z') ||
|
|
44
|
+
((e.ctrlKey || e.metaKey) && e.key === 'y')) {
|
|
45
|
+
if (isInputFocused()) return;
|
|
46
|
+
e.preventDefault();
|
|
47
|
+
this.redo();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
document.addEventListener('keydown', this._onKeyDown);
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Unsubscribe from the store.
|
|
56
|
+
*/
|
|
57
|
+
destroy() {
|
|
58
|
+
if (_unsub) {
|
|
59
|
+
_unsub();
|
|
60
|
+
_unsub = null;
|
|
61
|
+
}
|
|
62
|
+
if (this._onKeyDown) {
|
|
63
|
+
document.removeEventListener('keydown', this._onKeyDown);
|
|
64
|
+
this._onKeyDown = null;
|
|
65
|
+
}
|
|
66
|
+
_stack = [];
|
|
67
|
+
_index = -1;
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Push a workflow snapshot onto the history stack.
|
|
72
|
+
* Truncates any redo entries ahead of the current index.
|
|
73
|
+
* @param {object} state - Workflow object to snapshot
|
|
74
|
+
*/
|
|
75
|
+
push(state) {
|
|
76
|
+
const json = JSON.stringify(state);
|
|
77
|
+
|
|
78
|
+
// Avoid duplicates (same as current top)
|
|
79
|
+
if (_index >= 0 && _stack[_index] === json) return;
|
|
80
|
+
|
|
81
|
+
// Truncate redo entries
|
|
82
|
+
_stack = _stack.slice(0, _index + 1);
|
|
83
|
+
_stack.push(json);
|
|
84
|
+
|
|
85
|
+
// Cap at MAX_HISTORY
|
|
86
|
+
if (_stack.length > MAX_HISTORY) {
|
|
87
|
+
_stack.shift();
|
|
88
|
+
}
|
|
89
|
+
_index = _stack.length - 1;
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Restore the previous state.
|
|
94
|
+
*/
|
|
95
|
+
undo() {
|
|
96
|
+
if (!this.canUndo()) return;
|
|
97
|
+
_index--;
|
|
98
|
+
_skipNext = true;
|
|
99
|
+
const snapshot = JSON.parse(_stack[_index]);
|
|
100
|
+
store.set('workflow', snapshot);
|
|
101
|
+
store.set('isDirty', true);
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Restore the next state (after an undo).
|
|
106
|
+
*/
|
|
107
|
+
redo() {
|
|
108
|
+
if (!this.canRedo()) return;
|
|
109
|
+
_index++;
|
|
110
|
+
_skipNext = true;
|
|
111
|
+
const snapshot = JSON.parse(_stack[_index]);
|
|
112
|
+
store.set('workflow', snapshot);
|
|
113
|
+
store.set('isDirty', true);
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @returns {boolean}
|
|
118
|
+
*/
|
|
119
|
+
canUndo() {
|
|
120
|
+
return _index > 0;
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* @returns {boolean}
|
|
125
|
+
*/
|
|
126
|
+
canRedo() {
|
|
127
|
+
return _index < _stack.length - 1;
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Clear all history.
|
|
132
|
+
*/
|
|
133
|
+
clear() {
|
|
134
|
+
_stack = [];
|
|
135
|
+
_index = -1;
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Check if an input/textarea/contenteditable is focused.
|
|
141
|
+
*/
|
|
142
|
+
function isInputFocused() {
|
|
143
|
+
const el = document.activeElement;
|
|
144
|
+
if (!el) return false;
|
|
145
|
+
const tag = el.tagName.toLowerCase();
|
|
146
|
+
return tag === 'input' || tag === 'textarea' || tag === 'select' || el.isContentEditable;
|
|
147
|
+
}
|