初始化
This commit is contained in:
@@ -0,0 +1,417 @@
|
||||
import {
|
||||
branchInputName,
|
||||
createBranch,
|
||||
createStage,
|
||||
parsePipelineConfig,
|
||||
serializePipelineConfig,
|
||||
} from "./pipeline_config.mjs";
|
||||
|
||||
let editorCounter = 0;
|
||||
export const PIPELINE_NODE_MIN_WIDTH = 430;
|
||||
const PIPELINE_WIDGET_START_Y = 4;
|
||||
const PIPELINE_NODE_BOTTOM_PADDING = 8;
|
||||
|
||||
export function calculateStackContentHeight(
|
||||
childHeights,
|
||||
gap = 0,
|
||||
paddingTop = 0,
|
||||
paddingBottom = 0,
|
||||
) {
|
||||
const heights = childHeights.filter((height) => Number.isFinite(height) && height > 0);
|
||||
return Math.ceil(
|
||||
heights.reduce((total, height) => total + height, 0)
|
||||
+ gap * Math.max(heights.length - 1, 0)
|
||||
+ paddingTop
|
||||
+ paddingBottom,
|
||||
);
|
||||
}
|
||||
|
||||
export function calculateSlotCanvasY(widgetY, rowOffsetY, widgetMargin = 0) {
|
||||
return widgetY + rowOffsetY + widgetMargin;
|
||||
}
|
||||
|
||||
function stackContentHeight(root) {
|
||||
const style = getComputedStyle(root);
|
||||
const gap = Number.parseFloat(style.rowGap || style.gap) || 0;
|
||||
const paddingTop = Number.parseFloat(style.paddingTop) || 0;
|
||||
const paddingBottom = Number.parseFloat(style.paddingBottom) || 0;
|
||||
return calculateStackContentHeight(
|
||||
[...root.children].map((child) => child.offsetHeight),
|
||||
gap,
|
||||
paddingTop,
|
||||
paddingBottom,
|
||||
);
|
||||
}
|
||||
|
||||
function element(tag, className, text) {
|
||||
const item = document.createElement(tag);
|
||||
if (className) item.className = className;
|
||||
if (text !== undefined) item.textContent = text;
|
||||
return item;
|
||||
}
|
||||
|
||||
function iconButton(symbol, title, onClick) {
|
||||
const button = element("button", "fb-icon-button", symbol);
|
||||
button.type = "button";
|
||||
button.title = title;
|
||||
button.setAttribute("aria-label", title);
|
||||
button.addEventListener("click", onClick);
|
||||
return button;
|
||||
}
|
||||
|
||||
function nodeInput(node, name) {
|
||||
return node.inputs?.find((item) => item.name === name);
|
||||
}
|
||||
|
||||
function isConnected(input) {
|
||||
return input?.link !== null && input?.link !== undefined;
|
||||
}
|
||||
|
||||
function removeInputByName(node, name) {
|
||||
const index = node.inputs?.findIndex((item) => item.name === name) ?? -1;
|
||||
if (index >= 0) node.removeInput(index);
|
||||
}
|
||||
|
||||
function stopCanvasGestures(root) {
|
||||
for (const eventName of ["pointerdown", "mousedown", "wheel"]) {
|
||||
root.addEventListener(eventName, (event) => event.stopPropagation());
|
||||
}
|
||||
}
|
||||
|
||||
export function setupPipelineEditor(node, app, onStateChanged) {
|
||||
if (node.__flowPipelineEditor) return node.__flowPipelineEditor;
|
||||
const configWidget = node.widgets?.find((item) => item.name === "pipeline_config");
|
||||
if (!configWidget) return null;
|
||||
|
||||
editorCounter += 1;
|
||||
const editorId = `flow-pipeline-${editorCounter}`;
|
||||
const root = element("div", "fb-pipeline-editor");
|
||||
stopCanvasGestures(root);
|
||||
let config = parsePipelineConfig(configWidget.value);
|
||||
let rowElements = new Map();
|
||||
let destroyed = false;
|
||||
let syncingInputs = false;
|
||||
let fittingNode = false;
|
||||
|
||||
// Branch sockets are positioned beside DOM rows. A fixed widget origin keeps
|
||||
// LiteGraph's slot bounds from pushing the widget down again on every frame.
|
||||
node.widgets_start_y = PIPELINE_WIDGET_START_Y;
|
||||
|
||||
function desiredEditorHeight() {
|
||||
return Math.max(52, stackContentHeight(root) + 8);
|
||||
}
|
||||
|
||||
const domWidget = node.addDOMWidget("flow_pipeline_editor", "flow-pipeline-editor", root, {
|
||||
serialize: false,
|
||||
getMinHeight() {
|
||||
return desiredEditorHeight();
|
||||
},
|
||||
getMaxHeight() {
|
||||
return desiredEditorHeight();
|
||||
},
|
||||
});
|
||||
domWidget.serialize = false;
|
||||
|
||||
function persist() {
|
||||
configWidget.value = serializePipelineConfig(config);
|
||||
}
|
||||
|
||||
function desiredBranchInputs() {
|
||||
return config.stages.flatMap((stage) => stage.branches.map((branch) => ({
|
||||
name: branchInputName(branch.id),
|
||||
label: branch.name,
|
||||
})));
|
||||
}
|
||||
|
||||
function syncInputs() {
|
||||
syncingInputs = true;
|
||||
try {
|
||||
const desired = desiredBranchInputs();
|
||||
const desiredNames = new Set(desired.map((item) => item.name));
|
||||
const obsolete = (node.inputs || [])
|
||||
.filter((item) => item.name.startsWith("branch_") && !desiredNames.has(item.name))
|
||||
.map((item) => item.name);
|
||||
for (const name of obsolete) removeInputByName(node, name);
|
||||
for (const item of desired) {
|
||||
let input = nodeInput(node, item.name);
|
||||
if (!input) input = node.addInput(item.name, "*");
|
||||
input.label = " ";
|
||||
input.localized_name = " ";
|
||||
input.__flowBranchLabel = item.label;
|
||||
}
|
||||
} finally {
|
||||
syncingInputs = false;
|
||||
}
|
||||
}
|
||||
|
||||
function updateSlotOffsets() {
|
||||
if (destroyed || !root.isConnected) return;
|
||||
const rootRect = root.getBoundingClientRect();
|
||||
const scale = root.offsetHeight > 0 ? rootRect.height / root.offsetHeight : 1;
|
||||
for (const [inputName, row] of rowElements) {
|
||||
const input = nodeInput(node, inputName);
|
||||
if (!input || !row.isConnected) continue;
|
||||
const rowRect = row.getBoundingClientRect();
|
||||
input.__flowBranchOffsetY = (rowRect.top - rootRect.top + rowRect.height / 2) / (scale || 1);
|
||||
}
|
||||
updateCanvasPositions();
|
||||
}
|
||||
|
||||
function updateCanvasPositions() {
|
||||
for (const input of node.inputs || []) {
|
||||
if (!input.name.startsWith("branch_") || input.__flowBranchOffsetY === undefined) continue;
|
||||
input.pos = [
|
||||
0,
|
||||
calculateSlotCanvasY(domWidget.y, input.__flowBranchOffsetY, domWidget.margin),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
function fitNode() {
|
||||
if (destroyed || fittingNode) return;
|
||||
fittingNode = true;
|
||||
try {
|
||||
const width = Math.max(PIPELINE_NODE_MIN_WIDTH, node.size?.[0] || 0);
|
||||
const height = Math.max(
|
||||
150,
|
||||
PIPELINE_WIDGET_START_Y + desiredEditorHeight() + PIPELINE_NODE_BOTTOM_PADDING,
|
||||
);
|
||||
if (Math.abs((node.size?.[1] || 0) - height) > 2 || (node.size?.[0] || 0) < width) {
|
||||
node.setSize([width, height]);
|
||||
}
|
||||
updateSlotOffsets();
|
||||
app.graph?.setDirtyCanvas(true, true);
|
||||
} finally {
|
||||
fittingNode = false;
|
||||
}
|
||||
}
|
||||
|
||||
function notify({ render = true } = {}) {
|
||||
persist();
|
||||
syncInputs();
|
||||
if (render) renderEditor();
|
||||
onStateChanged?.();
|
||||
requestAnimationFrame(fitNode);
|
||||
}
|
||||
|
||||
function makeStageHeader(stage, stageIndex, previousName) {
|
||||
const header = element("div", "fb-stage-header");
|
||||
const enabled = element("input", "fb-stage-toggle");
|
||||
enabled.type = "checkbox";
|
||||
enabled.checked = stage.enabled;
|
||||
enabled.title = stage.enabled ? "阶段已启用" : "阶段已旁路";
|
||||
enabled.addEventListener("change", () => {
|
||||
stage.enabled = enabled.checked;
|
||||
notify();
|
||||
});
|
||||
|
||||
const names = element("div", "fb-stage-names");
|
||||
const source = element("div", "fb-stage-source");
|
||||
source.append(
|
||||
element("span", "fb-stage-index", `阶段 ${stageIndex + 1}`),
|
||||
element("span", "fb-stage-path", `来自 ${previousName || "未命名结果"}`),
|
||||
);
|
||||
const name = element("input", "fb-name-input");
|
||||
name.type = "text";
|
||||
name.value = stage.name;
|
||||
name.placeholder = "阶段结果名称";
|
||||
name.title = "该阶段完成后发布的结果名称";
|
||||
name.addEventListener("input", () => {
|
||||
stage.name = name.value;
|
||||
persist();
|
||||
onStateChanged?.();
|
||||
});
|
||||
name.addEventListener("change", () => notify());
|
||||
names.append(source, name);
|
||||
|
||||
const actions = element("div", "fb-stage-actions");
|
||||
const moveUp = iconButton("↑", "阶段上移", () => {
|
||||
[config.stages[stageIndex - 1], config.stages[stageIndex]] = [
|
||||
config.stages[stageIndex], config.stages[stageIndex - 1],
|
||||
];
|
||||
notify();
|
||||
});
|
||||
moveUp.disabled = stageIndex <= 0;
|
||||
const moveDown = iconButton("↓", "阶段下移", () => {
|
||||
[config.stages[stageIndex + 1], config.stages[stageIndex]] = [
|
||||
config.stages[stageIndex], config.stages[stageIndex + 1],
|
||||
];
|
||||
notify();
|
||||
});
|
||||
moveDown.disabled = stageIndex >= config.stages.length - 1;
|
||||
const removeStage = iconButton("×", "删除阶段", () => {
|
||||
config.stages.splice(stageIndex, 1);
|
||||
notify();
|
||||
});
|
||||
removeStage.classList.add("fb-delete-button");
|
||||
actions.append(moveUp, moveDown, removeStage);
|
||||
header.append(enabled, names, actions);
|
||||
return header;
|
||||
}
|
||||
|
||||
function makeBypassRow(stage) {
|
||||
const label = element("label", "fb-branch-row fb-bypass-row");
|
||||
if (!stage.autoSelect && stage.selected === null) label.classList.add("fb-branch-selected");
|
||||
const radio = element("input", "fb-branch-radio");
|
||||
radio.type = "radio";
|
||||
radio.name = `${editorId}-${stage.id}`;
|
||||
radio.checked = stage.selected === null;
|
||||
radio.disabled = !stage.enabled || stage.autoSelect;
|
||||
radio.addEventListener("change", () => {
|
||||
if (!radio.checked) return;
|
||||
stage.selected = null;
|
||||
notify();
|
||||
});
|
||||
label.title = "不执行本阶段,直接使用上一阶段结果";
|
||||
label.append(radio, element("span", "fb-bypass-label", "跳过本阶段"));
|
||||
return label;
|
||||
}
|
||||
|
||||
function makeAutoSelectRow(stage) {
|
||||
const label = element("label", "fb-auto-row");
|
||||
if (stage.autoSelect) label.classList.add("fb-auto-enabled");
|
||||
const toggle = element("input", "fb-auto-toggle");
|
||||
toggle.type = "checkbox";
|
||||
toggle.checked = stage.autoSelect;
|
||||
toggle.title = "运行时按从上到下的顺序选择第一个可用方案";
|
||||
toggle.addEventListener("change", () => {
|
||||
stage.autoSelect = toggle.checked;
|
||||
notify();
|
||||
});
|
||||
label.append(
|
||||
toggle,
|
||||
element("span", "fb-auto-label", "自动选择可用方案"),
|
||||
element("span", "fb-auto-hint", "从上到下"),
|
||||
);
|
||||
return label;
|
||||
}
|
||||
|
||||
function makeBranchRow(stage, branch, branchIndex) {
|
||||
const inputName = branchInputName(branch.id);
|
||||
const row = element("div", "fb-branch-row");
|
||||
row.dataset.inputName = inputName;
|
||||
if (!stage.autoSelect && stage.selected === branch.id) row.classList.add("fb-branch-selected");
|
||||
if (isConnected(nodeInput(node, inputName))) row.classList.add("fb-branch-connected");
|
||||
|
||||
const radio = element("input", "fb-branch-radio");
|
||||
radio.type = "radio";
|
||||
radio.name = `${editorId}-${stage.id}`;
|
||||
radio.checked = stage.selected === branch.id;
|
||||
radio.disabled = !stage.enabled || stage.autoSelect;
|
||||
radio.title = "选择这个方案";
|
||||
radio.addEventListener("change", () => {
|
||||
if (!radio.checked) return;
|
||||
stage.selected = branch.id;
|
||||
notify();
|
||||
});
|
||||
|
||||
const name = element("input", "fb-name-input fb-branch-name");
|
||||
name.type = "text";
|
||||
name.value = branch.name;
|
||||
name.placeholder = `方案 ${branchIndex + 1}`;
|
||||
name.title = "方案名称";
|
||||
name.addEventListener("input", () => {
|
||||
branch.name = name.value;
|
||||
const input = nodeInput(node, inputName);
|
||||
if (input) input.__flowBranchLabel = branch.name;
|
||||
persist();
|
||||
});
|
||||
name.addEventListener("change", () => notify());
|
||||
|
||||
const connected = isConnected(nodeInput(node, inputName));
|
||||
const status = element(
|
||||
"span",
|
||||
`fb-connection-status ${connected ? "fb-is-connected" : "fb-is-unconnected"}`,
|
||||
connected ? "已连接" : "未连接",
|
||||
);
|
||||
status.title = connected ? "方案结果已连接" : "方案结果未连接,将沿用上一阶段";
|
||||
|
||||
const remove = iconButton("×", "删除方案", () => {
|
||||
stage.branches.splice(branchIndex, 1);
|
||||
if (stage.selected === branch.id) stage.selected = null;
|
||||
notify();
|
||||
});
|
||||
remove.classList.add("fb-delete-button");
|
||||
row.append(radio, name, status, remove);
|
||||
rowElements.set(inputName, row);
|
||||
return row;
|
||||
}
|
||||
|
||||
function makeStage(stage, stageIndex, previousName) {
|
||||
const section = element("section", "fb-stage");
|
||||
if (!stage.enabled) section.classList.add("fb-stage-disabled");
|
||||
if (stage.autoSelect) section.classList.add("fb-stage-auto");
|
||||
section.append(
|
||||
makeStageHeader(stage, stageIndex, previousName),
|
||||
makeAutoSelectRow(stage),
|
||||
makeBypassRow(stage),
|
||||
);
|
||||
for (const [branchIndex, branch] of stage.branches.entries()) {
|
||||
section.append(makeBranchRow(stage, branch, branchIndex));
|
||||
}
|
||||
const addBranch = element("button", "fb-add-button fb-add-branch", "+ 添加方案");
|
||||
addBranch.type = "button";
|
||||
addBranch.addEventListener("click", () => {
|
||||
const branch = createBranch(stage.branches.length + 1);
|
||||
stage.branches.push(branch);
|
||||
stage.selected = branch.id;
|
||||
notify();
|
||||
});
|
||||
section.append(addBranch);
|
||||
return section;
|
||||
}
|
||||
|
||||
function renderEditor() {
|
||||
rowElements = new Map();
|
||||
root.replaceChildren();
|
||||
if (config.stages.length === 0) {
|
||||
root.append(element("div", "fb-empty-state", "尚未添加处理阶段"));
|
||||
}
|
||||
let previousName = node.widgets?.find((item) => item.name === "input_channel")?.value || "原始图像";
|
||||
for (const [stageIndex, stage] of config.stages.entries()) {
|
||||
root.append(makeStage(stage, stageIndex, previousName));
|
||||
previousName = stage.name;
|
||||
}
|
||||
const addStage = element("button", "fb-add-button fb-add-stage", "+ 添加阶段");
|
||||
addStage.type = "button";
|
||||
addStage.addEventListener("click", () => {
|
||||
const stage = createStage(config.stages.length + 1);
|
||||
const branch = createBranch(1);
|
||||
stage.branches.push(branch);
|
||||
stage.selected = branch.id;
|
||||
config.stages.push(stage);
|
||||
notify();
|
||||
});
|
||||
root.append(addStage);
|
||||
requestAnimationFrame(fitNode);
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(() => requestAnimationFrame(updateSlotOffsets));
|
||||
observer.observe(root);
|
||||
|
||||
const editor = {
|
||||
get config() {
|
||||
return config;
|
||||
},
|
||||
persist,
|
||||
render: renderEditor,
|
||||
reload() {
|
||||
config = parsePipelineConfig(configWidget.value);
|
||||
syncInputs();
|
||||
renderEditor();
|
||||
},
|
||||
refreshConnections() {
|
||||
if (syncingInputs) return;
|
||||
renderEditor();
|
||||
},
|
||||
updateCanvasPositions,
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
observer.disconnect();
|
||||
},
|
||||
};
|
||||
node.__flowPipelineEditor = editor;
|
||||
syncInputs();
|
||||
renderEditor();
|
||||
return editor;
|
||||
}
|
||||
Reference in New Issue
Block a user