155 lines
6.5 KiB
HTML
155 lines
6.5 KiB
HTML
<!-- Jinja2 Macro: 定义一个通用的节点结构 -->
|
||
{% macro render_node(node_data, node_instance_id, x=100, y=100) %}
|
||
<div class="node-box" id="{{ node_instance_id }}" data-node-type="{{ node_data.id }}"
|
||
style="top: {{ y }}px; left: {{ x }}px; width: 280px;" x-data="{
|
||
x: {{ x }},
|
||
y: {{ y }},
|
||
w: 280,
|
||
h: 0,
|
||
dragging: false,
|
||
resizing: false,
|
||
lastMouseX: 0,
|
||
lastMouseY: 0,
|
||
startDrag(e) {
|
||
if (e.target.closest('.node-header')) {
|
||
this.dragging = true;
|
||
this.lastMouseX = e.clientX;
|
||
this.lastMouseY = e.clientY;
|
||
window.addEventListener('mousemove', this.handleDrag.bind(this));
|
||
window.addEventListener('mouseup', this.stopDrag.bind(this));
|
||
}
|
||
},
|
||
handleDrag(e) {
|
||
if (this.dragging) {
|
||
const zoom = window.currentZoom || 1;
|
||
const dx = (e.clientX - this.lastMouseX) / zoom;
|
||
const dy = (e.clientY - this.lastMouseY) / zoom;
|
||
|
||
// 计算连续移动值
|
||
this.x += dx;
|
||
this.y += dy;
|
||
|
||
// 应用 30px 的网格吸附
|
||
const snapX = Math.round(this.x / 30) * 30;
|
||
const snapY = Math.round(this.y / 30) * 30;
|
||
|
||
this.lastMouseX = e.clientX;
|
||
this.lastMouseY = e.clientY;
|
||
|
||
$el.style.left = snapX + 'px';
|
||
$el.style.top = snapY + 'px';
|
||
|
||
if (window.updateLines) window.updateLines();
|
||
}
|
||
},
|
||
stopDrag() {
|
||
this.dragging = false;
|
||
window.removeEventListener('mousemove', this.handleDrag);
|
||
window.removeEventListener('mouseup', this.stopDrag);
|
||
},
|
||
startResize(e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
this.resizing = true;
|
||
this.lastMouseX = e.clientX;
|
||
this.lastMouseY = e.clientY;
|
||
// 获取当前宽高作为基准
|
||
const rect = $el.getBoundingClientRect();
|
||
const zoom = window.currentZoom || 1;
|
||
this.w = rect.width / zoom;
|
||
this.h = rect.height / zoom;
|
||
window.addEventListener('mousemove', this.handleResize.bind(this));
|
||
window.addEventListener('mouseup', this.stopResize.bind(this));
|
||
},
|
||
handleResize(e) {
|
||
if (this.resizing) {
|
||
const zoom = window.currentZoom || 1;
|
||
const dx = (e.clientX - this.lastMouseX) / zoom;
|
||
const dy = (e.clientY - this.lastMouseY) / zoom;
|
||
this.w += dx;
|
||
this.h += dy;
|
||
this.lastMouseX = e.clientX;
|
||
this.lastMouseY = e.clientY;
|
||
$el.style.width = Math.max(250, this.w) + 'px';
|
||
$el.style.height = Math.max(280, this.h) + 'px';
|
||
if (window.updateLines) window.updateLines();
|
||
}
|
||
},
|
||
stopResize() {
|
||
this.resizing = false;
|
||
window.removeEventListener('mousemove', this.handleResize);
|
||
window.removeEventListener('mouseup', this.stopResize);
|
||
}
|
||
}" @mousedown="startDrag">
|
||
<!-- 标题栏 -->
|
||
<div class="node-header">
|
||
<span class="node-title">{{ node_data.name }}</span>
|
||
<div class="header-tools">
|
||
<div class="status-dot"></div>
|
||
<button class="node-delete-btn" @click.stop="window.removeNode('{{ node_instance_id }}')"
|
||
title="删除节点">×</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 输入区域 -->
|
||
<div class="node-body">
|
||
<div class="node-inputs">
|
||
{% for input in node_data.inputs %}
|
||
<div class="slot-group">
|
||
<div class="slot input-slot" id="{{ node_instance_id }}_in_{{ input.name }}"
|
||
data-node-id="{{ node_instance_id }}" data-slot-name="{{ input.name }}" data-slot-type="input"
|
||
data-data-type="{{ input.data_type }}" onmousedown="startConnectionDrag(event, this)">
|
||
<div class="slot-dot"></div>
|
||
<label>{{ input.label }}</label>
|
||
</div>
|
||
{% if input.ui_widget == 'text_area' %}
|
||
<textarea name="{{ input.name }}" placeholder="在此输入内容..." class="node-input-field"></textarea>
|
||
{% elif input.ui_widget == 'text_input' %}
|
||
<input type="text" name="{{ input.name }}" placeholder="在此输入..." class="node-input-field">
|
||
{% elif input.ui_widget == 'select' %}
|
||
<select name="{{ input.name }}" class="node-input-field">
|
||
{% for option in input.options %}
|
||
<option value="{{ option }}">{{ option }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
{% elif input.ui_widget == 'file_upload' %}
|
||
<label class="file-upload-label">
|
||
<input type="file" name="{{ input.name }}" class="hidden-input" hx-post="/api/upload_asset"
|
||
hx-encoding="multipart/form-data" hx-target="#content-{{ node_instance_id }}">
|
||
<div class="upload-box">
|
||
<span class="upload-icon">↑</span>
|
||
<span class="upload-text">点击上传或直接粘贴</span>
|
||
</div>
|
||
</label>
|
||
{% elif input.ui_widget == 'hidden' %}
|
||
<input type="hidden" name="{{ input.name }}">
|
||
{% endif %}
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
|
||
<!-- 预览/输出区域 -->
|
||
<div class="node-content" id="content-{{ node_instance_id }}">
|
||
<div class="placeholder">
|
||
<div class="loader-ripple" hx-indicator></div>
|
||
<span>等待操作...</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 输出插槽 -->
|
||
<div class="node-outputs">
|
||
{% for output in node_data.outputs %}
|
||
<div class="slot output-slot" id="{{ node_instance_id }}_out_{{ output.name }}"
|
||
data-node-id="{{ node_instance_id }}" data-slot-name="{{ output.name }}" data-slot-type="output"
|
||
data-data-type="{{ output.data_type }}" onmousedown="startConnectionDrag(event, this)">
|
||
<label>{{ output.label }}</label>
|
||
<div class="slot-dot"></div>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 缩放句柄 -->
|
||
<div class="node-resizer" @mousedown="startResize"></div>
|
||
</div>
|
||
{% endmacro %} |