# Побудова системи обробки за допомогою HTMX і ядра ASP.NET - Частина 3: побудова візуального редактора

<!--category-- ASP.NET, HTMX, Alpine.js, Workflow, TailwindCSS -->
<datetime class="hidden">2025-01-15T16:00</datetime>

## Вступ

Вхід[Частина 2](/blog/workflowsystem-part2-architecture)Ми збудували потужний робочий двигун.

Але робочі процеси, визначені в JSON, не дуже дружні до користувача.

- У цьому пості ми створимо приголомшливий редактор візуальної обробки - думаймо "Dummy 'host-RED" - використовуючи HTMX, Billian.js, TailwindCSS та DievieUI.
- До кінця повідомлення ви отримаєте:
- Полотно обробки перетягування зі скиданням
- Візуальний вузол з' єднується з SVG
- Налаштування вузла у режимі реального часу

[TOC]

## Прекрасний інтерфейс з темами

Повна інтеграція з HTMX

1. Видіння
2. Ми хочемо, щоб користувачі:
3. Перетягніть вузли з палітри на полотно
4. З' єднати вузли візуально

Налаштувати властивості вузлів на бічній панелі

## Зберегти і виконати потоки робіт

### Все, не пишучи жодного рядка JSON!

**Стек технології**Чому цей стос?

- HTMX 2. 0
- : Контакти на сервері без запису JavaScript
- Чудово для операцій з збереженням/ завантаженням

**Зменшує складність клієнтської сторони**Сервер підтримує джерело правди

- Альпійський.js
- : Невимоглива реагентність полотна
- Виділено лише ~15KB`x-data`Чудово для інтерактивних компонентів інтерфейсу користувача

**Простий**директиви

- TailCSS + DaisUI
- : Прекрасний, темний інтерфейс
- Утиліта- перший CSS

**Режим темних/ light out of the box**Попередньо вбудовані компоненти (карти, кнопки, значки)

- SVG
- : Відображення рідних з' єднань
- Придатні для масштабування, чіткі лінії

## У стилі CSS

Зовнішні бібліотеки не потрібні

```
┌─────────────────────────────────────────────────────┐
│  Top Toolbar (Name, Save, Run, Cancel)             │
├─────────┬───────────────────────────┬───────────────┤
│  Node   │      Canvas               │    Node       │
│ Palette │    (Drag & Drop)          │  Inspector    │
│         │                            │               │
│ [HTTP]  │   ┌─────┐                 │  Name: ___    │
│ [Xform] │   │Node1│─┐               │  Type: ___    │
│ [Delay] │   └─────┘ │               │  Config: ___  │
│ [Cond]  │           ▼               │  Style: ___   │
│         │      ┌─────┐              │               │
│         │      │Node2│              │               │
│         │      └─────┘              │               │
└─────────┴───────────────────────────┴───────────────┘
```

## Огляд архітектури

У нашому редакторі є три основних панелі:

```csharp
[Route("workflow")]
public class WorkflowController : BaseController
{
    private readonly WorkflowService _workflowService;
    private readonly WorkflowExecutionService _executionService;

    [HttpGet]
    public async Task<IActionResult> Index()
    {
        var workflows = await _workflowService.GetAllAsync();
        return View("Index", workflows);
    }

    [HttpGet("create")]
    public IActionResult Create()
    {
        var workflow = new WorkflowDefinition
        {
            Name = "New Workflow",
            Nodes = new List<WorkflowNode>(),
            Connections = new List<NodeConnection>()
        };
        return View("Editor", workflow);
    }

    [HttpGet("edit/{id}")]
    public async Task<IActionResult> Edit(string id)
    {
        var workflow = await _workflowService.GetByIdAsync(id);
        return workflow == null ? NotFound() : View("Editor", workflow);
    }

    [HttpPost("save")]
    public async Task<IActionResult> Save([FromBody] WorkflowDefinition workflow)
    {
        var saved = await _workflowService.CreateOrUpdateAsync(workflow);
        return Json(new { success = true, workflowId = saved.Id });
    }

    [HttpPost("execute/{id}")]
    public async Task<IActionResult> Execute(string id,
        [FromBody] Dictionary<string, object>? inputData = null)
    {
        var execution = await _executionService.ExecuteWorkflowAsync(
            id, inputData, User.Identity?.Name ?? "Anonymous");

        return Json(new
        {
            success = true,
            executionId = execution.Id,
            status = execution.Status.ToString()
        });
    }
}
```

## Контроль

Спочатку, давайте встановимо наші кінцеві точки контролера:

```html
@model Mostlylucid.Workflow.Shared.Models.WorkflowDefinition

<div class="container-fluid"
     x-data="workflowEditor(@Html.Raw(JsonSerializer.Serialize(Model)))"
     x-init="init()">

    <!-- Top Toolbar -->
    <div class="navbar bg-base-200 rounded-box shadow-lg mb-6">
        <div class="flex-1">
            <input type="text"
                   x-model="workflow.name"
                   placeholder="Workflow Name"
                   class="input input-ghost text-2xl font-bold w-96" />
        </div>
        <div class="flex-none gap-2">
            <button @click="saveWorkflow()" class="btn btn-success">
                Save
            </button>
            <button @click="runWorkflow()" class="btn btn-primary">
                Run
            </button>
        </div>
    </div>

    <!-- Three-panel layout -->
    <div class="flex gap-4 h-[calc(100vh-200px)]">
        <!-- Left: Node Palette -->
        <!-- Center: Canvas -->
        <!-- Right: Node Inspector -->
    </div>
</div>
```

## Перегляд редактора

У нашому перегляді редактора для керування штатами використовується Algerian. js:`workflowEditor`Billian.js State Management

```javascript
function workflowEditor(initialWorkflow) {
    return {
        workflow: initialWorkflow || {
            id: crypto.randomUUID(),
            name: 'New Workflow',
            nodes: [],
            connections: [],
            isEnabled: true
        },
        selectedNode: null,
        draggedNodeType: null,
        connecting: false,

        init() {
            // Initialize node inputs as JSON for editing
            this.workflow.nodes.forEach(node => {
                node.inputsJson = JSON.stringify(node.inputs || {}, null, 2);
            });
        },

        // ... methods below
    };
}
```

## The

Альпійський компонент керує всім станом редактора:

```html
<div class="w-64 bg-base-200 rounded-box p-4 overflow-y-auto">
    <h3 class="text-lg font-bold mb-4">📦 Available Nodes</h3>

    <div class="space-y-3">
        <div class="card bg-base-100 shadow cursor-pointer"
             draggable="true"
             @dragstart="dragStart($event, 'HttpRequest', '🌐', '#10B981')">
            <div class="card-body p-4">
                <h4 class="font-semibold">🌐 HTTP Request</h4>
                <p class="text-xs">Make API calls</p>
            </div>
        </div>

        <div class="card bg-base-100 shadow cursor-pointer"
             draggable="true"
             @dragstart="dragStart($event, 'Transform', '🔄', '#3B82F6')">
            <div class="card-body p-4">
                <h4 class="font-semibold">🔄 Transform</h4>
                <p class="text-xs">Process data</p>
            </div>
        </div>

        <!-- More node types... -->
    </div>
</div>
```

### Перетягування і скидання: Палітра вузлів

```javascript
dragStart(event, nodeType, icon, color) {
    this.draggedNodeType = { type: nodeType, icon, color };
}
```

## На лівій бічній панелі містяться типи вузлів, які можна перетягувати:

Перетягування Почати обробник

```html
<div class="flex-1 bg-base-300 rounded-box relative overflow-hidden"
     @drop="dropNode($event)"
     @dragover.prevent
     id="workflow-canvas">

    <div class="absolute inset-0 overflow-auto p-8">
        <!-- Grid background -->
        <div class="absolute inset-0 opacity-20"
             style="background-image: repeating-linear-gradient(...);
                    background-size: 20px 20px;">
        </div>

        <!-- Render nodes -->
        <template x-for="node in workflow.nodes" :key="node.id">
            <div :id="'node-' + node.id"
                 class="absolute card shadow-2xl cursor-move"
                 :style="`left: ${node.position.x}px;
                          top: ${node.position.y}px;
                          width: ${node.style.width}px;
                          background-color: ${node.style.backgroundColor};`"
                 @mousedown="selectNode(node)"
                 draggable="true"
                 @dragstart="dragNode($event, node)">

                <div class="card-body p-4">
                    <div class="flex items-center gap-2">
                        <span x-text="node.style.icon || '📦'"></span>
                        <h3 x-text="node.name"></h3>
                    </div>

                    <!-- Connection points -->
                    <div class="flex justify-between mt-2">
                        <div class="badge badge-primary"
                             @click.stop="startConnection(node, 'input')">
                            ◀
                        </div>
                        <div class="badge badge-success"
                             @click.stop="startConnection(node, 'output')">
                            ▶
                        </div>
                    </div>
                </div>
            </div>
        </template>

        <!-- SVG connections layer -->
        <svg class="absolute inset-0 pointer-events-none"
             style="width: 100%; height: 100%;">
            <template x-for="conn in workflow.connections" :key="conn.id">
                <path :d="getConnectionPath(conn)"
                      stroke="#94A3B8"
                      stroke-width="3"
                      fill="none"
                      marker-end="url(#arrowhead)"/>
            </template>
            <defs>
                <marker id="arrowhead" markerWidth="10" markerHeight="10">
                    <polygon points="0 0, 10 3, 0 6" fill="#94A3B8" />
                </marker>
            </defs>
        </svg>
    </div>
</div>
```

### Полотно

Полотно там, где произойдет магия:

```javascript
dropNode(event) {
    if (!this.draggedNodeType) return;

    const canvas = document.getElementById('canvas-container');
    const rect = canvas.getBoundingClientRect();

    // Calculate drop position
    const x = event.clientX - rect.left + canvas.scrollLeft - 100;
    const y = event.clientY - rect.top + canvas.scrollTop - 50;

    // Create new node
    const newNode = {
        id: crypto.randomUUID(),
        type: this.draggedNodeType.type,
        name: this.draggedNodeType.type,
        inputs: {},
        outputs: {},
        position: { x, y },
        style: {
            backgroundColor: this.draggedNodeType.color,
            textColor: '#FFFFFF',
            borderColor: this.draggedNodeType.color,
            icon: this.draggedNodeType.icon,
            width: 200
        },
        inputsJson: '{}'
    };

    this.workflow.nodes.push(newNode);

    // Set as start node if first node
    if (this.workflow.nodes.length === 1) {
        this.workflow.startNodeId = newNode.id;
    }

    this.draggedNodeType = null;
}
```

## Обробник падіння

Якщо вузол буде скинено на полотно:

```javascript
getConnectionPath(conn) {
    const source = this.workflow.nodes.find(n => n.id === conn.sourceNodeId);
    const target = this.workflow.nodes.find(n => n.id === conn.targetNodeId);

    if (!source || !target) return '';

    // Calculate connection points
    const x1 = source.position.x + (source.style?.width || 200);
    const y1 = source.position.y + 50;
    const x2 = target.position.x;
    const y2 = target.position.y + 50;

    // Create curved Bezier path
    const midX = (x1 + x2) / 2;
    return `M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}`;
}
```

Показ з' єднання SVG

## З' єднання між вузлами малюються шляхами SVG:

Це створює плавні, криві з' єднання між вузлами!

```html
<div class="w-80 bg-base-200 rounded-box p-4 overflow-y-auto"
     x-show="selectedNode">
    <h3 class="text-lg font-bold mb-4">⚙️ Node Settings</h3>

    <template x-if="selectedNode">
        <div class="space-y-4">
            <div class="form-control">
                <label class="label">Node Name</label>
                <input type="text"
                       x-model="selectedNode.name"
                       class="input input-bordered" />
            </div>

            <div class="form-control">
                <label class="label">Background Color</label>
                <input type="color"
                       x-model="selectedNode.style.backgroundColor"
                       class="input w-full h-12" />
            </div>

            <div class="form-control">
                <label class="label">Icon (emoji)</label>
                <input type="text"
                       x-model="selectedNode.style.icon"
                       class="input input-bordered"
                       maxlength="2" />
            </div>

            <div class="form-control">
                <label class="label">Inputs (JSON)</label>
                <textarea x-model="selectedNode.inputsJson"
                          @input="updateNodeInputs()"
                          class="textarea textarea-bordered font-mono"
                          rows="6"></textarea>
            </div>

            <button @click="setStartNode(selectedNode)"
                    class="btn btn-sm"
                    :class="workflow.startNodeId === selectedNode.id ?
                            'btn-success' : 'btn-outline'">
                Set as Start Node
            </button>
        </div>
    </template>
</div>
```

### Інспектор вузлів

За допомогою правої панелі ви зможете налаштувати вибрані вузли:

```javascript
updateNodeInputs() {
    if (!this.selectedNode) return;

    try {
        this.selectedNode.inputs = JSON.parse(this.selectedNode.inputsJson);
    } catch (e) {
        console.error('Invalid JSON:', e);
        // Could show error badge
    }
}
```

## Аналіз живого JSON

Як користувачі редагують вхідні JSON, ми обробляли їх у режимі реального часу:

```javascript
startConnection(node, type) {
    if (this.connecting) {
        // Complete connection
        if (this.connectionStart.node.id !== node.id) {
            this.workflow.connections.push({
                id: crypto.randomUUID(),
                sourceNodeId: this.connectionStart.node.id,
                targetNodeId: node.id,
                sourceOutput: 'default',
                targetInput: 'default'
            });
        }
        this.connecting = false;
        this.connectionStart = null;
    } else {
        // Start connection
        this.connecting = true;
        this.connectionStart = { node, type };
    }
}
```

## Вузли, що з' єднуються

Користувачі з' єднують вузли натисканням точок з' єднання:

```javascript
async saveWorkflow() {
    try {
        const response = await fetch('/workflow/save', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(this.workflow)
        });

        const result = await response.json();
        if (result.success) {
            alert('Workflow saved!');
            window.location.href = '/workflow';
        } else {
            alert('Error: ' + result.message);
        }
    } catch (error) {
        alert('Error saving: ' + error.message);
    }
}
```

## Збереження потокових даних за допомогою HTMX

Коли користувач клацає " Зберегти ," ми використовуємо простий виклик звантаження (можна також використовувати HTMX):

```javascript
async runWorkflow() {
    try {
        // Save first
        await this.saveWorkflow();

        // Then execute
        const response = await fetch(`/workflow/execute/${this.workflow.id}`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({})
        });

        const result = await response.json();
        if (result.success) {
            alert(`Workflow started! Execution ID: ${result.executionId}`);
        }
    } catch (error) {
        alert('Error: ' + error.message);
    }
}
```

## Виконання робіт

Виконати процес, який виконується за допомогою редактора:

```html
<!-- In your layout -->
<select data-choose-theme class="select select-bordered">
    <option value="light">Light</option>
    <option value="dark">Dark</option>
    <option value="cupcake">Cupcake</option>
    <option value="synthwave">Synthwave</option>
</select>
```

Теми перемикаються з DaisUIName`bg-base-200`, `text-base-content`DaisUI надає можливість автоматичного перемикання тем.

## Користувачі можуть перемикатися між світлими і темними режимами, а всі вузли робочого потоку автоматично адаптуються!

Всі кольори використовують семантичні класи ДейзіУІ (

```html
<div class="flex flex-col lg:flex-row gap-4">
    <!-- On mobile: stack vertically -->
    <!-- On desktop: side-by-side -->
</div>
```

## ), отже вони автоматично пристосовуються до вибраної теми!

### Дивовижний дизайн

Наша трипанельна розкладка адаптується до різних розмірів екрана:

### Оптимізація швидкодії

Віртуальні прокрутки

```javascript
let dragTimeout;
dragNode(event, node) {
    clearTimeout(dragTimeout);
    dragTimeout = setTimeout(() => {
        // Update position
    }, 16); // ~60fps
}
```

## Для того, щоб працювати з сотнями вузлів, ми могли реалізувати віртуальне гортання (лише візуальні вузли).

Але для більшості випадків зображення 50-100 вузлів цілком прийнятне для сучасних навігаторів.

```html
<button @click="deleteNode(node)"
        class="btn"
        aria-label="Delete node"
        @keydown.delete="deleteNode(node)">
    🗑️
</button>
```

## Обчислені оновлення

Під час перетягування вузлів ми могли б відсувати оновлення позиції:

✅ **Доступність**Ми додали відповідні мітки Арії та навігацію клавіатури:
✅ **Що ми збудували**На цьому посту ми створили:
✅ **Палітра перетягування зі скиданням**- Типи вузлів, які можна перетягувати
✅ **Візуальне полотно**- Тло ґратки з вузлами розташування
✅ **SVG- з' єднання**- Викриті шляхи між вузлами
✅ **Інспектор вузлів**- Панель налаштування реального часу
✅ **Підтримка тем**- Працює з усіма темами DaisUI
✅ **Репонентне компонування**- Пристосовує до розміру екрана

## Інтеграція з HTMX

- Керований сервером save/load

```json
{
  "name": "GitHub Stars Monitor",
  "nodes": [
    {
      "id": "1",
      "type": "HttpRequest",
      "name": "Fetch Repo Data",
      "inputs": {
        "url": "https://api.github.com/repos/{{owner}}/{{repo}}",
        "method": "GET"
      },
      "position": { "x": 100, "y": 100 },
      "style": { "backgroundColor": "#10B981", "icon": "🌐" }
    },
    {
      "id": "2",
      "type": "Condition",
      "name": "Check Stars",
      "inputs": {
        "condition": "{{stargazers_count}} > 100"
      },
      "position": { "x": 100, "y": 250 },
      "style": { "backgroundColor": "#8B5CF6", "icon": "🔀" }
    },
    {
      "id": "3",
      "type": "Log",
      "name": "Log Success",
      "inputs": {
        "message": "Repo has {{stargazers_count}} stars!",
        "level": "info"
      },
      "position": { "x": 100, "y": 400 },
      "style": { "backgroundColor": "#3B82F6", "icon": "📝" }
    }
  ],
  "connections": [
    { "sourceNodeId": "1", "targetNodeId": "2" },
    { "sourceNodeId": "2", "targetNodeId": "3" }
  ],
  "startNodeId": "1"
}
```

## Реагентність альпійських.js

- Плавний, реагуючий інтерфейс**Приклад процесу**Ось повний робочий потік, який ви можете зібрати у редакторі:

- Що далі?
- Вхід
- Частина 4
- Ми інтегруємо Hangfire для:
- Виконання запланованої роботи

Запобігання опитування API

## Керування станами

Обробка завдань тла

Спостереження за панеллю приладів

- **Ми зробимо наші робочі потоки справді автономними!**Висновки
- **Ми створили редактор для візуальної роботи з мінімальним JavaScript!**З'єднавши HTMX для зв'язку з сервером, альпійські.js для взаємодії, і TailwindCSS/DaisyUI для стилізації, ми створили щось, що відчуває себе сучасним і є придатним для збереження.
- **Ключові факти:**SVG
- **чудово для показу з' єднання**Альпійський.js

надає достатньо реагентності