509 lines
22 KiB
HTML
509 lines
22 KiB
HTML
|
|
{% extends "base.html" %}
|
|||
|
|
{% from "components/node_macro.html" import render_node %}
|
|||
|
|
|
|||
|
|
{% block content %}
|
|||
|
|
<div class="noir-layout" x-data="{
|
|||
|
|
aiOpen: false,
|
|||
|
|
zoom: 1,
|
|||
|
|
panX: 0,
|
|||
|
|
panY: 0,
|
|||
|
|
isPanning: false,
|
|||
|
|
startX: 0,
|
|||
|
|
startY: 0,
|
|||
|
|
spaceDown: false
|
|||
|
|
}" @keydown.window="if($event.code === 'Space') spaceDown = true"
|
|||
|
|
@keyup.window="if($event.code === 'Space') spaceDown = false">
|
|||
|
|
|
|||
|
|
<!-- 顶部统一操作栏 -->
|
|||
|
|
<div class="unified-toolbar">
|
|||
|
|
<button class="btn-queue" onclick="queueWorkflow()">
|
|||
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
|||
|
|
<path d="M8 5v14l11-7z" />
|
|||
|
|
</svg>
|
|||
|
|
Queue Prompt (启动任务)
|
|||
|
|
</button>
|
|||
|
|
<div class="divider" style="width: 1px; background: var(--border-color); margin: 0 4px;"></div>
|
|||
|
|
<button class="tool-btn" title="清理画布" onclick="clearCanvas()">
|
|||
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|||
|
|
<path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" />
|
|||
|
|
</svg>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 悬浮品牌 -->
|
|||
|
|
<div class="floating-brand">
|
|||
|
|
<h1 class="brand-logo">NoirFlow</h1>
|
|||
|
|
<p class="brand-tagline">暗流 · 节点生成系统</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 右键菜单 -->
|
|||
|
|
<div id="context-menu" class="context-menu" @click.away="closeContext()" style="display: none;">
|
|||
|
|
<div class="section-title" style="padding: 4px 12px; margin-bottom: 4px; font-size: 10px; opacity: 0.5;">新建节点
|
|||
|
|
</div>
|
|||
|
|
{% for config in node_configs %}
|
|||
|
|
<div class="context-menu-item" @click="createNodeAtMouse('{{ config.id }}')">
|
|||
|
|
<span class="icon">▣</span>
|
|||
|
|
<span class="label">{{ config.name }}</span>
|
|||
|
|
</div>
|
|||
|
|
{% endfor %}
|
|||
|
|
<div style="height: 1px; background: var(--border-color); margin: 4px 8px;"></div>
|
|||
|
|
<div class="context-menu-item" @click="clearCanvas(); closeContext();">
|
|||
|
|
<span class="icon">🗑</span>
|
|||
|
|
<span class="label">清空画布</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- AI 助手悬浮按钮 -->
|
|||
|
|
<div class="ai-toggle-btn" @click="aiOpen = !aiOpen">
|
|||
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|||
|
|
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
|
|||
|
|
</svg>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="ai-modal" x-show="aiOpen" x-transition x-cloak>
|
|||
|
|
<div class="sidebar-header"
|
|||
|
|
style="padding: 16px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center;">
|
|||
|
|
<h3 class="section-title" style="margin: 0;">AI 助手</h3>
|
|||
|
|
<button @click="aiOpen = false"
|
|||
|
|
style="background:none; border:none; color:var(--text-secondary); cursor:pointer; font-size: 20px;">×</button>
|
|||
|
|
</div>
|
|||
|
|
<div class="sidebar-section chat-section"
|
|||
|
|
style="padding: 16px; flex: 1; display: flex; flex-direction: column;">
|
|||
|
|
<div id="chat-messages" class="chat-box" style="flex: 1; overflow-y: auto;">
|
|||
|
|
<div class="msg system">你好!我是 NoirFlow 助手。您可以问我如何构建工作流。</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="chat-input-wrapper" style="margin-top: 12px;">
|
|||
|
|
<form hx-post="/api/chat" hx-target="#chat-messages" hx-swap="beforeend">
|
|||
|
|
<input type="text" name="message" placeholder="与 AI 对话..." class="chat-input">
|
|||
|
|
<button type="submit" class="chat-send">
|
|||
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|||
|
|
stroke-width="2">
|
|||
|
|
<path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" />
|
|||
|
|
</svg>
|
|||
|
|
</button>
|
|||
|
|
</form>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 主画布 -->
|
|||
|
|
<main class="canvas-area" id="canvas" @contextmenu.prevent="showContext($event)" @wheel.passive="handleZoom($event)"
|
|||
|
|
@mousedown="startPan($event)" @mousemove="doPan($event)" @mouseup="stopPan()" @mouseleave="stopPan()">
|
|||
|
|
|
|||
|
|
<div id="canvas-transform" :style="`transform: translate(${panX}px, ${panY}px) scale(${zoom})`"
|
|||
|
|
style="transform-origin: 0 0; width: 100%; height: 100%;">
|
|||
|
|
<div class="canvas-grid"></div>
|
|||
|
|
|
|||
|
|
<!-- 连线层 -->
|
|||
|
|
<svg id="connection-svg" class="connection-layer">
|
|||
|
|
<defs>
|
|||
|
|
<marker id="arrowhead-image" markerWidth="5" markerHeight="5" refX="4" refY="2.5" orient="auto">
|
|||
|
|
<polygon points="0 0, 5 2.5, 0 5" fill="#4ade80" />
|
|||
|
|
</marker>
|
|||
|
|
<marker id="arrowhead-text" markerWidth="5" markerHeight="5" refX="4" refY="2.5" orient="auto">
|
|||
|
|
<polygon points="0 0, 5 2.5, 0 5" fill="#38bdf8" />
|
|||
|
|
</marker>
|
|||
|
|
<marker id="arrowhead-file" markerWidth="5" markerHeight="5" refX="4" refY="2.5" orient="auto">
|
|||
|
|
<polygon points="0 0, 5 2.5, 0 5" fill="#facc15" />
|
|||
|
|
</marker>
|
|||
|
|
<marker id="arrowhead-default" markerWidth="5" markerHeight="5" refX="4" refY="2.5" orient="auto">
|
|||
|
|
<polygon points="0 0, 5 2.5, 0 5" fill="#ffffff" />
|
|||
|
|
</marker>
|
|||
|
|
</defs>
|
|||
|
|
</svg>
|
|||
|
|
|
|||
|
|
<div id="node-container">
|
|||
|
|
<!-- 初始预览节点 -->
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 顶级拖拽预览层 (不受缩放和平移影响,使用屏幕坐标) -->
|
|||
|
|
<svg id="drag-layer"
|
|||
|
|
style="position:fixed; top:0; left:0; width:100vw; height:100vh; z-index:9999; pointer-events:none; fill:none;"></svg>
|
|||
|
|
</main>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
// NoirConnect 工业级连线引擎:全局状态
|
|||
|
|
window.connections = [];
|
|||
|
|
window.selectedNodeId = null;
|
|||
|
|
let tempPathEl = null;
|
|||
|
|
window.currentZoom = 1;
|
|||
|
|
|
|||
|
|
// --- 核心坐标投影工具 (工业级方案) ---
|
|||
|
|
function projectToCanvas(screenX, screenY) {
|
|||
|
|
const svg = document.getElementById('connection-svg');
|
|||
|
|
if (!svg) return { x: screenX, y: screenY };
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const pt = svg.createSVGPoint();
|
|||
|
|
pt.x = screenX;
|
|||
|
|
pt.y = screenY;
|
|||
|
|
const ctm = svg.getScreenCTM();
|
|||
|
|
if (ctm) {
|
|||
|
|
const localPt = pt.matrixTransform(ctm.inverse());
|
|||
|
|
return { x: localPt.x, y: localPt.y };
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error("CTM Error:", e);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 极致降级方案:手动算 (仅在 CTM 失效时)
|
|||
|
|
const rect = svg.getBoundingClientRect();
|
|||
|
|
const alpine = getAlpineData();
|
|||
|
|
const zoom = alpine.zoom || 1;
|
|||
|
|
return {
|
|||
|
|
x: (screenX - rect.left) / zoom,
|
|||
|
|
y: (screenY - rect.top) / zoom
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getAlpineData() {
|
|||
|
|
const el = document.querySelector('.noir-layout');
|
|||
|
|
if (!el) return {};
|
|||
|
|
if (window.Alpine) return Alpine.$data(el);
|
|||
|
|
return el.__x ? el.__x.$data : {};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// --- 画布操作逻辑 (Zoom & Pan) ---
|
|||
|
|
let lastMouseX = 0, lastMouseY = 0;
|
|||
|
|
let spaceDown = false;
|
|||
|
|
window.addEventListener('keydown', (e) => { if (e.code === 'Space') spaceDown = true; });
|
|||
|
|
window.addEventListener('keyup', (e) => { if (e.code === 'Space') spaceDown = false; });
|
|||
|
|
|
|||
|
|
function handleZoom(e) {
|
|||
|
|
const alpine = getAlpineData();
|
|||
|
|
const delta = e.deltaY > 0 ? 0.9 : 1.1;
|
|||
|
|
const currentZoom = alpine.zoom || 1;
|
|||
|
|
const newZoom = Math.min(Math.max(0.1, currentZoom * delta), 3);
|
|||
|
|
alpine.zoom = newZoom;
|
|||
|
|
window.currentZoom = newZoom;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function startPan(e) {
|
|||
|
|
const alpine = getAlpineData();
|
|||
|
|
if (e.button === 1 || (e.button === 0 && (alpine.spaceDown || spaceDown))) {
|
|||
|
|
alpine.isPanning = true;
|
|||
|
|
alpine.startX = e.clientX - (alpine.panX || 0);
|
|||
|
|
alpine.startY = e.clientY - (alpine.panY || 0);
|
|||
|
|
document.body.style.cursor = 'grabbing';
|
|||
|
|
e.preventDefault();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function doPan(e) {
|
|||
|
|
const alpine = getAlpineData();
|
|||
|
|
if (alpine.isPanning) {
|
|||
|
|
alpine.panX = e.clientX - (alpine.startX || 0);
|
|||
|
|
alpine.panY = e.clientY - (alpine.startY || 0);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function stopPan() {
|
|||
|
|
const alpine = getAlpineData();
|
|||
|
|
if (alpine) alpine.isPanning = false;
|
|||
|
|
document.body.style.cursor = 'default';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// --- 右键菜单逻辑 ---
|
|||
|
|
let menuX = 0, menuY = 0;
|
|||
|
|
function showContext(e) {
|
|||
|
|
const menu = document.getElementById('context-menu');
|
|||
|
|
menu.style.display = 'block';
|
|||
|
|
menu.style.left = e.clientX + 'px';
|
|||
|
|
menu.style.top = e.clientY + 'px';
|
|||
|
|
|
|||
|
|
const alpine = getAlpineData();
|
|||
|
|
const canvas = document.getElementById('canvas');
|
|||
|
|
const rect = canvas.getBoundingClientRect();
|
|||
|
|
|
|||
|
|
menuX = (e.clientX - rect.left - (alpine.panX || 0)) / (alpine.zoom || 1);
|
|||
|
|
menuY = (e.clientY - rect.top - (alpine.panY || 0)) / (alpine.zoom || 1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function closeContext() {
|
|||
|
|
const menu = document.getElementById('context-menu');
|
|||
|
|
if (menu) menu.style.display = 'none';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function createNodeAtMouse(typeId) {
|
|||
|
|
const params = new URLSearchParams({ type: typeId, x: Math.round(menuX), y: Math.round(menuY) });
|
|||
|
|
fetch(`/api/create_node?${params.toString()}`, { method: 'POST' })
|
|||
|
|
.then(res => res.text())
|
|||
|
|
.then(html => {
|
|||
|
|
const wrapper = document.createElement('div');
|
|||
|
|
wrapper.innerHTML = html.trim();
|
|||
|
|
const newNode = wrapper.firstElementChild;
|
|||
|
|
document.getElementById('node-container').appendChild(newNode);
|
|||
|
|
htmx.process(newNode);
|
|||
|
|
closeContext();
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// --- 统一执行逻辑 ---
|
|||
|
|
window.queueWorkflow = function () {
|
|||
|
|
console.log("Queueing workflow...");
|
|||
|
|
// 找到所有非 input 类型的节点并触发执行 (简单版本)
|
|||
|
|
const nodes = document.querySelectorAll('.node-box');
|
|||
|
|
nodes.forEach(node => {
|
|||
|
|
const btn = node.querySelector('.btn-run');
|
|||
|
|
// 如果节点需要执行逻辑(如生成器),模拟点击
|
|||
|
|
// 注意:我们之前移除了节点上的按钮,这里需要通过 HTMX 手动触发
|
|||
|
|
const nodeId = node.id;
|
|||
|
|
const nodeType = node.getAttribute('data-node-type');
|
|||
|
|
if (nodeType === 'nano_banana' || nodeType === 'image_preview' || nodeType === 'sys_dict_api') {
|
|||
|
|
htmx.ajax('POST', `/api/run_node/${nodeId}`, {
|
|||
|
|
target: `#content-${nodeId}`,
|
|||
|
|
values: collectNodeInputs(nodeId)
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
function collectNodeInputs(nodeId) {
|
|||
|
|
const node = document.getElementById(nodeId);
|
|||
|
|
const inputs = {};
|
|||
|
|
node.querySelectorAll('.node-input-field').forEach(field => {
|
|||
|
|
inputs[field.name] = field.value;
|
|||
|
|
});
|
|||
|
|
return inputs;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// --- 连线引擎 ---
|
|||
|
|
window.updateLines = () => {
|
|||
|
|
window.connections.forEach(conn => {
|
|||
|
|
updatePathPosition(conn, conn.startSlotId, conn.endSlotId);
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
function updatePathPosition(conn, startId, endId) {
|
|||
|
|
const sEl = document.getElementById(startId);
|
|||
|
|
const eEl = document.getElementById(endId);
|
|||
|
|
if (!sEl || !eEl) return;
|
|||
|
|
|
|||
|
|
const sDot = sEl.querySelector('.slot-dot') || sEl;
|
|||
|
|
const eDot = eEl.querySelector('.slot-dot') || eEl;
|
|||
|
|
|
|||
|
|
const sRect = sDot.getBoundingClientRect();
|
|||
|
|
const eRect = eDot.getBoundingClientRect();
|
|||
|
|
|
|||
|
|
const p1 = projectToCanvas(sRect.left + sRect.width / 2, sRect.top + sRect.height / 2);
|
|||
|
|
const p2 = projectToCanvas(eRect.left + eRect.width / 2, eRect.top + eRect.height / 2);
|
|||
|
|
if (isNaN(p1.x) || isNaN(p1.y) || isNaN(p2.x) || isNaN(p2.y)) return;
|
|||
|
|
|
|||
|
|
const dx = Math.abs(p1.x - p2.x) * 0.5;
|
|||
|
|
const d = `M ${p1.x} ${p1.y} C ${p1.x + dx} ${p1.y}, ${p2.x - dx} ${p2.y}, ${p2.x} ${p2.y}`;
|
|||
|
|
|
|||
|
|
conn.pathEl.setAttribute('d', d);
|
|||
|
|
if (conn.hitEl) conn.hitEl.setAttribute('d', d);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function createConnection(sId, eId) {
|
|||
|
|
const sEl = document.getElementById(sId);
|
|||
|
|
const eEl = document.getElementById(eId);
|
|||
|
|
// 智能类型兼容性检查
|
|||
|
|
const t1 = sEl.dataset.dataType || 'any';
|
|||
|
|
const t2 = eEl.dataset.dataType || 'any';
|
|||
|
|
const isCompatible = (t1 === t2) ||
|
|||
|
|
((t1 === 'text' || t1 === 'string') && (t2 === 'text' || t2 === 'string'));
|
|||
|
|
|
|||
|
|
if (!isCompatible) {
|
|||
|
|
console.warn(`[NoirFlow] 类型不匹配: ${t1} vs ${t2}`);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const outId = sEl.dataset.slotType === 'output' ? sId : eId;
|
|||
|
|
const inId = sEl.dataset.slotType === 'output' ? eId : sId;
|
|||
|
|
|
|||
|
|
if (window.connections.find(c => c.startSlotId === outId && c.endSlotId === inId)) return;
|
|||
|
|
|
|||
|
|
const activeType = sEl.dataset.slotType === 'output' ? t1 : t2;
|
|||
|
|
const colors = { 'image': '#4ade80', 'text': '#38bdf8', 'string': '#38bdf8', 'dict': '#a855f7', 'file': '#facc15' };
|
|||
|
|
const activeColor = colors[activeType] || '#ffffff';
|
|||
|
|
|
|||
|
|
const hit = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|||
|
|
hit.setAttribute('class', 'connection-hit-area');
|
|||
|
|
hit.style.cssText = 'fill:none; stroke:transparent; stroke-width:15px; cursor:pointer; pointer-events:stroke;';
|
|||
|
|
|
|||
|
|
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|||
|
|
path.setAttribute('class', 'connection-path');
|
|||
|
|
const markerMap = { 'image': 'arrowhead-image', 'text': 'arrowhead-text', 'string': 'arrowhead-text', 'file': 'arrowhead-file' };
|
|||
|
|
path.setAttribute('marker-end', `url(#${markerMap[activeType] || 'arrowhead-default'})`);
|
|||
|
|
path.style.stroke = activeColor;
|
|||
|
|
path.style.filter = `drop-shadow(0 0 5px ${activeColor}66)`;
|
|||
|
|
|
|||
|
|
const conn = { startSlotId: outId, endSlotId: inId, pathEl: path, hitEl: hit };
|
|||
|
|
|
|||
|
|
hit.onclick = (e) => {
|
|||
|
|
e.stopPropagation();
|
|||
|
|
path.remove(); hit.remove();
|
|||
|
|
window.connections = window.connections.filter(c => c !== conn);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const svg = document.getElementById('connection-svg');
|
|||
|
|
svg.appendChild(hit);
|
|||
|
|
svg.appendChild(path);
|
|||
|
|
window.connections.push(conn);
|
|||
|
|
updatePathPosition(conn, outId, inId);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
window.currentZoom = 1;
|
|||
|
|
|
|||
|
|
function startConnectionDrag(e, el) {
|
|||
|
|
if (e.button !== 0) return;
|
|||
|
|
e.preventDefault(); e.stopPropagation();
|
|||
|
|
|
|||
|
|
const startSlot = el;
|
|||
|
|
const dragLayer = document.getElementById('drag-layer');
|
|||
|
|
if (!dragLayer) return;
|
|||
|
|
|
|||
|
|
// 获取屏幕中心点
|
|||
|
|
const sDot = startSlot.querySelector('.slot-dot') || startSlot;
|
|||
|
|
const sRect = sDot.getBoundingClientRect();
|
|||
|
|
const p1 = { x: sRect.left + sRect.width / 2, y: sRect.top + sRect.height / 2 };
|
|||
|
|
|
|||
|
|
sDot.classList.add('active');
|
|||
|
|
let currentTarget = null;
|
|||
|
|
|
|||
|
|
const tempPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|||
|
|
tempPath.style.fill = 'none';
|
|||
|
|
tempPath.style.stroke = '#00f7ff';
|
|||
|
|
tempPath.style.strokeWidth = '4px';
|
|||
|
|
tempPath.style.strokeDasharray = '8,4';
|
|||
|
|
tempPath.style.pointerEvents = 'none';
|
|||
|
|
dragLayer.appendChild(tempPath);
|
|||
|
|
|
|||
|
|
const findNearestSlot = (x, y) => {
|
|||
|
|
let best = null;
|
|||
|
|
let minDist = 40;
|
|||
|
|
document.querySelectorAll('.slot').forEach(slot => {
|
|||
|
|
if (slot === startSlot || slot.dataset.slotType === startSlot.dataset.slotType) return;
|
|||
|
|
const dot = slot.querySelector('.slot-dot') || slot;
|
|||
|
|
const dRect = dot.getBoundingClientRect();
|
|||
|
|
const dist = Math.hypot(x - (dRect.left + dRect.width / 2), y - (dRect.top + dRect.height / 2));
|
|||
|
|
if (dist < minDist) { minDist = dist; best = slot; }
|
|||
|
|
});
|
|||
|
|
return best;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const onMouseMove = (moveEvent) => {
|
|||
|
|
const p2 = { x: moveEvent.clientX, y: moveEvent.clientY };
|
|||
|
|
const dx = Math.abs(p1.x - p2.x) * 0.5;
|
|||
|
|
tempPath.setAttribute('d', `M ${p1.x} ${p1.y} C ${p1.x + dx} ${p1.y}, ${p2.x - dx} ${p2.y}, ${p2.x} ${p2.y}`);
|
|||
|
|
|
|||
|
|
const newTarget = findNearestSlot(moveEvent.clientX, moveEvent.clientY);
|
|||
|
|
|
|||
|
|
if (newTarget !== currentTarget) {
|
|||
|
|
if (currentTarget) currentTarget.querySelector('.slot-dot')?.classList.remove('hover');
|
|||
|
|
currentTarget = newTarget;
|
|||
|
|
if (currentTarget) currentTarget.querySelector('.slot-dot')?.classList.add('hover');
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const onMouseUp = (upEvent) => {
|
|||
|
|
window.removeEventListener('mousemove', onMouseMove);
|
|||
|
|
window.removeEventListener('mouseup', onMouseUp);
|
|||
|
|
tempPath.remove();
|
|||
|
|
sDot.classList.remove('active');
|
|||
|
|
if (currentTarget) currentTarget.querySelector('.slot-dot')?.classList.remove('hover');
|
|||
|
|
|
|||
|
|
// 最终落点检测
|
|||
|
|
const target = findNearestSlot(upEvent.clientX, upEvent.clientY);
|
|||
|
|
|
|||
|
|
console.log("[NoirFlow] 释放鼠标,命中的目标:", target?.id || '无');
|
|||
|
|
|
|||
|
|
if (target && target !== startSlot) {
|
|||
|
|
if (target.dataset.slotType !== startSlot.dataset.slotType) {
|
|||
|
|
createConnection(startSlot.id, target.id);
|
|||
|
|
} else {
|
|||
|
|
console.warn("[NoirFlow] 无法连接相同类型的插槽");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
window.addEventListener('mousemove', onMouseMove);
|
|||
|
|
window.addEventListener('mouseup', onMouseUp);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
window.removeNode = (nodeId) => {
|
|||
|
|
window.connections.forEach(c => {
|
|||
|
|
const s = document.getElementById(c.startSlotId);
|
|||
|
|
const e = document.getElementById(c.endSlotId);
|
|||
|
|
if ((s && s.dataset.nodeId === nodeId) || (e && e.dataset.nodeId === nodeId)) {
|
|||
|
|
c.pathEl.remove(); c.hitEl.remove();
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
window.connections = window.connections.filter(c => {
|
|||
|
|
const s = document.getElementById(c.startSlotId), e = document.getElementById(c.endSlotId);
|
|||
|
|
return !((s && s.dataset.nodeId === nodeId) || (e && e.dataset.nodeId === nodeId));
|
|||
|
|
});
|
|||
|
|
document.getElementById(nodeId)?.remove();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
window.addEventListener('mousedown', (e) => {
|
|||
|
|
if (!e.target.closest('.context-menu')) closeContext();
|
|||
|
|
const node = e.target.closest('.node-box');
|
|||
|
|
document.querySelectorAll('.node-box').forEach(n => n.classList.remove('selected'));
|
|||
|
|
if (node) {
|
|||
|
|
node.classList.add('selected');
|
|||
|
|
window.selectedNodeId = node.id;
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
window.clearCanvas = () => {
|
|||
|
|
document.getElementById('node-container').innerHTML = '';
|
|||
|
|
window.connections.forEach(c => { c.pathEl.remove(); c.hitEl.remove(); });
|
|||
|
|
window.connections = [];
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 初始清空示例,改为根据配置生成
|
|||
|
|
document.addEventListener('DOMContentLoaded', () => { closeContext(); });
|
|||
|
|
|
|||
|
|
// 全局粘贴与点击逻辑保持不变...
|
|||
|
|
window.addEventListener('paste', function (e) {
|
|||
|
|
if (['INPUT', 'TEXTAREA'].includes(e.target.tagName)) return;
|
|||
|
|
const items = (e.clipboardData || e.originalEvent.clipboardData).items;
|
|||
|
|
let file = null;
|
|||
|
|
for (let i = 0; i < items.length; i++) {
|
|||
|
|
if (items[i].type.indexOf('image') !== -1) {
|
|||
|
|
file = items[i].getAsFile();
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (file) {
|
|||
|
|
e.preventDefault();
|
|||
|
|
let selectedNode = window.selectedNodeId ? document.getElementById(window.selectedNodeId) : null;
|
|||
|
|
if (selectedNode && (selectedNode.getAttribute('data-node-type') === 'image_upload' || selectedNode.id.includes('image_upload'))) {
|
|||
|
|
window.uploadToNode(window.selectedNodeId, file);
|
|||
|
|
} else {
|
|||
|
|
const params = new URLSearchParams({ type: 'image_upload', x: 250, y: 250 });
|
|||
|
|
fetch(`/api/create_node?${params.toString()}`, { method: 'POST' })
|
|||
|
|
.then(res => res.text())
|
|||
|
|
.then(html => {
|
|||
|
|
const wrapper = document.createElement('div');
|
|||
|
|
wrapper.innerHTML = html.trim();
|
|||
|
|
const newNode = wrapper.firstElementChild;
|
|||
|
|
document.getElementById('node-container').appendChild(newNode);
|
|||
|
|
htmx.process(newNode);
|
|||
|
|
setTimeout(() => {
|
|||
|
|
document.querySelectorAll('.node-box').forEach(n => n.classList.remove('selected'));
|
|||
|
|
newNode.classList.add('selected');
|
|||
|
|
window.selectedNodeId = newNode.id;
|
|||
|
|
window.uploadToNode(newNode.id, file);
|
|||
|
|
}, 10);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}, true);
|
|||
|
|
|
|||
|
|
window.uploadToNode = function (nodeId, file) {
|
|||
|
|
const formData = new FormData();
|
|||
|
|
formData.append('file', file);
|
|||
|
|
const contentEl = document.querySelector(`#${nodeId} .node-content`);
|
|||
|
|
if (contentEl) contentEl.innerHTML = '<div class="placeholder"><div class="loader-ripple"></div><span>正在上传图片...</span></div>';
|
|||
|
|
fetch('/api/upload_asset', { method: 'POST', body: formData })
|
|||
|
|
.then(res => res.text())
|
|||
|
|
.then(html => { if (contentEl) { contentEl.innerHTML = html; htmx.process(contentEl); } });
|
|||
|
|
};
|
|||
|
|
</script>
|
|||
|
|
{% endblock %}
|