From b06852b2108e60e115bcc8eb4bb1718c22eb9d3e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 27 Jun 2026 04:51:21 +0000 Subject: [PATCH 1/4] feat: gorgeous mobile-first visual workflow editor with drag-and-drop and real-time execution - Node-based canvas with pan/zoom and touch support - Drag-and-drop palette to add Math, ConsoleLog, HTTPRequest, Wait blocks - SVG bezier curve connections between nodes with animated execution states - Properties panel (bottom sheet on mobile) for editing node params - Real-time execution visualization with glow effects and pulse animations - YAML import/export with sample workflow loading - Dark glassmorphism theme with responsive mobile-first layout - Integrates with existing WorkflowParser and WorkflowRunner --- main.ts | 144 +-------- styles.css | 726 ++++++++++++++++++++++++++++++++++++++-------- ui/app.ts | 41 +++ ui/canvas.ts | 167 +++++++++++ ui/connections.ts | 89 ++++++ ui/executor.ts | 61 ++++ ui/node.ts | 40 +++ ui/palette.ts | 107 +++++++ ui/panel.ts | 150 ++++++++++ ui/serializer.ts | 96 ++++++ ui/state.ts | 87 ++++++ ui/toolbar.ts | 81 ++++++ ui/types.ts | 43 +++ 13 files changed, 1568 insertions(+), 264 deletions(-) create mode 100644 ui/app.ts create mode 100644 ui/canvas.ts create mode 100644 ui/connections.ts create mode 100644 ui/executor.ts create mode 100644 ui/node.ts create mode 100644 ui/palette.ts create mode 100644 ui/panel.ts create mode 100644 ui/serializer.ts create mode 100644 ui/state.ts create mode 100644 ui/toolbar.ts create mode 100644 ui/types.ts diff --git a/main.ts b/main.ts index f814208..243ffa4 100644 --- a/main.ts +++ b/main.ts @@ -1,15 +1,5 @@ import "./styles.css"; -import YAML from "yaml"; - -import fetchAndPrintSample from "./samples/fetchAndPrint.yml?raw"; -import mathAndForkSample from "./samples/mathAndFork.yml?raw"; -import { WorkflowParser } from "./workflow/parser"; -import { WorkflowRunner } from "./workflow/runner"; - -const samples: Record = { - mathAndFork: mathAndForkSample, - fetchAndPrint: fetchAndPrintSample, -}; +import { mountApp } from "./ui/app"; const app = document.querySelector("#app"); @@ -17,134 +7,4 @@ if (!app) { throw new Error("App container not found."); } -app.innerHTML = ` -
-
-

JFlow in the browser

-

- Load a workflow definition, run it in the browser, and inspect the execution - result without any server process. -

-
-
-
-

Workflow definition

-
- - - Edit the YAML directly before running. -
- -
- - -
-
Ready
-
- -
-
-`; - -const sampleSelect = document.querySelector("#sample-select"); -const sourceField = document.querySelector("#workflow-source"); -const runButton = document.querySelector("#run-workflow"); -const resetButton = document.querySelector("#reset-workflow"); -const statusLabel = document.querySelector("#run-status"); -const metaList = document.querySelector("#workflow-meta"); -const logOutput = document.querySelector("#execution-log"); -const resultOutput = document.querySelector("#execution-result"); - -if (!sampleSelect || !sourceField || !runButton || !resetButton || !statusLabel || !metaList || !logOutput || !resultOutput) { - throw new Error("App controls not found."); -} - -const renderMeta = () => { - try { - const parsed = YAML.parse(sourceField.value); - const blocks = Array.isArray(parsed?.blocks) ? parsed.blocks.length : 0; - metaList.innerHTML = ` -
  • ID: ${parsed?.id ?? "Unknown"}
  • -
  • Name: ${parsed?.name ?? "Unknown"}
  • -
  • Blocks: ${blocks}
  • - `; - } catch { - metaList.innerHTML = ` -
  • Status: Invalid YAML
  • - `; - } -}; - -const setSample = (key: string) => { - sourceField.value = samples[key] ?? samples.mathAndFork; - logOutput.textContent = "No blocks have finished yet."; - resultOutput.textContent = "Run a workflow to see the result."; - setStatus("Ready", "status"); - renderMeta(); -}; - -const setStatus = (message: string, className: string) => { - statusLabel.textContent = message; - statusLabel.className = className; -}; - -sampleSelect.addEventListener("change", () => { - setSample(sampleSelect.value); -}); - -sourceField.addEventListener("input", renderMeta); - -resetButton.addEventListener("click", () => { - setSample(sampleSelect.value); -}); - -runButton.addEventListener("click", async () => { - setStatus("Running workflow…", "status status-running"); - runButton.disabled = true; - logOutput.textContent = "Waiting for completed blocks…"; - resultOutput.textContent = "Running…"; - - try { - const workflowDefinition = YAML.parse(sourceField.value); - const workflow = WorkflowParser.parse(workflowDefinition); - const runner = new WorkflowRunner(workflow); - const blockLogs: string[] = []; - - runner.onBlockFinished = (block, result) => { - blockLogs.push(`${block.id}: ${JSON.stringify(result.value)}`); - logOutput.textContent = blockLogs.join("\n"); - }; - - const result = await runner.run(); - resultOutput.textContent = JSON.stringify(result, null, 2); - if (!blockLogs.length) { - logOutput.textContent = "Workflow completed without block output."; - } - setStatus("Workflow completed.", "status status-success"); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - resultOutput.textContent = message; - setStatus("Workflow failed.", "status status-error"); - } finally { - runButton.disabled = false; - renderMeta(); - } -}); - -setSample("mathAndFork"); +mountApp(app); diff --git a/styles.css b/styles.css index b975418..a27873e 100644 --- a/styles.css +++ b/styles.css @@ -1,206 +1,688 @@ +/* ========================================================= + JFlow Visual Workflow Editor – Mobile-First Styles + ========================================================= */ + :root { color-scheme: dark; - font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - line-height: 1.5; - font-weight: 400; - background: #08111f; - color: #e5eefb; + --font-sans: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-mono: "JetBrains Mono", "Fira Code", ui-monospace, monospace; + + /* Palette */ + --bg-base: #06080f; + --bg-surface: rgba(12, 18, 32, 0.92); + --bg-elevated: rgba(20, 30, 56, 0.88); + --bg-node: rgba(17, 24, 44, 0.95); + --border: rgba(99, 130, 200, 0.18); + --border-focus: rgba(99, 160, 255, 0.55); + + --accent: #3b82f6; + --accent-glow: rgba(59, 130, 246, 0.35); + --success: #10b981; + --success-glow: rgba(16, 185, 129, 0.35); + --warning: #f59e0b; + --warning-glow: rgba(245, 158, 11, 0.3); + --danger: #ef4444; + --danger-glow: rgba(239, 68, 68, 0.3); + + --text-primary: #f1f5f9; + --text-secondary: #94a3b8; + --text-muted: #64748b; + + --radius-sm: 8px; + --radius-md: 14px; + --radius-lg: 22px; + --radius-full: 999px; + + --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3); + --shadow-md: 0 8px 32px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 24px 64px rgba(0, 0, 0, 0.5); + --shadow-glow: 0 0 40px var(--accent-glow); } * { box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, body { + height: 100%; + overflow: hidden; } body { - margin: 0; - min-height: 100vh; - background: - radial-gradient(circle at top, rgba(68, 134, 255, 0.18), transparent 40%), - #08111f; + font-family: var(--font-sans); + font-size: 14px; + line-height: 1.5; + background: var(--bg-base); + color: var(--text-primary); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background-image: + radial-gradient(ellipse at 20% 0%, rgba(59, 130, 246, 0.08) 0%, transparent 50%), + radial-gradient(ellipse at 80% 100%, rgba(16, 185, 129, 0.05) 0%, transparent 50%); } -button, -select, -textarea { +button, select, input { font: inherit; } #app { - width: min(1100px, calc(100% - 2rem)); - margin: 0 auto; - padding: 2rem 0 3rem; + height: 100%; } -.shell { - display: grid; - gap: 1.5rem; +/* ========================================================= + Layout + ========================================================= */ + +.jf-app { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +.jf-layout { + display: flex; + flex: 1; + overflow: hidden; + position: relative; +} + +.jf-layout__canvas { + flex: 1; + position: relative; + overflow: hidden; + background: + radial-gradient(circle at 50% 50%, rgba(30, 40, 70, 0.3) 0%, transparent 70%); + /* Grid pattern */ + background-image: + radial-gradient(circle, rgba(99, 130, 200, 0.08) 1px, transparent 1px); + background-size: 24px 24px; +} + +.jf-layout__sidebar { + width: 280px; + display: flex; + flex-direction: column; + gap: 0; + background: var(--bg-surface); + border-left: 1px solid var(--border); + overflow-y: auto; + overflow-x: hidden; + backdrop-filter: blur(20px); +} + +/* Mobile: sidebar becomes bottom sheet */ +@media (max-width: 768px) { + .jf-layout { + flex-direction: column; + } + + .jf-layout__sidebar { + width: 100%; + max-height: 40vh; + border-left: none; + border-top: 1px solid var(--border); + flex-direction: row; + flex-wrap: wrap; + overflow-y: auto; + } +} + +/* ========================================================= + Toolbar + ========================================================= */ + +.jf-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.65rem 1rem; + background: var(--bg-surface); + border-bottom: 1px solid var(--border); + backdrop-filter: blur(16px); + z-index: 100; + flex-shrink: 0; +} + +.jf-toolbar__brand { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.jf-toolbar__logo { + font-size: 1.4rem; + filter: drop-shadow(0 0 8px var(--accent-glow)); +} + +.jf-toolbar__title { + font-size: 1.1rem; + font-weight: 700; + letter-spacing: -0.02em; + background: linear-gradient(135deg, #fff 30%, var(--accent)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.jf-toolbar__actions { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.jf-toolbar__select { + padding: 0.45rem 0.75rem; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-elevated); + color: var(--text-primary); + font-size: 0.85rem; + cursor: pointer; + transition: border-color 0.2s; +} + +.jf-toolbar__select:hover { + border-color: var(--border-focus); +} + +.jf-toolbar__btn { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.5rem 0.85rem; + border-radius: var(--radius-full); + border: 1px solid var(--border); + background: var(--bg-elevated); + color: var(--text-primary); + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; +} + +.jf-toolbar__btn:hover { + border-color: var(--border-focus); + transform: translateY(-1px); + box-shadow: var(--shadow-sm); +} + +.jf-toolbar__btn--run { + background: linear-gradient(135deg, #1d4ed8, #3b82f6); + border-color: transparent; + color: white; + box-shadow: 0 2px 12px var(--accent-glow); +} + +.jf-toolbar__btn--run:hover { + box-shadow: 0 4px 20px var(--accent-glow); + transform: translateY(-2px); +} + +.jf-toolbar__btn-icon { + font-size: 0.9rem; +} + +@media (max-width: 480px) { + .jf-toolbar__btn-label { + display: none; + } + .jf-toolbar__btn { + padding: 0.5rem 0.65rem; + } } -.hero, -.panel { - border: 1px solid rgba(148, 163, 184, 0.24); - border-radius: 18px; - background: rgba(15, 23, 42, 0.82); - box-shadow: 0 20px 45px rgba(0, 0, 0, 0.25); +/* ========================================================= + Canvas + ========================================================= */ + +.jf-canvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + transform-origin: 0 0; + will-change: transform; +} + +.jf-nodes-layer { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; +} + +.jf-connections { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + overflow: visible; +} + +.jf-conn--animated { + stroke-dasharray: 8 4; + animation: jf-dash 0.6s linear infinite; +} + +@keyframes jf-dash { + to { stroke-dashoffset: -12; } +} + +/* ========================================================= + Nodes + ========================================================= */ + +.jf-node { + position: absolute; + top: 0; + left: 0; + width: 200px; + pointer-events: all; + cursor: grab; + touch-action: none; + user-select: none; + -webkit-user-select: none; + border-radius: var(--radius-md); + background: var(--bg-node); + border: 1.5px solid var(--border); + box-shadow: var(--shadow-md); backdrop-filter: blur(12px); + transition: border-color 0.25s, box-shadow 0.25s, transform 0.1s; + overflow: hidden; } -.hero { - padding: 1.75rem; +.jf-node:active { + cursor: grabbing; } -.hero h1 { - margin: 0 0 0.75rem; - font-size: clamp(2rem, 4vw, 3.2rem); +.jf-node:hover { + border-color: var(--border-focus); + box-shadow: var(--shadow-md), 0 0 20px rgba(59, 130, 246, 0.1); } -.hero p { - margin: 0; - max-width: 58rem; - color: #cbd5e1; +.jf-node--selected { + border-color: var(--accent) !important; + box-shadow: var(--shadow-md), var(--shadow-glow) !important; +} + +/* Execution states */ +.jf-node--running { + border-color: var(--warning) !important; + box-shadow: var(--shadow-md), 0 0 30px var(--warning-glow) !important; } -.workspace { +.jf-node--running .jf-node__pulse { + display: block; +} + +.jf-node--success { + border-color: var(--success) !important; + box-shadow: var(--shadow-md), 0 0 24px var(--success-glow) !important; +} + +.jf-node--failed { + border-color: var(--danger) !important; + box-shadow: var(--shadow-md), 0 0 24px var(--danger-glow) !important; +} + +.jf-node__header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.7rem 0.85rem; + border-bottom: 1px solid var(--border); + background: rgba(30, 40, 70, 0.3); +} + +.jf-node__icon { + font-size: 1rem; + line-height: 1; +} + +.jf-node__name { + font-size: 0.82rem; + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.jf-node__badges { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; + padding: 0.55rem 0.85rem; +} + +.jf-badge { + display: inline-flex; + padding: 0.15rem 0.5rem; + border-radius: var(--radius-full); + font-size: 0.7rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.jf-badge--math { background: rgba(139, 92, 246, 0.2); color: #a78bfa; } +.jf-badge--consolelog { background: rgba(16, 185, 129, 0.15); color: #6ee7b7; } +.jf-badge--httprequest { background: rgba(59, 130, 246, 0.2); color: #93c5fd; } +.jf-badge--wait { background: rgba(245, 158, 11, 0.15); color: #fcd34d; } + +.jf-node__pulse { + display: none; + position: absolute; + inset: -4px; + border-radius: var(--radius-md); + border: 2px solid var(--warning); + animation: jf-pulse 1.2s ease-in-out infinite; + pointer-events: none; +} + +@keyframes jf-pulse { + 0%, 100% { opacity: 0.3; transform: scale(1); } + 50% { opacity: 0.8; transform: scale(1.02); } +} + +/* ========================================================= + Palette + ========================================================= */ + +.jf-palette { + padding: 1rem; + border-bottom: 1px solid var(--border); +} + +.jf-palette__title { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); + margin-bottom: 0.75rem; +} + +.jf-palette__items { display: grid; - gap: 1.5rem; + grid-template-columns: 1fr 1fr; + gap: 0.5rem; } -@media (min-width: 960px) { - .workspace { - grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr); - align-items: start; +@media (max-width: 768px) { + .jf-palette { + border-bottom: none; + border-right: 1px solid var(--border); + min-width: 50%; + } + .jf-palette__items { + grid-template-columns: 1fr 1fr; } } -.panel { - padding: 1.25rem; +.jf-palette__item { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.3rem; + padding: 0.65rem 0.5rem; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-elevated); + color: var(--text-primary); + font-size: 0.78rem; + cursor: grab; + transition: all 0.2s; +} + +.jf-palette__item:hover { + border-color: var(--accent); + background: rgba(59, 130, 246, 0.08); + transform: translateY(-1px); + box-shadow: var(--shadow-sm); +} + +.jf-palette__item:active { + cursor: grabbing; + transform: scale(0.96); +} + +.jf-palette__icon { + font-size: 1.2rem; +} + +.jf-palette__label { + font-weight: 500; + white-space: nowrap; } -.panel h2, -.panel h3 { - margin-top: 0; +/* ========================================================= + Properties Panel + ========================================================= */ + +.jf-panel { + flex: 1; + padding: 1rem; + overflow-y: auto; } -.toolbar { +@media (max-width: 768px) { + .jf-panel { + min-width: 50%; + } +} + +.jf-panel__placeholder { display: flex; - flex-wrap: wrap; - gap: 0.75rem; align-items: center; + justify-content: center; + height: 100%; + min-height: 80px; + color: var(--text-muted); + font-size: 0.85rem; + text-align: center; + padding: 1rem; +} + +.jf-panel__header { + display: flex; + align-items: center; + justify-content: space-between; margin-bottom: 1rem; } -.toolbar label, -.meta { - color: #94a3b8; - font-size: 0.95rem; +.jf-panel__title { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); } -select, -textarea, -.output { - width: 100%; - border-radius: 12px; - border: 1px solid rgba(148, 163, 184, 0.25); - background: rgba(2, 6, 23, 0.9); - color: inherit; +.jf-panel__close { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-full); + border: 1px solid var(--border); + background: transparent; + color: var(--text-secondary); + cursor: pointer; + font-size: 0.8rem; + transition: all 0.2s; } -select { - min-width: 14rem; - padding: 0.75rem 0.9rem; +.jf-panel__close:hover { + background: var(--bg-elevated); + color: var(--text-primary); } -textarea { - min-height: 28rem; - padding: 1rem; - resize: vertical; +.jf-panel__body { + display: flex; + flex-direction: column; + gap: 0.85rem; +} + +.jf-panel__state { + display: inline-flex; + padding: 0.1rem 0.45rem; + border-radius: var(--radius-full); + font-size: 0.65rem; + font-weight: 600; + text-transform: uppercase; + margin-left: 0.5rem; } -.actions { +.jf-panel__state--running { background: var(--warning-glow); color: var(--warning); } +.jf-panel__state--success { background: var(--success-glow); color: var(--success); } +.jf-panel__state--failed { background: var(--danger-glow); color: var(--danger); } + +.jf-field { + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.jf-field--nested { + margin-left: 0.75rem; + margin-top: 0.3rem; +} + +.jf-field__label { + font-size: 0.75rem; + font-weight: 500; + color: var(--text-secondary); +} + +.jf-field__input { + padding: 0.5rem 0.7rem; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: rgba(2, 6, 20, 0.7); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 0.8rem; + transition: border-color 0.2s, box-shadow 0.2s; + width: 100%; +} + +.jf-field__input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-glow); +} + +.jf-chips { display: flex; flex-wrap: wrap; - gap: 0.75rem; - margin-top: 1rem; + gap: 0.35rem; +} + +.jf-chip { + display: inline-flex; + padding: 0.2rem 0.55rem; + border-radius: var(--radius-full); + font-size: 0.72rem; + font-weight: 500; + background: rgba(59, 130, 246, 0.12); + color: var(--accent); + border: 1px solid rgba(59, 130, 246, 0.25); } -.button { - border: none; - border-radius: 999px; - padding: 0.8rem 1.15rem; +.jf-chip--add { cursor: pointer; - transition: transform 0.15s ease, opacity 0.15s ease; + background: transparent; + border-style: dashed; + transition: all 0.2s; } -.button:hover { - transform: translateY(-1px); +.jf-chip--add:hover { + background: rgba(59, 130, 246, 0.08); } -.button:disabled { - cursor: wait; - opacity: 0.6; - transform: none; +.jf-expr { + padding: 0.6rem; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: rgba(2, 6, 20, 0.4); } -.button-primary { - background: linear-gradient(135deg, #2563eb, #3b82f6); - color: white; +.jf-expr__type { + display: inline-flex; + padding: 0.15rem 0.5rem; + border-radius: var(--radius-full); + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + background: rgba(139, 92, 246, 0.15); + color: #a78bfa; + margin-bottom: 0.5rem; } -.button-secondary { - background: rgba(30, 41, 59, 0.95); - color: #e2e8f0; - border: 1px solid rgba(148, 163, 184, 0.22); +.jf-panel__actions { + padding-top: 0.5rem; + border-top: 1px solid var(--border); } -.status { +.jf-btn { display: inline-flex; align-items: center; - gap: 0.5rem; - margin-top: 1rem; - font-size: 0.95rem; - color: #cbd5e1; + padding: 0.5rem 0.85rem; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-elevated); + color: var(--text-primary); + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; } -.status::before { - content: ""; - width: 0.65rem; - height: 0.65rem; - border-radius: 999px; - background: currentColor; +.jf-btn:hover { + transform: translateY(-1px); } -.status-running { - color: #f59e0b; +.jf-btn--danger { + border-color: rgba(239, 68, 68, 0.3); + color: var(--danger); } -.status-success { - color: #10b981; +.jf-btn--danger:hover { + background: rgba(239, 68, 68, 0.1); + box-shadow: 0 2px 12px var(--danger-glow); } -.status-error { - color: #f87171; -} +/* ========================================================= + Scrollbar + ========================================================= */ -.stack { - display: grid; - gap: 1rem; +::-webkit-scrollbar { + width: 6px; + height: 6px; } -.output { - min-height: 10rem; - margin: 0; - padding: 1rem; - overflow: auto; +::-webkit-scrollbar-track { + background: transparent; } -.meta-list { - display: grid; - gap: 0.5rem; - margin: 0; - padding: 0; - list-style: none; +::-webkit-scrollbar-thumb { + background: rgba(148, 163, 184, 0.2); + border-radius: 3px; } -.meta-list strong { - color: #e2e8f0; +::-webkit-scrollbar-thumb:hover { + background: rgba(148, 163, 184, 0.35); } diff --git a/ui/app.ts b/ui/app.ts new file mode 100644 index 0000000..88c0959 --- /dev/null +++ b/ui/app.ts @@ -0,0 +1,41 @@ +/** App entry – assembles the visual editor */ + +import { initCanvas } from "./canvas"; +import { initPalette, handleCanvasDrop } from "./palette"; +import { initPanel } from "./panel"; +import { initToolbar } from "./toolbar"; +import { deserialize } from "./serializer"; +import mathAndForkSample from "../samples/mathAndFork.yml?raw"; + +export function mountApp(root: HTMLElement) { + root.innerHTML = ""; + root.className = "jf-app"; + + // Layout structure + root.innerHTML = ` +
    +
    +
    +
    + `; + + const canvasContainer = root.querySelector("#jf-canvas-container")!; + const sidebar = root.querySelector("#jf-sidebar")!; + + // Init toolbar + initToolbar(root); + + // Init canvas + initCanvas(canvasContainer); + + // Drop zone + canvasContainer.addEventListener("dragover", (e) => e.preventDefault()); + canvasContainer.addEventListener("drop", handleCanvasDrop); + + // Init sidebar components + initPalette(sidebar); + initPanel(sidebar); + + // Load default sample + deserialize(mathAndForkSample); +} diff --git a/ui/canvas.ts b/ui/canvas.ts new file mode 100644 index 0000000..82f5eae --- /dev/null +++ b/ui/canvas.ts @@ -0,0 +1,167 @@ +/** Canvas: pannable/zoomable workspace with nodes and connections */ + +import { getState, setState, updateNode, subscribe } from "./state"; +import { Position, NodeData } from "./types"; +import { renderConnections } from "./connections"; +import { createNodeElement } from "./node"; + +let canvasEl: HTMLDivElement; +let nodesLayer: HTMLDivElement; +let svgLayer: SVGSVGElement; +let containerEl: HTMLDivElement; + +// Pan state +let isPanning = false; +let panStart: Position = { x: 0, y: 0 }; +let panOffsetStart: Position = { x: 0, y: 0 }; + +// Drag node state +let dragNodeId: string | null = null; +let dragStart: Position = { x: 0, y: 0 }; +let dragNodeStart: Position = { x: 0, y: 0 }; + +export function initCanvas(container: HTMLDivElement) { + containerEl = container; + container.innerHTML = ""; + + canvasEl = document.createElement("div"); + canvasEl.className = "jf-canvas"; + + svgLayer = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svgLayer.classList.add("jf-connections"); + svgLayer.setAttribute("width", "100%"); + svgLayer.setAttribute("height", "100%"); + + nodesLayer = document.createElement("div"); + nodesLayer.className = "jf-nodes-layer"; + + canvasEl.appendChild(svgLayer); + canvasEl.appendChild(nodesLayer); + container.appendChild(canvasEl); + + bindPanEvents(container); + subscribe(render); + render(); +} + +function bindPanEvents(container: HTMLDivElement) { + // Mouse pan (middle click or right-click + drag on canvas background) + container.addEventListener("pointerdown", (e) => { + const target = e.target as HTMLElement; + if (target === canvasEl || target === container || target === nodesLayer) { + if (e.button === 0 || e.button === 1) { + isPanning = true; + panStart = { x: e.clientX, y: e.clientY }; + panOffsetStart = { ...getState().panOffset }; + container.setPointerCapture(e.pointerId); + e.preventDefault(); + } + } + }); + + container.addEventListener("pointermove", (e) => { + if (isPanning) { + const dx = e.clientX - panStart.x; + const dy = e.clientY - panStart.y; + setState({ + panOffset: { + x: panOffsetStart.x + dx, + y: panOffsetStart.y + dy, + }, + }); + } + if (dragNodeId) { + const zoom = getState().zoom; + const dx = (e.clientX - dragStart.x) / zoom; + const dy = (e.clientY - dragStart.y) / zoom; + updateNode(dragNodeId, { + position: { + x: dragNodeStart.x + dx, + y: dragNodeStart.y + dy, + }, + }); + } + }); + + container.addEventListener("pointerup", (e) => { + if (isPanning) { + isPanning = false; + container.releasePointerCapture(e.pointerId); + } + if (dragNodeId) { + dragNodeId = null; + } + }); + + // Zoom with wheel + container.addEventListener("wheel", (e) => { + e.preventDefault(); + const delta = e.deltaY > 0 ? 0.9 : 1.1; + const newZoom = Math.min(2, Math.max(0.3, getState().zoom * delta)); + setState({ zoom: newZoom }); + }, { passive: false }); +} + +export function startNodeDrag(nodeId: string, clientX: number, clientY: number) { + const node = getState().nodes.find((n) => n.id === nodeId); + if (!node) return; + dragNodeId = nodeId; + dragStart = { x: clientX, y: clientY }; + dragNodeStart = { ...node.position }; + setState({ selectedNodeId: nodeId }); +} + +function render() { + const { nodes, panOffset, zoom, executionStates, selectedNodeId } = getState(); + + canvasEl.style.transform = `translate(${panOffset.x}px, ${panOffset.y}px) scale(${zoom})`; + + // Render nodes + const existingEls = new Map(); + nodesLayer.querySelectorAll(".jf-node").forEach((el) => { + existingEls.set(el.dataset.nodeId!, el); + }); + + const activeIds = new Set(nodes.map((n) => n.id)); + + // Remove stale + existingEls.forEach((el, id) => { + if (!activeIds.has(id)) el.remove(); + }); + + // Add/update + for (const node of nodes) { + let el = existingEls.get(node.id); + if (!el) { + el = createNodeElement(node); + nodesLayer.appendChild(el); + } + // Position + el.style.transform = `translate(${node.position.x}px, ${node.position.y}px)`; + + // Execution state styling + const exec = executionStates[node.id]; + el.classList.toggle("jf-node--running", exec?.state === "running"); + el.classList.toggle("jf-node--success", exec?.state === "success"); + el.classList.toggle("jf-node--failed", exec?.state === "failed"); + el.classList.toggle("jf-node--selected", node.id === selectedNodeId); + + // Update name + const nameEl = el.querySelector(".jf-node__name"); + if (nameEl && nameEl.textContent !== node.name) { + nameEl.textContent = node.name; + } + + // Update expression badges + const badgesEl = el.querySelector(".jf-node__badges"); + if (badgesEl) { + const badgeHTML = node.expressions + .map((ex) => `${ex.type}`) + .join(""); + if (badgesEl.innerHTML !== badgeHTML) badgesEl.innerHTML = badgeHTML; + } + } + + // Render connections + renderConnections(svgLayer, nodes, executionStates); +} diff --git a/ui/connections.ts b/ui/connections.ts new file mode 100644 index 0000000..4b9b5a2 --- /dev/null +++ b/ui/connections.ts @@ -0,0 +1,89 @@ +/** SVG connection rendering between nodes */ + +import { NodeData, NodeExecutionState } from "./types"; + +const NODE_WIDTH = 200; +const NODE_HEIGHT = 80; + +export function renderConnections( + svg: SVGSVGElement, + nodes: NodeData[], + executionStates: Record +) { + // Clear previous + svg.innerHTML = ""; + + const nodeMap = new Map(nodes.map((n) => [n.id, n])); + + for (const node of nodes) { + // Parent → child connections + for (const parentId of node.parentBlocks) { + if (parentId === "workflow") continue; + const parent = nodeMap.get(parentId); + if (!parent) continue; + drawConnection(svg, parent, node, "parent", executionStates); + } + + // Fork connections + for (const fork of node.forks) { + for (const branch of fork.branches) { + for (const targetId of branch.resultTrueBlocks) { + const target = nodeMap.get(targetId); + if (target) drawConnection(svg, node, target, "true", executionStates); + } + for (const targetId of branch.resultFalseBlocks) { + const target = nodeMap.get(targetId); + if (target) drawConnection(svg, node, target, "false", executionStates); + } + } + } + } +} + +function drawConnection( + svg: SVGSVGElement, + from: NodeData, + to: NodeData, + type: "parent" | "true" | "false", + executionStates: Record +) { + const fromX = from.position.x + NODE_WIDTH / 2; + const fromY = from.position.y + NODE_HEIGHT; + const toX = to.position.x + NODE_WIDTH / 2; + const toY = to.position.y; + + // Bezier curve + const midY = (fromY + toY) / 2; + const d = `M ${fromX} ${fromY} C ${fromX} ${midY}, ${toX} ${midY}, ${toX} ${toY}`; + + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute("d", d); + path.setAttribute("fill", "none"); + path.setAttribute("stroke-width", "2.5"); + path.setAttribute("stroke-linecap", "round"); + + // Color based on type and execution state + let color = "rgba(148,163,184,0.5)"; + if (type === "true") color = "rgba(16,185,129,0.7)"; + if (type === "false") color = "rgba(248,113,113,0.7)"; + + const fromExec = executionStates[from.id]; + const toExec = executionStates[to.id]; + if (fromExec?.state === "success" && toExec?.state !== "idle") { + color = type === "false" ? "rgba(248,113,113,1)" : "rgba(16,185,129,1)"; + } + if (fromExec?.state === "running" || toExec?.state === "running") { + path.classList.add("jf-conn--animated"); + } + + path.setAttribute("stroke", color); + svg.appendChild(path); + + // Arrow indicator + const arrow = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + arrow.setAttribute("cx", String(toX)); + arrow.setAttribute("cy", String(toY)); + arrow.setAttribute("r", "4"); + arrow.setAttribute("fill", color); + svg.appendChild(arrow); +} diff --git a/ui/executor.ts b/ui/executor.ts new file mode 100644 index 0000000..8ad6781 --- /dev/null +++ b/ui/executor.ts @@ -0,0 +1,61 @@ +/** Run the workflow and update execution states in real-time */ + +import YAML from "yaml"; +import { getState, setState, setExecutionState, clearExecutionStates } from "./state"; +import { serialize } from "./serializer"; +import { WorkflowParser } from "../workflow/parser"; +import { WorkflowRunner } from "../workflow/runner"; + +export async function runWorkflow() { + const state = getState(); + if (state.isRunning) return; + + // Reset execution states + clearExecutionStates(); + setState({ isRunning: true }); + + // Set all nodes to idle + for (const node of state.nodes) { + setExecutionState(node.id, { state: "idle" }); + } + + try { + const yamlStr = serialize(); + const def = YAML.parse(yamlStr); + const workflow = WorkflowParser.parse(def); + const runner = new WorkflowRunner(workflow); + + // Hook into the runner to get real-time block state changes + const originalRun = runner.run.bind(runner); + + // Mark blocks as running when they start + runner.onBlockFinished = (block, result) => { + setExecutionState(block.id, { + state: "success", + result: result.value, + finishedAt: Date.now(), + }); + }; + + // Patch: mark blocks running by intercepting the state + const origSetState = (runner as any).setBlockState; + if (origSetState) { + (runner as any).setBlockState = function (block: any, blockState: any, result: any) { + origSetState.call(runner, block, blockState, result); + if (blockState === 1) { + // Running + setExecutionState(block.id, { state: "running", startedAt: Date.now() }); + } else if (blockState === 3) { + // Failed + setExecutionState(block.id, { state: "failed", finishedAt: Date.now() }); + } + }; + } + + await runner.run(); + setState({ isRunning: false }); + } catch (error) { + console.error("Workflow execution failed:", error); + setState({ isRunning: false }); + } +} diff --git a/ui/node.ts b/ui/node.ts new file mode 100644 index 0000000..785de1c --- /dev/null +++ b/ui/node.ts @@ -0,0 +1,40 @@ +/** Node element factory */ + +import { NodeData } from "./types"; +import { startNodeDrag } from "./canvas"; +import { setState, getState } from "./state"; + +export function createNodeElement(node: NodeData): HTMLElement { + const el = document.createElement("div"); + el.className = "jf-node"; + el.dataset.nodeId = node.id; + + el.innerHTML = ` +
    + ${getNodeIcon(node)} + ${node.name} +
    +
    +
    + `; + + // Drag handler + el.addEventListener("pointerdown", (e) => { + e.stopPropagation(); + startNodeDrag(node.id, e.clientX, e.clientY); + }); + + // Tap to select + el.addEventListener("click", (e) => { + e.stopPropagation(); + setState({ selectedNodeId: node.id }); + }); + + return el; +} + +function getNodeIcon(node: NodeData): string { + if (node.parentBlocks.includes("workflow")) return "⚡"; + if (node.forks.length > 0) return "🔀"; + return "◆"; +} diff --git a/ui/palette.ts b/ui/palette.ts new file mode 100644 index 0000000..8221c09 --- /dev/null +++ b/ui/palette.ts @@ -0,0 +1,107 @@ +/** Node palette – drag new blocks onto canvas */ + +import { addNode, getState } from "./state"; +import { NodeData, ExpressionData } from "./types"; + +const BLOCK_TEMPLATES: { label: string; icon: string; expressionType: string }[] = [ + { label: "Math", icon: "🧮", expressionType: "Math" }, + { label: "Console Log", icon: "📝", expressionType: "ConsoleLog" }, + { label: "HTTP Request", icon: "🌐", expressionType: "HTTPRequest" }, + { label: "Wait", icon: "⏱️", expressionType: "Wait" }, +]; + +let paletteEl: HTMLDivElement; + +export function initPalette(container: HTMLElement) { + paletteEl = document.createElement("div"); + paletteEl.className = "jf-palette"; + + paletteEl.innerHTML = ` +

    Add Block

    +
    + ${BLOCK_TEMPLATES.map( + (t, i) => ` + + ` + ).join("")} +
    + `; + + // Click to add + paletteEl.querySelectorAll(".jf-palette__item").forEach((btn) => { + btn.addEventListener("click", () => { + const idx = parseInt(btn.dataset.idx!, 10); + addBlockFromTemplate(idx); + }); + + // Drag support + btn.addEventListener("dragstart", (e) => { + e.dataTransfer?.setData("text/plain", btn.dataset.idx!); + }); + }); + + container.appendChild(paletteEl); +} + +export function handleCanvasDrop(e: DragEvent) { + const idx = parseInt(e.dataTransfer?.getData("text/plain") ?? "", 10); + if (isNaN(idx)) return; + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + const { panOffset, zoom } = getState(); + const x = (e.clientX - rect.left - panOffset.x) / zoom; + const y = (e.clientY - rect.top - panOffset.y) / zoom; + addBlockFromTemplate(idx, { x, y }); +} + +function addBlockFromTemplate(idx: number, position?: { x: number; y: number }) { + const template = BLOCK_TEMPLATES[idx]; + if (!template) return; + + const id = `block_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; + const nodes = getState().nodes; + + // Auto-position if not specified + const pos = position ?? { + x: 100 + (nodes.length % 3) * 240, + y: 100 + Math.floor(nodes.length / 3) * 150, + }; + + const expression: ExpressionData = { + id: `expr_${Date.now()}`, + type: template.expressionType, + parameters: getDefaultParams(template.expressionType), + }; + + const node: NodeData = { + id, + name: template.label + " Block", + type: "block", + position: pos, + expressions: [expression], + forks: [], + parentBlocks: nodes.length === 0 ? ["workflow"] : [], + }; + + addNode(node); +} + +function getDefaultParams(type: string): { id: string; name: string; value: string }[] { + switch (type) { + case "Math": + return [{ id: "p1", name: "expression", value: "2 + 2" }]; + case "ConsoleLog": + return [{ id: "p1", name: "data", value: "Hello from JFlow" }]; + case "HTTPRequest": + return [ + { id: "p1", name: "url", value: "https://httpbin.org/get" }, + { id: "p2", name: "method", value: "GET" }, + ]; + case "Wait": + return [{ id: "p1", name: "seconds", value: "2" }]; + default: + return []; + } +} diff --git a/ui/panel.ts b/ui/panel.ts new file mode 100644 index 0000000..0125070 --- /dev/null +++ b/ui/panel.ts @@ -0,0 +1,150 @@ +/** Properties panel – edit selected node (bottom sheet on mobile) */ + +import { getState, updateNode, removeNode, subscribe, setState } from "./state"; +import { NodeData } from "./types"; + +let panelEl: HTMLDivElement; +let currentNodeId: string | null = null; + +export function initPanel(container: HTMLElement) { + panelEl = document.createElement("div"); + panelEl.className = "jf-panel"; + panelEl.innerHTML = `
    Select a node to edit its properties
    `; + container.appendChild(panelEl); + + subscribe(() => { + const { selectedNodeId, nodes } = getState(); + if (selectedNodeId !== currentNodeId) { + currentNodeId = selectedNodeId; + renderPanel(nodes.find((n) => n.id === selectedNodeId) ?? null); + } + }); +} + +function renderPanel(node: NodeData | null) { + if (!node) { + panelEl.innerHTML = `
    Select a node to edit its properties
    `; + panelEl.classList.remove("jf-panel--open"); + return; + } + + panelEl.classList.add("jf-panel--open"); + + const execState = getState().executionStates[node.id]; + const stateLabel = execState ? `${execState.state}` : ""; + + panelEl.innerHTML = ` +
    +

    Node Properties

    + +
    +
    + + +
    + Parent Blocks +
    + ${node.parentBlocks.map((p) => `${p}`).join("")} + +
    +
    +
    + Expressions ${stateLabel} + ${node.expressions + .map( + (expr, i) => ` +
    + ${expr.type} + ${expr.parameters + .map( + (p) => ` + + ` + ) + .join("")} +
    + ` + ) + .join("")} +
    +
    + +
    +
    + `; + + // Event bindings + panelEl.querySelector(".jf-panel__close")?.addEventListener("click", () => { + setState({ selectedNodeId: null }); + }); + + panelEl.querySelector('[data-field="name"]')?.addEventListener("input", (e) => { + updateNode(node.id, { name: (e.target as HTMLInputElement).value }); + }); + + panelEl.querySelector('[data-field="id"]')?.addEventListener("change", (e) => { + const newId = (e.target as HTMLInputElement).value.trim(); + if (newId && newId !== node.id) { + // Update references in other nodes + const { nodes } = getState(); + nodes.forEach((n) => { + if (n.parentBlocks.includes(node.id)) { + updateNode(n.id, { + parentBlocks: n.parentBlocks.map((p) => (p === node.id ? newId : p)), + }); + } + }); + updateNode(node.id, { id: newId }); + } + }); + + // Expression parameter editing + panelEl.querySelectorAll("[data-expr]").forEach((input) => { + input.addEventListener("input", (e) => { + const exprIdx = parseInt(input.dataset.expr!, 10); + const paramName = input.dataset.param!; + const newExprs = [...node.expressions]; + newExprs[exprIdx] = { + ...newExprs[exprIdx], + parameters: newExprs[exprIdx].parameters.map((p) => + p.name === paramName ? { ...p, value: (e.target as HTMLInputElement).value } : p + ), + }; + updateNode(node.id, { expressions: newExprs }); + }); + }); + + // Add parent + panelEl.querySelector('[data-action="add-parent"]')?.addEventListener("click", () => { + const other = getState().nodes.filter((n) => n.id !== node.id && !node.parentBlocks.includes(n.id)); + if (other.length === 0) { + // Set as root + updateNode(node.id, { parentBlocks: [...node.parentBlocks, "workflow"] }); + } else { + // Pick first available (in a real app we'd show a picker) + const picked = prompt("Enter parent block ID:", other[0]?.id ?? "workflow"); + if (picked) { + updateNode(node.id, { parentBlocks: [...node.parentBlocks, picked] }); + } + } + currentNodeId = null; // force re-render + }); + + // Delete + panelEl.querySelector('[data-action="delete"]')?.addEventListener("click", () => { + removeNode(node.id); + }); +} + +function escapeAttr(s: string): string { + return s.replace(/"/g, """).replace(/ ({ + id: node.id, + name: node.name, + expressions: node.expressions.map((expr) => ({ + id: expr.id, + type: expr.type, + parameters: expr.parameters.map((p) => ({ + id: p.id, + name: p.name, + value: p.value, + })), + })), + forks: node.forks.map((fork) => ({ + id: fork.id, + name: fork.name, + branches: fork.branches.map((b) => ({ + statement: b.statement, + resultTrueBlocks: b.resultTrueBlocks, + resultFalseBlocks: b.resultFalseBlocks, + })), + })), + parentBlocks: node.parentBlocks, + parameters: [], + })); + + return YAML.stringify({ + id: workflowId, + name: workflowName, + environment: {}, + blocks, + }); +} + +export function deserialize(yamlStr: string) { + const def = YAML.parse(yamlStr); + if (!def) return; + + const nodes: NodeData[] = (def.blocks ?? []).map((block: any, index: number) => { + const expressions: ExpressionData[] = (block.expressions ?? []).map((expr: any) => ({ + id: expr.id || `expr_${Math.random().toString(36).slice(2, 6)}`, + type: expr.type, + parameters: (expr.parameters ?? []).map((p: any) => ({ + id: p.id || "", + name: p.name, + value: p.value ?? p.defaultValue ?? "", + })), + })); + + const forks: ForkData[] = (block.forks ?? []).map((fork: any) => ({ + id: fork.id || `fork_${Math.random().toString(36).slice(2, 6)}`, + name: fork.name || "Fork", + branches: (fork.branches ?? []).map((b: any) => ({ + statement: b.statement, + resultTrueBlocks: b.resultTrueBlocks ?? [], + resultFalseBlocks: b.resultFalseBlocks ?? [], + })), + })); + + return { + id: block.id, + name: block.name || block.id, + type: "block" as const, + position: autoLayout(index, def.blocks.length), + expressions, + forks, + parentBlocks: block.parentBlocks ?? [], + }; + }); + + setState({ + nodes, + workflowId: def.id || "workflow", + workflowName: def.name || "Workflow", + selectedNodeId: null, + }); +} + +function autoLayout(index: number, total: number): { x: number; y: number } { + // Simple tree-like layout + const cols = Math.max(2, Math.ceil(Math.sqrt(total))); + const col = index % cols; + const row = Math.floor(index / cols); + return { + x: 80 + col * 280, + y: 80 + row * 180, + }; +} diff --git a/ui/state.ts b/ui/state.ts new file mode 100644 index 0000000..ce344fc --- /dev/null +++ b/ui/state.ts @@ -0,0 +1,87 @@ +/** Reactive state store for the workflow editor */ + +import { NodeData, NodeExecutionState, Position } from "./types"; + +export interface EditorState { + nodes: NodeData[]; + workflowId: string; + workflowName: string; + selectedNodeId: string | null; + panOffset: Position; + zoom: number; + executionStates: Record; + isRunning: boolean; +} + +type Listener = () => void; + +let state: EditorState = { + nodes: [], + workflowId: "new-workflow", + workflowName: "New Workflow", + selectedNodeId: null, + panOffset: { x: 0, y: 0 }, + zoom: 1, + executionStates: {}, + isRunning: false, +}; + +const listeners: Set = new Set(); + +export function getState(): EditorState { + return state; +} + +export function setState(partial: Partial) { + state = { ...state, ...partial }; + notify(); +} + +export function updateNode(id: string, updates: Partial) { + state = { + ...state, + nodes: state.nodes.map((n) => (n.id === id ? { ...n, ...updates } : n)), + }; + notify(); +} + +export function addNode(node: NodeData) { + state = { ...state, nodes: [...state.nodes, node] }; + notify(); +} + +export function removeNode(id: string) { + state = { + ...state, + nodes: state.nodes + .filter((n) => n.id !== id) + .map((n) => ({ + ...n, + parentBlocks: n.parentBlocks.filter((p) => p !== id), + })), + selectedNodeId: state.selectedNodeId === id ? null : state.selectedNodeId, + }; + notify(); +} + +export function setExecutionState(nodeId: string, execState: NodeExecutionState) { + state = { + ...state, + executionStates: { ...state.executionStates, [nodeId]: execState }, + }; + notify(); +} + +export function clearExecutionStates() { + state = { ...state, executionStates: {}, isRunning: false }; + notify(); +} + +export function subscribe(listener: Listener): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +function notify() { + listeners.forEach((l) => l()); +} diff --git a/ui/toolbar.ts b/ui/toolbar.ts new file mode 100644 index 0000000..c1f1f1a --- /dev/null +++ b/ui/toolbar.ts @@ -0,0 +1,81 @@ +/** Top toolbar with workflow actions */ + +import { getState, setState, clearExecutionStates } from "./state"; +import { serialize, deserialize } from "./serializer"; +import { runWorkflow } from "./executor"; + +import fetchAndPrintSample from "../samples/fetchAndPrint.yml?raw"; +import mathAndForkSample from "../samples/mathAndFork.yml?raw"; + +const samples: Record = { + mathAndFork: mathAndForkSample, + fetchAndPrint: fetchAndPrintSample, +}; + +let toolbarEl: HTMLElement; + +export function initToolbar(container: HTMLElement) { + toolbarEl = document.createElement("header"); + toolbarEl.className = "jf-toolbar"; + toolbarEl.innerHTML = ` +
    + + JFlow +
    +
    + + + + +
    + `; + + container.prepend(toolbarEl); + + // Sample select + const sampleSelect = toolbarEl.querySelector("#jf-sample-select")!; + sampleSelect.addEventListener("change", () => { + const key = sampleSelect.value; + if (key && samples[key]) { + deserialize(samples[key]); + clearExecutionStates(); + sampleSelect.value = ""; + } + }); + + // Run + toolbarEl.querySelector("#jf-run-btn")!.addEventListener("click", () => { + if (getState().isRunning) return; + runWorkflow(); + }); + + // Reset + toolbarEl.querySelector("#jf-reset-btn")!.addEventListener("click", () => { + clearExecutionStates(); + }); + + // Export YAML + toolbarEl.querySelector("#jf-export-btn")!.addEventListener("click", () => { + const yaml = serialize(); + const blob = new Blob([yaml], { type: "text/yaml" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${getState().workflowId}.yml`; + a.click(); + URL.revokeObjectURL(url); + }); +} diff --git a/ui/types.ts b/ui/types.ts new file mode 100644 index 0000000..47ba332 --- /dev/null +++ b/ui/types.ts @@ -0,0 +1,43 @@ +/** Shared types for the visual workflow editor */ + +export interface Position { + x: number; + y: number; +} + +export interface NodeData { + id: string; + name: string; + type: "block"; + position: Position; + expressions: ExpressionData[]; + forks: ForkData[]; + parentBlocks: string[]; +} + +export interface ExpressionData { + id: string; + type: string; + parameters: { id: string; name: string; value: string }[]; +} + +export interface ForkBranchData { + statement: any; + resultTrueBlocks: string[]; + resultFalseBlocks: string[]; +} + +export interface ForkData { + id: string; + name: string; + branches: ForkBranchData[]; +} + +export type ExecutionState = "idle" | "running" | "success" | "failed"; + +export interface NodeExecutionState { + state: ExecutionState; + result?: any; + startedAt?: number; + finishedAt?: number; +} From 9a7c1159c99d7450b74ef11025dbfd0128e6d57c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 27 Jun 2026 04:52:34 +0000 Subject: [PATCH 2/4] refactor: use BlockState enum instead of magic numbers in executor --- ui/executor.ts | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/ui/executor.ts b/ui/executor.ts index 8ad6781..0b83edd 100644 --- a/ui/executor.ts +++ b/ui/executor.ts @@ -4,7 +4,7 @@ import YAML from "yaml"; import { getState, setState, setExecutionState, clearExecutionStates } from "./state"; import { serialize } from "./serializer"; import { WorkflowParser } from "../workflow/parser"; -import { WorkflowRunner } from "../workflow/runner"; +import { WorkflowRunner, BlockState } from "../workflow/runner"; export async function runWorkflow() { const state = getState(); @@ -25,9 +25,6 @@ export async function runWorkflow() { const workflow = WorkflowParser.parse(def); const runner = new WorkflowRunner(workflow); - // Hook into the runner to get real-time block state changes - const originalRun = runner.run.bind(runner); - // Mark blocks as running when they start runner.onBlockFinished = (block, result) => { setExecutionState(block.id, { @@ -37,16 +34,14 @@ export async function runWorkflow() { }); }; - // Patch: mark blocks running by intercepting the state + // Patch: mark blocks running by intercepting the internal state setter const origSetState = (runner as any).setBlockState; if (origSetState) { - (runner as any).setBlockState = function (block: any, blockState: any, result: any) { + (runner as any).setBlockState = function (block: any, blockState: BlockState, result: any) { origSetState.call(runner, block, blockState, result); - if (blockState === 1) { - // Running + if (blockState === BlockState.Running) { setExecutionState(block.id, { state: "running", startedAt: Date.now() }); - } else if (blockState === 3) { - // Failed + } else if (blockState === BlockState.Failed) { setExecutionState(block.id, { state: "failed", finishedAt: Date.now() }); } }; From 343783cfb4f78e60248a92daae1129c27d1f33e6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 27 Jun 2026 05:21:29 +0000 Subject: [PATCH 3/4] feat: add results panel and make sidebar panels collapsible --- styles.css | 181 ++++++++++++++++++++++++++++++++++++++++++-------- ui/app.ts | 2 + ui/palette.ts | 34 +++++++--- ui/panel.ts | 118 ++++++++++++++++++++------------ ui/results.ts | 86 ++++++++++++++++++++++++ 5 files changed, 340 insertions(+), 81 deletions(-) create mode 100644 ui/results.ts diff --git a/styles.css b/styles.css index a27873e..2f48efb 100644 --- a/styles.css +++ b/styles.css @@ -401,23 +401,14 @@ button, select, input { ========================================================= */ .jf-palette { - padding: 1rem; border-bottom: 1px solid var(--border); } -.jf-palette__title { - font-size: 0.75rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--text-muted); - margin-bottom: 0.75rem; -} - .jf-palette__items { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; + padding: 0.75rem 1rem; } @media (max-width: 768px) { @@ -473,8 +464,8 @@ button, select, input { .jf-panel { flex: 1; - padding: 1rem; overflow-y: auto; + border-top: 1px solid var(--border); } @media (max-width: 768px) { @@ -495,21 +486,6 @@ button, select, input { padding: 1rem; } -.jf-panel__header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 1rem; -} - -.jf-panel__title { - font-size: 0.75rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--text-muted); -} - .jf-panel__close { width: 28px; height: 28px; @@ -534,6 +510,7 @@ button, select, input { display: flex; flex-direction: column; gap: 0.85rem; + padding: 0.75rem 1rem; } .jf-panel__state { @@ -665,6 +642,158 @@ button, select, input { box-shadow: 0 2px 12px var(--danger-glow); } +/* ========================================================= + Collapsible Panels + ========================================================= */ + +.jf-collapsible__header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.65rem 1rem; + border-bottom: 1px solid var(--border); + cursor: default; + user-select: none; +} + +.jf-collapsible__toggle { + width: 22px; + height: 22px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: transparent; + color: var(--text-secondary); + cursor: pointer; + font-size: 0.75rem; + transition: all 0.2s; + flex-shrink: 0; +} + +.jf-collapsible__toggle:hover { + background: var(--bg-elevated); + color: var(--text-primary); +} + +.jf-collapsible__title { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); + flex: 1; +} + +.jf-collapsible__body { + overflow: hidden; + transition: max-height 0.25s ease, opacity 0.2s ease; +} + +.jf-collapsible--collapsed .jf-collapsible__body { + max-height: 0 !important; + opacity: 0; + padding: 0; + overflow: hidden; +} + +/* ========================================================= + Results Panel + ========================================================= */ + +.jf-results { + border-top: 1px solid var(--border); +} + +.jf-results__count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 0.4rem; + border-radius: var(--radius-full); + font-size: 0.7rem; + font-weight: 600; + background: rgba(59, 130, 246, 0.15); + color: var(--accent); +} + +.jf-results__empty { + display: flex; + align-items: center; + justify-content: center; + min-height: 60px; + color: var(--text-muted); + font-size: 0.82rem; + text-align: center; + padding: 1rem; +} + +.jf-results__list { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.75rem; +} + +.jf-results__item { + padding: 0.6rem 0.75rem; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: rgba(2, 6, 20, 0.5); +} + +.jf-results__item--success { + border-color: rgba(16, 185, 129, 0.3); +} + +.jf-results__item--failed { + border-color: rgba(239, 68, 68, 0.3); +} + +.jf-results__item-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.3rem; +} + +.jf-results__item-name { + font-size: 0.78rem; + font-weight: 600; + color: var(--text-primary); +} + +.jf-results__item-state { + font-size: 0.65rem; + font-weight: 600; + text-transform: uppercase; + padding: 0.1rem 0.4rem; + border-radius: var(--radius-full); +} + +.jf-results__item--success .jf-results__item-state { + background: var(--success-glow); + color: var(--success); +} + +.jf-results__item--failed .jf-results__item-state { + background: var(--danger-glow); + color: var(--danger); +} + +.jf-results__item-value { + font-family: var(--font-mono); + font-size: 0.75rem; + color: var(--text-secondary); + white-space: pre-wrap; + word-break: break-all; + max-height: 80px; + overflow-y: auto; +} + /* ========================================================= Scrollbar ========================================================= */ diff --git a/ui/app.ts b/ui/app.ts index 88c0959..851bcb8 100644 --- a/ui/app.ts +++ b/ui/app.ts @@ -3,6 +3,7 @@ import { initCanvas } from "./canvas"; import { initPalette, handleCanvasDrop } from "./palette"; import { initPanel } from "./panel"; +import { initResults } from "./results"; import { initToolbar } from "./toolbar"; import { deserialize } from "./serializer"; import mathAndForkSample from "../samples/mathAndFork.yml?raw"; @@ -35,6 +36,7 @@ export function mountApp(root: HTMLElement) { // Init sidebar components initPalette(sidebar); initPanel(sidebar); + initResults(sidebar); // Load default sample deserialize(mathAndForkSample); diff --git a/ui/palette.ts b/ui/palette.ts index 8221c09..199cf8e 100644 --- a/ui/palette.ts +++ b/ui/palette.ts @@ -14,22 +14,34 @@ let paletteEl: HTMLDivElement; export function initPalette(container: HTMLElement) { paletteEl = document.createElement("div"); - paletteEl.className = "jf-palette"; + paletteEl.className = "jf-palette jf-collapsible"; paletteEl.innerHTML = ` -

    Add Block

    -
    - ${BLOCK_TEMPLATES.map( - (t, i) => ` - - ` - ).join("")} +
    + + Add Block +
    +
    +
    + ${BLOCK_TEMPLATES.map( + (t, i) => ` + + ` + ).join("")} +
    `; + // Toggle collapse + paletteEl.querySelector(".jf-collapsible__toggle")?.addEventListener("click", () => { + paletteEl.classList.toggle("jf-collapsible--collapsed"); + const btn = paletteEl.querySelector(".jf-collapsible__toggle")!; + btn.textContent = paletteEl.classList.contains("jf-collapsible--collapsed") ? "▸" : "▾"; + }); + // Click to add paletteEl.querySelectorAll(".jf-palette__item").forEach((btn) => { btn.addEventListener("click", () => { diff --git a/ui/panel.ts b/ui/panel.ts index 0125070..8325d8a 100644 --- a/ui/panel.ts +++ b/ui/panel.ts @@ -8,8 +8,8 @@ let currentNodeId: string | null = null; export function initPanel(container: HTMLElement) { panelEl = document.createElement("div"); - panelEl.className = "jf-panel"; - panelEl.innerHTML = `
    Select a node to edit its properties
    `; + panelEl.className = "jf-panel jf-collapsible"; + panelEl.innerHTML = renderPlaceholder(); container.appendChild(panelEl); subscribe(() => { @@ -23,8 +23,9 @@ export function initPanel(container: HTMLElement) { function renderPanel(node: NodeData | null) { if (!node) { - panelEl.innerHTML = `
    Select a node to edit its properties
    `; + panelEl.innerHTML = renderPlaceholder(); panelEl.classList.remove("jf-panel--open"); + bindPanelToggle(); return; } @@ -32,57 +33,64 @@ function renderPanel(node: NodeData | null) { const execState = getState().executionStates[node.id]; const stateLabel = execState ? `${execState.state}` : ""; + const isCollapsed = panelEl.classList.contains("jf-collapsible--collapsed"); + const headerIcon = isCollapsed ? "▸" : "▾"; panelEl.innerHTML = ` -
    -

    Node Properties

    +
    + + Node Properties
    -
    - - -
    - Parent Blocks -
    - ${node.parentBlocks.map((p) => `${p}`).join("")} - -
    -
    -
    - Expressions ${stateLabel} - ${node.expressions - .map( - (expr, i) => ` -
    - ${expr.type} - ${expr.parameters - .map( - (p) => ` - - ` - ) - .join("")} +
    +
    + + +
    + Parent Blocks +
    + ${node.parentBlocks.map((p) => `${p}`).join("")} +
    - ` - ) - .join("")} -
    -
    - +
    +
    + Expressions ${stateLabel} + ${node.expressions + .map( + (expr, i) => ` +
    + ${expr.type} + ${expr.parameters + .map( + (p) => ` + + ` + ) + .join("")} +
    + ` + ) + .join("")} +
    +
    + +
    `; // Event bindings + bindPanelToggle(); + panelEl.querySelector(".jf-panel__close")?.addEventListener("click", () => { setState({ selectedNodeId: null }); }); @@ -145,6 +153,28 @@ function renderPanel(node: NodeData | null) { }); } +function renderPlaceholder(): string { + const isCollapsed = panelEl?.classList.contains("jf-collapsible--collapsed"); + const headerIcon = isCollapsed ? "▸" : "▾"; + return ` +
    + + Properties +
    +
    +
    Select a node to edit its properties
    +
    + `; +} + +function bindPanelToggle() { + panelEl.querySelector(".jf-collapsible__toggle")?.addEventListener("click", () => { + panelEl.classList.toggle("jf-collapsible--collapsed"); + const btn = panelEl.querySelector(".jf-collapsible__toggle")!; + btn.textContent = panelEl.classList.contains("jf-collapsible--collapsed") ? "▸" : "▾"; + }); +} + function escapeAttr(s: string): string { return s.replace(/"/g, """).replace(/ = {}; + +export function initResults(container: HTMLElement) { + resultsEl = document.createElement("div"); + resultsEl.className = "jf-results jf-collapsible"; + resultsEl.innerHTML = renderResults({}); + container.appendChild(resultsEl); + + subscribe(() => { + const { executionStates } = getState(); + if (executionStates !== lastExecutionStates) { + lastExecutionStates = executionStates; + resultsEl.innerHTML = renderResults(executionStates); + bindToggle(); + } + }); + + bindToggle(); +} + +function bindToggle() { + resultsEl.querySelector(".jf-collapsible__toggle")?.addEventListener("click", () => { + resultsEl.classList.toggle("jf-collapsible--collapsed"); + }); +} + +function renderResults(executionStates: Record): string { + const entries = Object.entries(executionStates); + const completedEntries = entries.filter(([, s]) => s.state === "success" || s.state === "failed"); + + const headerIcon = resultsEl?.classList.contains("jf-collapsible--collapsed") ? "▸" : "▾"; + + let body: string; + if (completedEntries.length === 0) { + body = `
    Run workflow to see results
    `; + } else { + body = `
    + ${completedEntries + .map(([nodeId, execState]) => { + const node = getState().nodes.find((n) => n.id === nodeId); + const name = node?.name ?? nodeId; + const stateClass = execState.state === "success" ? "jf-results__item--success" : "jf-results__item--failed"; + const resultStr = execState.result != null ? formatResult(execState.result) : "—"; + return ` +
    +
    + ${escapeHtml(name)} + ${execState.state} +
    +
    ${escapeHtml(resultStr)}
    +
    `; + }) + .join("")} +
    `; + } + + return ` +
    + + Results + ${completedEntries.length > 0 ? `${completedEntries.length}` : ""} +
    +
    ${body}
    + `; +} + +function formatResult(value: any): string { + if (typeof value === "object") { + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } + } + return String(value); +} + +function escapeHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} From 0d5c733dd117af49420d8a4a66c2fee365e4df6b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 27 Jun 2026 05:23:14 +0000 Subject: [PATCH 4/4] fix: use event delegation for collapsible toggle to prevent listener accumulation --- ui/panel.ts | 20 +++++++++----------- ui/results.ts | 21 +++++++++++---------- 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/ui/panel.ts b/ui/panel.ts index 8325d8a..511cbdc 100644 --- a/ui/panel.ts +++ b/ui/panel.ts @@ -12,6 +12,15 @@ export function initPanel(container: HTMLElement) { panelEl.innerHTML = renderPlaceholder(); container.appendChild(panelEl); + // Single delegated click handler for toggle + panelEl.addEventListener("click", (e) => { + if ((e.target as HTMLElement).closest(".jf-collapsible__toggle")) { + panelEl.classList.toggle("jf-collapsible--collapsed"); + const btn = panelEl.querySelector(".jf-collapsible__toggle")!; + btn.textContent = panelEl.classList.contains("jf-collapsible--collapsed") ? "▸" : "▾"; + } + }); + subscribe(() => { const { selectedNodeId, nodes } = getState(); if (selectedNodeId !== currentNodeId) { @@ -25,7 +34,6 @@ function renderPanel(node: NodeData | null) { if (!node) { panelEl.innerHTML = renderPlaceholder(); panelEl.classList.remove("jf-panel--open"); - bindPanelToggle(); return; } @@ -89,8 +97,6 @@ function renderPanel(node: NodeData | null) { `; // Event bindings - bindPanelToggle(); - panelEl.querySelector(".jf-panel__close")?.addEventListener("click", () => { setState({ selectedNodeId: null }); }); @@ -167,14 +173,6 @@ function renderPlaceholder(): string { `; } -function bindPanelToggle() { - panelEl.querySelector(".jf-collapsible__toggle")?.addEventListener("click", () => { - panelEl.classList.toggle("jf-collapsible--collapsed"); - const btn = panelEl.querySelector(".jf-collapsible__toggle")!; - btn.textContent = panelEl.classList.contains("jf-collapsible--collapsed") ? "▸" : "▾"; - }); -} - function escapeAttr(s: string): string { return s.replace(/"/g, """).replace(/ { + if ((e.target as HTMLElement).closest(".jf-collapsible__toggle")) { + resultsEl.classList.toggle("jf-collapsible--collapsed"); + const btn = resultsEl.querySelector(".jf-collapsible__toggle")!; + btn.textContent = resultsEl.classList.contains("jf-collapsible--collapsed") ? "▸" : "▾"; + } + }); + subscribe(() => { const { executionStates } = getState(); if (executionStates !== lastExecutionStates) { lastExecutionStates = executionStates; resultsEl.innerHTML = renderResults(executionStates); - bindToggle(); } }); - - bindToggle(); -} - -function bindToggle() { - resultsEl.querySelector(".jf-collapsible__toggle")?.addEventListener("click", () => { - resultsEl.classList.toggle("jf-collapsible--collapsed"); - }); } function renderResults(executionStates: Record): string { const entries = Object.entries(executionStates); const completedEntries = entries.filter(([, s]) => s.state === "success" || s.state === "failed"); - const headerIcon = resultsEl?.classList.contains("jf-collapsible--collapsed") ? "▸" : "▾"; + const isCollapsed = resultsEl?.classList.contains("jf-collapsible--collapsed"); + const headerIcon = isCollapsed ? "▸" : "▾"; let body: string; if (completedEntries.length === 0) {