初始化

This commit is contained in:
2026-06-15 09:59:00 +08:00
commit c131f5e7c3
6 changed files with 938 additions and 0 deletions
+588
View File
@@ -0,0 +1,588 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
const NODE_NAME = "Reference Image Manager";
const MAX_ITEMS = 120;
const CONFIG_VALUES = "__rim_config_widgets_values";
function isItemsJson(value) {
return typeof value === "string" && value.trim().startsWith("[");
}
function ensureStyles() {
const id = "reference-image-manager-css";
if (document.getElementById(id)) return;
const link = document.createElement("link");
link.id = id;
link.rel = "stylesheet";
link.href = "extensions/ComfyUI-ReferenceImageManager/reference_image_manager.css";
document.head.append(link);
}
function makeEl(tag, className, text) {
const el = document.createElement(tag);
if (className) el.className = className;
if (text != null) el.textContent = text;
return el;
}
function basename(path) {
return (path || "").split(/[\\/]/).filter(Boolean).pop() || "";
}
function dirname(path) {
const slash = (path || "").includes("\\") ? "\\" : "/";
const parts = (path || "").split(/[\\/]/).filter(Boolean);
parts.pop();
return parts.join(slash);
}
function parseItems(value) {
try {
const items = JSON.parse(value || "[]");
return Array.isArray(items) ? items : [];
} catch {
return [];
}
}
function cleanItems(items) {
const seen = new Set();
return items
.filter((item) => item?.image)
.filter((item) => {
const key = item.id || `${item.image}|${item.original_path || ""}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
})
.slice(0, MAX_ITEMS);
}
function annotatedImageName(upload) {
const prefix = upload.subfolder ? `${upload.subfolder}/` : "";
const type = upload.type && upload.type !== "input" ? ` [${upload.type}]` : "";
return `${prefix}${upload.name}${type}`;
}
function viewUrl(item) {
if (!item?.image) return "";
const filename = item.subfolder ? `${item.subfolder}/${item.name}` : item.name || item.image;
const params = new URLSearchParams({
filename,
type: item.type || "input",
});
return api.apiURL(`/view?${params.toString()}${app.getPreviewFormatParam?.() || ""}`);
}
function getWidget(node, name) {
return node.widgets?.find((w) => w.name === name);
}
function getWidgetValue(node, name) {
return getWidget(node, name)?.value || "";
}
function readConfiguredItems(node, fallbackValues) {
const values = fallbackValues || node?.[CONFIG_VALUES] || node?.widgets_values || [];
return values.find(isItemsJson) || "";
}
function readManagedJson(node, fallbackValues) {
const configuredItems = readConfiguredItems(node, fallbackValues);
if (node?.__rim_config_dirty) {
return configuredItems || getWidgetValue(node, "managed_images") || node?.properties?.rim_items || node?.__rim_state_json || "[]";
}
return (
node?.__rim_state_json ||
getWidgetValue(node, "managed_images") ||
configuredItems ||
node?.properties?.rim_items ||
"[]"
);
}
function readSelectedImage(node, fallbackValues) {
const values = fallbackValues || node?.[CONFIG_VALUES] || node?.widgets_values || [];
if (node?.__rim_config_dirty) {
return values[0] || getWidgetValue(node, "image") || "";
}
return getWidgetValue(node, "image") || values[0] || "";
}
function writeSerializableState(node, workflowNode) {
const existingValues = workflowNode?.widgets_values || node?.[CONFIG_VALUES] || node?.widgets_values || [];
if (!node?.__rim_config_dirty) {
node?.__rim_persist?.();
}
const image = readSelectedImage(node, existingValues);
const json = readManagedJson(node, existingValues);
const selectedId = node?.properties?.rim_selected_id || "";
if (workflowNode) {
workflowNode.widgets_values = [image, json];
if (!workflowNode.properties) workflowNode.properties = {};
workflowNode.properties.rim_items = json;
workflowNode.properties.rim_selected_id = selectedId;
}
if (node) {
node.__rim_state_json = json;
node[CONFIG_VALUES] = [image, json];
if (!node.properties) node.properties = {};
node.properties.rim_items = json;
node.properties.rim_selected_id = selectedId;
}
}
function setNodeProperty(node, name, value) {
if (!node.properties) node.properties = {};
if (typeof node.setProperty === "function") {
node.setProperty(name, value);
} else {
node.properties[name] = value;
}
}
function setWidgetValue(node, name, value) {
const widget = getWidget(node, name);
if (!widget) return;
widget.value = value;
widget.callback?.(value);
}
function hideWidget(node, name) {
const widget = getWidget(node, name);
if (!widget) return;
if (!widget.options) widget.options = {};
widget.options.serialize = true;
widget.hidden = true;
widget.computeSize = () => [0, -4];
widget.serialize = true;
}
function removeWidgetByName(node, name) {
const widget = getWidget(node, name);
if (!widget) return;
widget.onRemove?.();
const index = node.widgets?.indexOf(widget) ?? -1;
if (index >= 0) node.widgets.splice(index, 1);
}
function buildManager(node) {
node.serialize_widgets = true;
removeWidgetByName(node, "upload");
hideWidget(node, "image");
hideWidget(node, "managed_images");
node.imgs = [];
const imageWidget = getWidget(node, "image");
const itemsWidget = getWidget(node, "managed_images");
if (!imageWidget || !itemsWidget) return;
const panel = makeEl("div", "rim-panel");
const fileInput = makeEl("input", "rim-hidden");
fileInput.type = "file";
fileInput.accept = "image/*";
fileInput.multiple = true;
const toolbar = makeEl("div", "rim-toolbar");
const title = makeEl("div", "rim-title", "参考图管理");
const addBtn = makeEl("button", "rim-btn rim-btn-primary", "添加图片");
const replaceBtn = makeEl("button", "rim-btn", "替换当前");
toolbar.append(title, addBtn, replaceBtn);
const preview = makeEl("div", "rim-preview");
const previewImg = document.createElement("img");
const emptyPreview = makeEl("div", "rim-empty", "先添加图片,然后点击缩略图切换输出。");
preview.append(emptyPreview);
const editor = makeEl("div", "rim-editor");
const nameInput = document.createElement("input");
nameInput.placeholder = "显示名称";
const pathInput = document.createElement("input");
pathInput.placeholder = "原始路径";
const folderInput = document.createElement("input");
folderInput.placeholder = "文件夹";
editor.append(nameInput, pathInput, folderInput);
const controls = makeEl("div", "rim-toolbar");
const search = document.createElement("input");
search.className = "rim-search";
search.placeholder = "搜索名称 / 路径 / 文件夹";
const showAllBtn = makeEl("button", "rim-tab rim-tab-active", "全部");
const showFolderBtn = makeEl("button", "rim-tab", "同文件夹");
const showStarBtn = makeEl("button", "rim-tab", "收藏");
controls.append(showAllBtn, showFolderBtn, showStarBtn);
const actions = makeEl("div", "rim-toolbar");
const saveMetaBtn = makeEl("button", "rim-btn", "保存信息");
const starBtn = makeEl("button", "rim-btn", "收藏");
const deleteBtn = makeEl("button", "rim-btn rim-btn-danger", "删除");
const clearBtn = makeEl("button", "rim-btn", "清空列表");
actions.append(saveMetaBtn, starBtn, deleteBtn, clearBtn);
const list = makeEl("div", "rim-list");
panel.append(toolbar, preview, editor, controls, search, actions, list, fileInput);
const configuredValues = node[CONFIG_VALUES] || node.widgets_values || [];
const configuredImage = configuredValues[0] || imageWidget.value || "";
const configuredItems = readConfiguredItems(node, configuredValues);
const storedItems = configuredItems || itemsWidget.value || node.properties?.rim_items || "[]";
let items = cleanItems(parseItems(storedItems));
let selectedId =
node.properties?.rim_selected_id ||
items.find((item) => item.image === configuredImage)?.id ||
items[0]?.id ||
"";
if (configuredImage) imageWidget.value = configuredImage;
itemsWidget.value = JSON.stringify(items);
node.__rim_state_json = itemsWidget.value;
imageWidget.serializeValue = () => imageWidget.value || "";
itemsWidget.serializeValue = () => node.__rim_state_json || itemsWidget.value || "[]";
let filterMode = "all";
let uploadMode = "add";
function selectedItem() {
return items.find((item) => item.id === selectedId) || null;
}
function persist() {
items = cleanItems(items);
const json = JSON.stringify(items);
node.__rim_state_json = json;
node[CONFIG_VALUES] = [imageWidget.value || "", json];
setWidgetValue(node, "managed_images", json);
setNodeProperty(node, "rim_items", json);
setNodeProperty(node, "rim_selected_id", selectedId || "");
}
function loadStateFromWidgets() {
const json = getWidgetValue(node, "managed_images") || node.__rim_state_json || node.properties?.rim_items || "[]";
items = cleanItems(parseItems(json));
node.__rim_state_json = JSON.stringify(items);
node[CONFIG_VALUES] = [imageWidget.value || "", node.__rim_state_json];
node.__rim_config_dirty = false;
selectedId =
node.properties?.rim_selected_id ||
items.find((item) => item.image === imageWidget.value)?.id ||
items[0]?.id ||
"";
if (selectedId) {
const item = items.find((entry) => entry.id === selectedId);
if (item) imageWidget.value = item.image;
}
}
function selectItem(item) {
if (!item) return;
selectedId = item.id;
setWidgetValue(node, "image", item.image);
node.imgs = [];
render();
}
function updateButtons() {
showAllBtn.classList.toggle("rim-tab-active", filterMode === "all");
showFolderBtn.classList.toggle("rim-tab-active", filterMode === "folder");
showStarBtn.classList.toggle("rim-tab-active", filterMode === "star");
}
function renderEditor(item) {
nameInput.value = item?.label || "";
pathInput.value = item?.original_path || "";
folderInput.value = item?.folder || "";
title.textContent = item ? basename(item.label || item.original_path || item.image) : "参考图管理";
}
function renderPreview(item) {
preview.replaceChildren();
if (!item) {
preview.append(emptyPreview);
return;
}
previewImg.src = viewUrl(item);
preview.append(previewImg);
}
function filteredItems() {
const q = search.value.trim().toLowerCase();
const currentFolder = selectedItem()?.folder || "";
return items.filter((item) => {
if (filterMode === "folder" && currentFolder && item.folder !== currentFolder) return false;
if (filterMode === "star" && !item.starred) return false;
if (!q) return true;
return `${item.label} ${item.name} ${item.original_path} ${item.folder}`.toLowerCase().includes(q);
});
}
function renderList() {
const active = selectedItem();
list.replaceChildren();
const visibleItems = filteredItems();
for (const item of visibleItems) {
const card = makeEl("div", `rim-card${item.id === selectedId ? " rim-card-active" : ""}`);
const thumb = makeEl("div", "rim-thumb");
const img = document.createElement("img");
img.loading = "lazy";
img.src = viewUrl(item);
thumb.append(img);
const name = makeEl("div", "rim-name", item.label || basename(item.original_path || item.name || item.image));
const meta = makeEl("div", "rim-meta", item.folder || item.type || "input");
const badge = makeEl("div", "rim-meta", item.starred ? "已收藏" : "点击选择");
card.append(thumb, name, meta, badge);
card.onclick = () => selectItem(item);
list.append(card);
}
if (!visibleItems.length) {
list.append(makeEl("div", "rim-empty", items.length ? "当前筛选没有图片。" : "列表为空。点击“添加图片”创建你的参考图库。"));
}
renderPreview(active);
renderEditor(active);
starBtn.textContent = active?.starred ? "取消收藏" : "收藏";
updateButtons();
}
function render() {
persist();
renderList();
requestAnimationFrame(() => {
node.setSize([Math.max(node.size[0], 460), Math.max(node.size[1], 560)]);
app.graph.setDirtyCanvas(true, true);
});
}
async function uploadFiles(files) {
const selectedBefore = selectedItem();
const uploads = [];
for (const file of files) {
const body = new FormData();
body.append("image", file);
body.append("type", "input");
const response = await api.fetchApi("/upload/image", { method: "POST", body });
if (!response.ok) throw new Error(`Upload failed: ${response.status}`);
const upload = await response.json();
const originalPath = file.path || file.webkitRelativePath || file.name;
uploads.push({
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
image: annotatedImageName(upload),
name: upload.name,
label: basename(originalPath || upload.name),
subfolder: upload.subfolder || "",
type: upload.type || "input",
original_path: originalPath,
folder: dirname(originalPath),
starred: false,
added_at: Date.now(),
});
}
if (!uploads.length) return;
if (uploadMode === "replace" && selectedBefore) {
const index = items.findIndex((item) => item.id === selectedBefore.id);
items.splice(index, 1, uploads[0]);
selectedId = uploads[0].id;
if (uploads.length > 1) items.splice(index + 1, 0, ...uploads.slice(1));
} else {
items.push(...uploads);
selectedId = uploads[0].id;
}
selectItem(items.find((item) => item.id === selectedId));
}
addBtn.onclick = () => {
uploadMode = "add";
fileInput.multiple = true;
fileInput.click();
};
replaceBtn.onclick = () => {
uploadMode = "replace";
fileInput.multiple = false;
fileInput.click();
};
fileInput.onchange = async () => {
try {
await uploadFiles([...fileInput.files]);
} finally {
fileInput.value = "";
}
};
showAllBtn.onclick = () => {
filterMode = "all";
renderList();
};
showFolderBtn.onclick = () => {
filterMode = "folder";
renderList();
};
showStarBtn.onclick = () => {
filterMode = "star";
renderList();
};
search.oninput = renderList;
saveMetaBtn.onclick = () => {
const item = selectedItem();
if (!item) return;
item.label = nameInput.value.trim();
item.original_path = pathInput.value.trim();
item.folder = folderInput.value.trim() || dirname(item.original_path);
render();
};
starBtn.onclick = () => {
const item = selectedItem();
if (!item) return;
item.starred = !item.starred;
render();
};
deleteBtn.onclick = () => {
const item = selectedItem();
if (!item) return;
items = items.filter((entry) => entry.id !== item.id);
selectedId = items[0]?.id || "";
if (selectedId) selectItem(items[0]);
else {
setWidgetValue(node, "image", "");
node.imgs = [];
render();
}
};
clearBtn.onclick = () => {
items = [];
selectedId = "";
setWidgetValue(node, "image", "");
node.imgs = [];
render();
};
const imageCallback = imageWidget.callback;
imageWidget.callback = function () {
imageCallback?.apply(this, arguments);
node.imgs = [];
};
node.__rim_persist = persist;
const widget = node.addDOMWidget("manager", "reference-image-manager", panel, {
getValue() {
return itemsWidget.value;
},
setValue(value) {
const stored = node.properties?.rim_items || value;
items = cleanItems(parseItems(stored));
selectedId =
node.properties?.rim_selected_id ||
items.find((item) => item.image === imageWidget.value)?.id ||
items[0]?.id ||
"";
renderList();
},
serialize: false,
getMinHeight() {
return 470;
},
getMaxHeight() {
return 900;
},
});
widget.serialize = false;
const serialize = node.onSerialize;
node.onSerialize = function (workflowNode) {
if (node.__rim_config_dirty) {
loadStateFromWidgets();
} else {
persist();
}
serialize?.apply(this, arguments);
writeSerializableState(node, workflowNode);
};
node.__rim_reload = () => {
loadStateFromWidgets();
render();
};
loadStateFromWidgets();
render();
}
app.registerExtension({
name: "Comfy.ReferenceImageManager",
init() {
ensureStyles();
},
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== NODE_NAME) return;
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
onNodeCreated?.apply(this, arguments);
buildManager(this);
};
const configure = nodeType.prototype.configure;
nodeType.prototype.configure = function (info) {
this[CONFIG_VALUES] = info?.widgets_values ? [...info.widgets_values] : [];
this.__rim_config_dirty = !!this[CONFIG_VALUES].length;
return configure?.apply(this, arguments);
};
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
onConfigure?.apply(this, arguments);
requestAnimationFrame(() => {
const itemsWidget = getWidget(this, "managed_images");
if (itemsWidget && this[CONFIG_VALUES]?.length) {
const configuredItems = readConfiguredItems(this);
if (configuredItems) {
itemsWidget.value = configuredItems;
this.__rim_state_json = configuredItems;
}
}
this.__rim_reload?.();
});
};
const clone = nodeType.prototype.clone;
nodeType.prototype.clone = function () {
const cloned = clone?.apply(this, arguments);
if (cloned) {
cloned[CONFIG_VALUES] = [
getWidgetValue(this, "image"),
readManagedJson(this),
];
cloned.__rim_state_json = cloned[CONFIG_VALUES][1];
const imageWidget = getWidget(cloned, "image");
const itemsWidget = getWidget(cloned, "managed_images");
if (imageWidget) imageWidget.value = cloned[CONFIG_VALUES][0];
if (itemsWidget) itemsWidget.value = cloned[CONFIG_VALUES][1];
if (!cloned.properties) cloned.properties = {};
cloned.properties.rim_items = cloned[CONFIG_VALUES][1];
cloned.properties.rim_selected_id = this.properties?.rim_selected_id || "";
}
return cloned;
};
const onSerialize = nodeType.prototype.onSerialize;
nodeType.prototype.onSerialize = function (workflowNode) {
onSerialize?.apply(this, arguments);
writeSerializableState(this, workflowNode);
};
const onDrawBackground = nodeType.prototype.onDrawBackground;
nodeType.prototype.onDrawBackground = function () {
const oldImgs = this.imgs;
this.imgs = [];
const result = onDrawBackground?.apply(this, arguments);
this.imgs = oldImgs?.length ? [] : oldImgs;
return result;
};
},
});