feat: 新增 Grok 图像节点、前端 UI 增强、重构 nano-banana 系列
- 新增 Grok Image 节点及客户端 - 新增 save_image_format 节点 - 新增前端 JS 扩展:画笔工具、点阵网格、侧边栏隐藏、资源切换、重命名等 - 重构 nano-banana 节点,移除 pro 版本 - 移除 multi_res_preview 节点 - 新增 http_error 工具模块 - 各客户端和节点优化改进 Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { api } from "../../../scripts/api.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "o1key.assetToggle",
|
||||
settings: [
|
||||
{
|
||||
id: "o1key.AssetSave",
|
||||
name: "资产保存",
|
||||
tooltip: "持久性保存生图记录",
|
||||
type: "boolean",
|
||||
defaultValue: true,
|
||||
},
|
||||
],
|
||||
async init() {
|
||||
const enabled = () => {
|
||||
try {
|
||||
return app.ui.settings.getSettingValue("o1key.AssetSave", true);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// --- Patch getHistory: 合并真实 jobs 与持久化历史 ---
|
||||
const _origGetHistory = api.getHistory.bind(api);
|
||||
api.getHistory = async function (maxItems = 200, opts = {}) {
|
||||
const real = await _origGetHistory(maxItems, opts);
|
||||
if (!enabled()) return real;
|
||||
try {
|
||||
const offset = opts?.offset || 0;
|
||||
const resp = await fetch(
|
||||
`/o1key/output_history?limit=${maxItems}&offset=${offset}`
|
||||
);
|
||||
if (!resp.ok) return real;
|
||||
const data = await resp.json();
|
||||
const persisted = data.jobs || [];
|
||||
if (!persisted.length) return real;
|
||||
if (!real || !Array.isArray(real) || !real.length) {
|
||||
// 仅持久化数据时也补充 priority
|
||||
const t = data.pagination?.total || persisted.length;
|
||||
return persisted.map((j, i) => ({
|
||||
...j,
|
||||
priority: j.priority ?? t - i,
|
||||
}));
|
||||
}
|
||||
// 合并去重:以 id 为 key,真实 jobs 优先
|
||||
const seen = new Set(real.map((j) => j.id));
|
||||
const merged = [...real];
|
||||
for (const job of persisted) {
|
||||
if (!seen.has(job.id)) {
|
||||
merged.push(job);
|
||||
}
|
||||
}
|
||||
// 按时间倒序
|
||||
merged.sort(
|
||||
(a, b) => (b.create_time || 0) - (a.create_time || 0)
|
||||
);
|
||||
const result = merged.slice(0, maxItems);
|
||||
// 补充 priority 字段(队列面板依赖此字段排序)
|
||||
const total = data.pagination?.total || result.length;
|
||||
for (let idx = 0; idx < result.length; idx++) {
|
||||
if (result[idx].priority == null) {
|
||||
result[idx] = {
|
||||
...result[idx],
|
||||
priority: total - idx,
|
||||
};
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
return real;
|
||||
}
|
||||
};
|
||||
|
||||
// --- Patch getJobDetail: 真实 API 失败时回退到本地路由 ---
|
||||
const _origGetJobDetail = api.getJobDetail.bind(api);
|
||||
api.getJobDetail = async function (jobId) {
|
||||
const real = await _origGetJobDetail(jobId);
|
||||
if (real) return real;
|
||||
if (!enabled()) return undefined;
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/o1key/job_detail/${encodeURIComponent(jobId)}`
|
||||
);
|
||||
if (!resp.ok) return undefined;
|
||||
return await resp.json();
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { app } from "../../../scripts/app.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "o1key.dotGrid",
|
||||
async setup() {
|
||||
function createBlackTile() {
|
||||
const size = 64;
|
||||
const c = document.createElement("canvas");
|
||||
c.width = size;
|
||||
c.height = size;
|
||||
const ctx = c.getContext("2d");
|
||||
ctx.fillStyle = "#1a1a1a";
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
return c;
|
||||
}
|
||||
|
||||
// Hook immediately so the first draw already uses our tile
|
||||
const orig = LGraphCanvas.prototype.drawBackCanvas;
|
||||
LGraphCanvas.prototype.drawBackCanvas = function () {
|
||||
if (!this._pattern || !this._pattern_img) {
|
||||
const ctx = this.bgcanvas?.getContext("2d");
|
||||
if (ctx) {
|
||||
const t = createBlackTile();
|
||||
this._pattern = ctx.createPattern(t, "repeat");
|
||||
this._pattern_img = t;
|
||||
}
|
||||
}
|
||||
return orig.apply(this, arguments);
|
||||
};
|
||||
|
||||
// Also apply to current canvas instance if already exists
|
||||
const canvas = app.canvas;
|
||||
if (canvas?.bgcanvas) {
|
||||
const bgCtx = canvas.bgcanvas.getContext("2d");
|
||||
const tile = createBlackTile();
|
||||
canvas._pattern = bgCtx.createPattern(tile, "repeat");
|
||||
canvas._pattern_img = tile;
|
||||
canvas.draw(true, true);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import { app } from "../../../scripts/app.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "o1key.hideSidebarItems",
|
||||
async setup() {
|
||||
const hide = () => {
|
||||
// 隐藏侧边栏的"说明"、"应用"、"模型"、"节点"按钮
|
||||
const hiddenLabels = ["说明", "帮助", "Help", "应用", "Apps", "模型", "Models", "节点", "Nodes"];
|
||||
document.querySelectorAll(".side-bar-button, [class*='sidebar'] button, .p-togglebutton").forEach(btn => {
|
||||
const label = btn.getAttribute("aria-label") || btn.textContent || "";
|
||||
if (hiddenLabels.some(k => label.includes(k))) {
|
||||
btn.style.display = "none";
|
||||
}
|
||||
});
|
||||
// 隐藏左上角下拉菜单中的"帮助"项
|
||||
document.querySelectorAll(".p-menuitem, .p-menu-item, [class*='menu'] li, [class*='Menu'] li").forEach(item => {
|
||||
const text = item.textContent || "";
|
||||
if (text.trim() === "帮助" || text.trim() === "Help") {
|
||||
item.style.display = "none";
|
||||
}
|
||||
});
|
||||
// 隐藏登录/注册弹框(Google/Github 登录对话框)
|
||||
document.querySelectorAll("[class*='dialog'], [class*='Dialog'], [class*='modal'], [class*='Modal']").forEach(dialog => {
|
||||
const text = dialog.textContent || "";
|
||||
if ((text.includes("Google") || text.includes("Github")) &&
|
||||
(text.includes("登录") || text.includes("注册"))) {
|
||||
dialog.style.display = "none";
|
||||
const mask = dialog.previousElementSibling;
|
||||
if (mask && mask.className && mask.className.includes("mask")) {
|
||||
mask.style.display = "none";
|
||||
}
|
||||
}
|
||||
});
|
||||
// 在右侧内容区隐藏"登录/注册"按钮并注入 API Key
|
||||
injectApiKeyPanel();
|
||||
};
|
||||
|
||||
async function injectApiKeyPanel() {
|
||||
// 找到右侧内容区中包含"我的用户设置"的区域
|
||||
let contentArea = null;
|
||||
document.querySelectorAll("h1, h2, h3, h4, span, div").forEach(el => {
|
||||
const t = (el.textContent || "").trim();
|
||||
if (t === "我的用户设置" || t === "My User Settings") {
|
||||
contentArea = el.closest("div");
|
||||
}
|
||||
});
|
||||
if (!contentArea) return;
|
||||
|
||||
// 隐藏"登录/注册"按钮和"登录您的账户"文字
|
||||
contentArea.querySelectorAll("button, a, span, p, div").forEach(el => {
|
||||
const t = (el.textContent || "").trim();
|
||||
if (t.includes("登录") || t.includes("注册") || t === "Sign In" || t === "Sign Up" || t.includes("登录您的账户") || t.includes("Log in")) {
|
||||
if (el.tagName === "BUTTON" || el.tagName === "A" || t.includes("登录您的账户")) {
|
||||
el.style.display = "none";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 检查是否已注入(DOM 中已存在则跳过)
|
||||
if (document.querySelector("#o1key-apikey-box")) return;
|
||||
|
||||
// 创建 API Key 输入区域
|
||||
const box = document.createElement("div");
|
||||
box.id = "o1key-apikey-box";
|
||||
box.style.cssText = "margin-top:24px;padding:20px;border:1px solid #444;border-radius:8px;background:#1e1e1e;";
|
||||
box.innerHTML = `
|
||||
<div style="font-weight:bold;font-size:15px;margin-bottom:6px;color:#eee;">O1Key API 密钥</div>
|
||||
<div style="font-size:12px;color:#999;margin-bottom:14px;">输入您的 API 密钥,测试通过后方可保存</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<input id="o1key-apikey-input" type="text" placeholder="请输入 API 密钥"
|
||||
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
|
||||
data-form-type="other" data-lpignore="true" name="o1key-key-field"
|
||||
style="flex:1;padding:8px 12px;border:1px solid #555;border-radius:4px;background:#111;color:#eee;font-size:13px;" />
|
||||
<button id="o1key-apikey-test"
|
||||
style="padding:8px 14px;border:none;border-radius:4px;background:#47a;color:#fff;cursor:pointer;font-size:13px;white-space:nowrap;">测试令牌</button>
|
||||
<button id="o1key-apikey-save" disabled
|
||||
style="padding:8px 14px;border:none;border-radius:4px;background:#555;color:#999;cursor:not-allowed;font-size:13px;white-space:nowrap;">保存</button>
|
||||
<button id="o1key-apikey-clear"
|
||||
style="padding:8px 14px;border:none;border-radius:4px;background:#a44;color:#fff;cursor:pointer;font-size:13px;white-space:nowrap;">清空密钥</button>
|
||||
</div>
|
||||
<div id="o1key-apikey-status" style="margin-top:10px;font-size:12px;color:#999;"></div>
|
||||
`;
|
||||
contentArea.appendChild(box);
|
||||
|
||||
// 加载当前状态
|
||||
try {
|
||||
const resp = await fetch("/o1key/api_key");
|
||||
const data = await resp.json();
|
||||
const status = box.querySelector("#o1key-apikey-status");
|
||||
if (data.has_key) {
|
||||
status.textContent = "当前密钥: " + data.masked;
|
||||
status.style.color = "#3b8";
|
||||
} else {
|
||||
status.textContent = "尚未配置 API 密钥";
|
||||
status.style.color = "#a84";
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
const saveBtn = box.querySelector("#o1key-apikey-save");
|
||||
const testBtn = box.querySelector("#o1key-apikey-test");
|
||||
let testPassed = false;
|
||||
|
||||
// 输入变化时重置测试状态
|
||||
box.querySelector("#o1key-apikey-input").addEventListener("input", () => {
|
||||
testPassed = false;
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.style.background = "#555";
|
||||
saveBtn.style.color = "#999";
|
||||
saveBtn.style.cursor = "not-allowed";
|
||||
});
|
||||
|
||||
// 测试令牌按钮
|
||||
testBtn.addEventListener("click", async () => {
|
||||
const input = box.querySelector("#o1key-apikey-input");
|
||||
const status = box.querySelector("#o1key-apikey-status");
|
||||
const key = input.value.trim();
|
||||
if (!key) { status.textContent = "请输入密钥"; status.style.color = "#a44"; return; }
|
||||
testBtn.disabled = true;
|
||||
testBtn.textContent = "验证中...";
|
||||
status.textContent = "正在验证密钥...";
|
||||
status.style.color = "#999";
|
||||
try {
|
||||
const resp = await fetch("/o1key/test_key", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({api_key: key})
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.valid) {
|
||||
testPassed = true;
|
||||
status.textContent = "验证通过,可以保存";
|
||||
status.style.color = "#3b8";
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.style.background = "#3b8";
|
||||
saveBtn.style.color = "#fff";
|
||||
saveBtn.style.cursor = "pointer";
|
||||
} else {
|
||||
testPassed = false;
|
||||
status.textContent = data.error || "验证失败";
|
||||
status.style.color = "#a44";
|
||||
}
|
||||
} catch (e) {
|
||||
status.textContent = "网络错误";
|
||||
status.style.color = "#a44";
|
||||
}
|
||||
testBtn.disabled = false;
|
||||
testBtn.textContent = "测试令牌";
|
||||
});
|
||||
|
||||
// 保存按钮(仅测试通过后可用)
|
||||
saveBtn.addEventListener("click", async () => {
|
||||
if (!testPassed) return;
|
||||
const input = box.querySelector("#o1key-apikey-input");
|
||||
const status = box.querySelector("#o1key-apikey-status");
|
||||
const key = input.value.trim();
|
||||
try {
|
||||
const resp = await fetch("/o1key/api_key", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({api_key: key})
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
status.textContent = "密钥已保存";
|
||||
status.style.color = "#3b8";
|
||||
input.value = "";
|
||||
testPassed = false;
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.style.background = "#555";
|
||||
saveBtn.style.color = "#999";
|
||||
saveBtn.style.cursor = "not-allowed";
|
||||
} else {
|
||||
status.textContent = data.error || "保存失败";
|
||||
status.style.color = "#a44";
|
||||
}
|
||||
} catch (e) {
|
||||
status.textContent = "网络错误";
|
||||
status.style.color = "#a44";
|
||||
}
|
||||
});
|
||||
|
||||
// 清空密钥按钮
|
||||
const clearBtn = box.querySelector("#o1key-apikey-clear");
|
||||
clearBtn.addEventListener("click", async () => {
|
||||
if (!confirm("确定要清空 API 密钥吗?")) return;
|
||||
const status = box.querySelector("#o1key-apikey-status");
|
||||
try {
|
||||
const resp = await fetch("/o1key/api_key", { method: "DELETE" });
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
status.textContent = "API 密钥已清空";
|
||||
status.style.color = "#a84";
|
||||
box.querySelector("#o1key-apikey-input").value = "";
|
||||
testPassed = false;
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.style.background = "#555";
|
||||
saveBtn.style.color = "#999";
|
||||
saveBtn.style.cursor = "not-allowed";
|
||||
} else {
|
||||
status.textContent = data.error || "清空失败";
|
||||
status.style.color = "#a44";
|
||||
}
|
||||
} catch (e) {
|
||||
status.textContent = "网络错误";
|
||||
status.style.color = "#a44";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(hide);
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
setTimeout(hide, 1000);
|
||||
setTimeout(hide, 3000);
|
||||
},
|
||||
});
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,483 @@
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { api } from "../../../scripts/api.js";
|
||||
|
||||
// Fabric.js local loader
|
||||
let fabricLoaded = false;
|
||||
function loadFabric() {
|
||||
if (fabricLoaded) return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement("script");
|
||||
// Load from local extension directory (allowed by CSP 'self')
|
||||
script.src = new URL("./lib/fabric.min.js", import.meta.url).href;
|
||||
script.onload = () => { fabricLoaded = true; resolve(); };
|
||||
script.onerror = () => reject(new Error("Failed to load Fabric.js"));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Styles ---
|
||||
const STYLES = `
|
||||
.pb-overlay { position:fixed; inset:0; z-index:99999; background:rgba(0,0,0,0.85); display:flex; flex-direction:column; align-items:center; justify-content:center; }
|
||||
.pb-toolbar { display:flex; gap:6px; padding:10px 16px; background:#1e1e1e; border-radius:8px; margin-bottom:10px; align-items:center; flex-wrap:wrap; }
|
||||
.pb-toolbar button { background:#333; color:#eee; border:1px solid #555; border-radius:4px; padding:6px 12px; cursor:pointer; font-size:13px; transition:all .15s; }
|
||||
.pb-toolbar button:hover { background:#444; }
|
||||
.pb-toolbar button.active { background:#0066ff; border-color:#0066ff; color:#fff; }
|
||||
.pb-toolbar .pb-sep { width:1px; height:24px; background:#555; margin:0 4px; }
|
||||
.pb-toolbar input[type=color] { width:32px; height:28px; border:none; padding:0; cursor:pointer; border-radius:4px; }
|
||||
.pb-toolbar input[type=range] { width:80px; accent-color:#0066ff; }
|
||||
.pb-toolbar .pb-label { color:#aaa; font-size:12px; }
|
||||
.pb-canvas-wrap { border:2px solid #444; border-radius:4px; overflow:hidden; }
|
||||
.pb-actions { display:flex; gap:10px; margin-top:10px; }
|
||||
.pb-actions button { padding:8px 24px; border-radius:6px; font-size:14px; cursor:pointer; border:none; }
|
||||
.pb-actions .pb-cancel { background:#555; color:#eee; }
|
||||
.pb-actions .pb-confirm { background:#0066ff; color:#fff; }
|
||||
`;
|
||||
|
||||
function injectStyles() {
|
||||
if (document.getElementById("pb-styles")) return;
|
||||
const el = document.createElement("style");
|
||||
el.id = "pb-styles";
|
||||
el.textContent = STYLES;
|
||||
document.head.appendChild(el);
|
||||
}
|
||||
|
||||
// --- Get current image URL from node ---
|
||||
function getImageUrl(node) {
|
||||
if (node.imgs && node.imgs.length > 0) {
|
||||
return node.imgs[node.imageIndex ?? 0].src;
|
||||
}
|
||||
const widget = node.widgets?.find(w => w.name === "image");
|
||||
if (!widget?.value) return null;
|
||||
const val = String(widget.value);
|
||||
const match = val.match(/^(.+?)(?:\s*\[(\w+)\])?$/);
|
||||
if (!match) return null;
|
||||
const filename = match[1];
|
||||
const type = match[2] || "input";
|
||||
const parts = filename.split("/");
|
||||
const name = parts.pop();
|
||||
const subfolder = parts.join("/");
|
||||
return `/view?filename=${encodeURIComponent(name)}&type=${type}&subfolder=${encodeURIComponent(subfolder)}`;
|
||||
}
|
||||
|
||||
// --- History Manager ---
|
||||
class HistoryManager {
|
||||
constructor(canvas) {
|
||||
this.canvas = canvas;
|
||||
this.stack = [];
|
||||
this.index = -1;
|
||||
this.locked = false;
|
||||
}
|
||||
save() {
|
||||
if (this.locked) return;
|
||||
this.index++;
|
||||
this.stack.length = this.index;
|
||||
this.stack.push(this.canvas.toJSON());
|
||||
}
|
||||
undo() {
|
||||
if (this.index <= 0) return;
|
||||
this.index--;
|
||||
this._restore();
|
||||
}
|
||||
redo() {
|
||||
if (this.index >= this.stack.length - 1) return;
|
||||
this.index++;
|
||||
this._restore();
|
||||
}
|
||||
_restore() {
|
||||
this.locked = true;
|
||||
this.canvas.loadFromJSON(this.stack[this.index], () => {
|
||||
this.canvas.renderAll();
|
||||
this.locked = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Shape drawing handler ---
|
||||
function setupShapeDrawing(canvas, state) {
|
||||
let startX, startY, shape;
|
||||
|
||||
canvas.on("mouse:down", (opt) => {
|
||||
if (state.tool === "select" || state.tool === "brush") return;
|
||||
const ptr = canvas.getPointer(opt.e);
|
||||
startX = ptr.x;
|
||||
startY = ptr.y;
|
||||
state.drawing = true;
|
||||
|
||||
const opts = { left: startX, top: startY, fill: "transparent", stroke: state.color, strokeWidth: state.width, selectable: true };
|
||||
|
||||
if (state.tool === "rect") {
|
||||
shape = new fabric.Rect({ ...opts, width: 0, height: 0 });
|
||||
} else if (state.tool === "circle") {
|
||||
shape = new fabric.Ellipse({ ...opts, rx: 0, ry: 0 });
|
||||
} else if (state.tool === "line") {
|
||||
shape = new fabric.Line([startX, startY, startX, startY], { stroke: state.color, strokeWidth: state.width, selectable: true });
|
||||
}
|
||||
if (shape) canvas.add(shape);
|
||||
});
|
||||
|
||||
canvas.on("mouse:move", (opt) => {
|
||||
if (!state.drawing || !shape) return;
|
||||
const ptr = canvas.getPointer(opt.e);
|
||||
const dx = ptr.x - startX;
|
||||
const dy = ptr.y - startY;
|
||||
|
||||
if (state.tool === "rect") {
|
||||
shape.set({ left: dx > 0 ? startX : ptr.x, top: dy > 0 ? startY : ptr.y, width: Math.abs(dx), height: Math.abs(dy) });
|
||||
} else if (state.tool === "circle") {
|
||||
shape.set({ left: dx > 0 ? startX : ptr.x, top: dy > 0 ? startY : ptr.y, rx: Math.abs(dx) / 2, ry: Math.abs(dy) / 2 });
|
||||
} else if (state.tool === "line") {
|
||||
shape.set({ x2: ptr.x, y2: ptr.y });
|
||||
}
|
||||
canvas.renderAll();
|
||||
});
|
||||
|
||||
canvas.on("mouse:up", () => {
|
||||
if (!state.drawing) return;
|
||||
state.drawing = false;
|
||||
shape = null;
|
||||
});
|
||||
}
|
||||
|
||||
// --- Open Paint Modal ---
|
||||
async function openPaintModal(node) {
|
||||
injectStyles();
|
||||
await loadFabric();
|
||||
|
||||
if (!node.properties) node.properties = {};
|
||||
|
||||
// Detect if user switched to a different image — reset saved state
|
||||
const widget = node.widgets?.find(w => w.name === "image");
|
||||
const currentVal = widget?.value ? String(widget.value) : "";
|
||||
const originalImg = node.properties.paintBrushOriginal;
|
||||
if (originalImg && currentVal !== originalImg && currentVal !== "painted_" + originalImg) {
|
||||
// Image changed, clear old paint state
|
||||
delete node.properties.paintBrushCanvas;
|
||||
delete node.properties.paintBrushOriginal;
|
||||
}
|
||||
|
||||
const savedState = node.properties.paintBrushCanvas;
|
||||
const storedOriginal = node.properties.paintBrushOriginal;
|
||||
|
||||
// If re-editing, use the original image as background; otherwise use current
|
||||
let bgUrl;
|
||||
if (savedState && storedOriginal) {
|
||||
bgUrl = `/view?filename=${encodeURIComponent(storedOriginal)}&type=input&subfolder=`;
|
||||
} else {
|
||||
bgUrl = getImageUrl(node);
|
||||
}
|
||||
if (!bgUrl) { alert("请先加载一张图片"); return; }
|
||||
|
||||
// Save original image name on first paint
|
||||
if (!storedOriginal) {
|
||||
node.properties.paintBrushOriginal = currentVal;
|
||||
}
|
||||
|
||||
// Create overlay
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "pb-overlay";
|
||||
|
||||
const maxW = window.innerWidth * 0.8;
|
||||
const maxH = window.innerHeight * 0.75;
|
||||
|
||||
// Load image to get dimensions
|
||||
const img = await new Promise((resolve, reject) => {
|
||||
const i = new Image();
|
||||
i.crossOrigin = "anonymous";
|
||||
i.onload = () => resolve(i);
|
||||
i.onerror = () => reject(new Error("图片加载失败"));
|
||||
i.src = bgUrl;
|
||||
});
|
||||
|
||||
const scale = Math.min(maxW / img.width, maxH / img.height, 1);
|
||||
const cw = Math.round(img.width * scale);
|
||||
const ch = Math.round(img.height * scale);
|
||||
|
||||
const state = { tool: "brush", color: "#ff0000", width: 4, drawing: false };
|
||||
|
||||
const toolbar = buildToolbar(state);
|
||||
overlay.appendChild(toolbar);
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "pb-canvas-wrap";
|
||||
const canvasEl = document.createElement("canvas");
|
||||
canvasEl.width = cw;
|
||||
canvasEl.height = ch;
|
||||
wrap.appendChild(canvasEl);
|
||||
overlay.appendChild(wrap);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// Init Fabric canvas
|
||||
const canvas = new fabric.Canvas(canvasEl, { width: cw, height: ch, isDrawingMode: true });
|
||||
canvas.freeDrawingBrush.color = state.color;
|
||||
canvas.freeDrawingBrush.width = state.width;
|
||||
|
||||
// Set background image (always the original)
|
||||
await new Promise(resolve => {
|
||||
canvas.setBackgroundImage(bgUrl, () => { canvas.renderAll(); resolve(); }, {
|
||||
scaleX: cw / img.width, scaleY: ch / img.height, crossOrigin: "anonymous"
|
||||
});
|
||||
});
|
||||
|
||||
// Restore previous drawing objects if re-editing
|
||||
if (savedState) {
|
||||
await new Promise(resolve => {
|
||||
canvas.loadFromJSON(savedState, () => {
|
||||
// Re-apply background since loadFromJSON may clear it
|
||||
canvas.setBackgroundImage(bgUrl, () => { canvas.renderAll(); resolve(); }, {
|
||||
scaleX: cw / img.width, scaleY: ch / img.height, crossOrigin: "anonymous"
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// History
|
||||
const history = new HistoryManager(canvas);
|
||||
setTimeout(() => history.save(), 300);
|
||||
canvas.on("object:added", () => history.save());
|
||||
canvas.on("object:modified", () => history.save());
|
||||
|
||||
// Shape drawing
|
||||
setupShapeDrawing(canvas, state);
|
||||
wireToolbar(toolbar, canvas, state, history);
|
||||
|
||||
// Actions buttons
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "pb-actions";
|
||||
actions.innerHTML = `<button class="pb-cancel">取消</button><button class="pb-confirm">确认</button>`;
|
||||
overlay.appendChild(actions);
|
||||
|
||||
const close = () => { canvas.dispose(); overlay.remove(); };
|
||||
actions.querySelector(".pb-cancel").onclick = close;
|
||||
overlay.addEventListener("keydown", (e) => { if (e.key === "Escape") close(); });
|
||||
overlay.tabIndex = 0;
|
||||
overlay.focus();
|
||||
|
||||
// Keyboard shortcuts
|
||||
overlay.addEventListener("keydown", (e) => {
|
||||
if (e.ctrlKey && e.key === "z" && !e.shiftKey) { e.preventDefault(); history.undo(); }
|
||||
if (e.ctrlKey && (e.key === "Z" || (e.key === "z" && e.shiftKey))) { e.preventDefault(); history.redo(); }
|
||||
if (e.key === "Delete" || e.key === "Backspace") {
|
||||
const active = canvas.getActiveObject();
|
||||
if (active) { canvas.remove(active); canvas.discardActiveObject(); history.save(); }
|
||||
}
|
||||
});
|
||||
|
||||
// Confirm - save painted image and store canvas state for re-editing
|
||||
actions.querySelector(".pb-confirm").onclick = async () => {
|
||||
// Save canvas objects (without background) for future re-editing
|
||||
node.properties.paintBrushCanvas = canvas.toJSON();
|
||||
node.graph?.change?.();
|
||||
await savePaintedImage(canvas, node, img.width, img.height);
|
||||
close();
|
||||
};
|
||||
}
|
||||
|
||||
// --- Save painted image ---
|
||||
async function savePaintedImage(canvas, node, origW, origH) {
|
||||
// Export at original resolution
|
||||
const exportCanvas = document.createElement("canvas");
|
||||
exportCanvas.width = origW;
|
||||
exportCanvas.height = origH;
|
||||
const ctx = exportCanvas.getContext("2d");
|
||||
|
||||
const dataUrl = canvas.toDataURL({ format: "png", multiplier: origW / canvas.width });
|
||||
const exportImg = await new Promise((resolve) => {
|
||||
const i = new Image();
|
||||
i.onload = () => resolve(i);
|
||||
i.src = dataUrl;
|
||||
});
|
||||
ctx.drawImage(exportImg, 0, 0, origW, origH);
|
||||
|
||||
// Get original filename for naming (use stored original, not current painted name)
|
||||
const origName = (node.properties.paintBrushOriginal || "").split("/").pop() || "image.png";
|
||||
const paintedName = "painted_" + origName;
|
||||
|
||||
const blob = await new Promise(r => exportCanvas.toBlob(r, "image/png"));
|
||||
const formData = new FormData();
|
||||
formData.append("image", blob, paintedName);
|
||||
formData.append("type", "input");
|
||||
formData.append("overwrite", "true");
|
||||
|
||||
const resp = await api.fetchApi("/upload/image", { method: "POST", body: formData });
|
||||
const data = await resp.json();
|
||||
|
||||
// Add to combo options so the value persists across refresh
|
||||
const widget = node.widgets.find(w => w.name === "image");
|
||||
if (widget) {
|
||||
if (Array.isArray(widget.options?.values) && !widget.options.values.includes(data.name)) {
|
||||
widget.options.values.push(data.name);
|
||||
}
|
||||
widget.value = data.name;
|
||||
if (widget.callback) {
|
||||
widget.callback(data.name);
|
||||
}
|
||||
}
|
||||
// Mark graph as changed to trigger workflow auto-save
|
||||
node.graph?.change?.();
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
}
|
||||
|
||||
// --- Build Toolbar ---
|
||||
function buildToolbar(state) {
|
||||
const toolbar = document.createElement("div");
|
||||
toolbar.className = "pb-toolbar";
|
||||
toolbar.innerHTML = `
|
||||
<button data-tool="brush" class="active">画笔</button>
|
||||
<button data-tool="rect">矩形</button>
|
||||
<button data-tool="circle">圆形</button>
|
||||
<button data-tool="line">直线</button>
|
||||
<button data-tool="eraser">橡皮擦</button>
|
||||
<span class="pb-sep"></span>
|
||||
<span class="pb-label">颜色</span>
|
||||
<input type="color" class="pb-color" value="${state.color}">
|
||||
<span class="pb-label">线宽</span>
|
||||
<input type="range" class="pb-width" min="1" max="40" value="${state.width}">
|
||||
<span class="pb-sep"></span>
|
||||
<button data-action="undo">撤回</button>
|
||||
<button data-action="clear">清空</button>
|
||||
`;
|
||||
return toolbar;
|
||||
}
|
||||
|
||||
// --- Wire Toolbar ---
|
||||
function wireToolbar(toolbar, canvas, state, history) {
|
||||
// Tool buttons
|
||||
toolbar.querySelectorAll("[data-tool]").forEach(btn => {
|
||||
btn.onclick = () => {
|
||||
toolbar.querySelectorAll("[data-tool]").forEach(b => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
state.tool = btn.dataset.tool;
|
||||
|
||||
if (state.tool === "brush") {
|
||||
canvas.isDrawingMode = true;
|
||||
canvas.selection = false;
|
||||
canvas.freeDrawingBrush.color = state.color;
|
||||
canvas.freeDrawingBrush.width = state.width;
|
||||
} else if (state.tool === "eraser") {
|
||||
// Eraser: click on object to delete it
|
||||
canvas.isDrawingMode = false;
|
||||
canvas.selection = true;
|
||||
canvas.defaultCursor = "crosshair";
|
||||
canvas.hoverCursor = "pointer";
|
||||
} else {
|
||||
canvas.isDrawingMode = false;
|
||||
canvas.selection = false;
|
||||
canvas.defaultCursor = "default";
|
||||
canvas.hoverCursor = "move";
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Eraser: remove object on click
|
||||
canvas.on("mouse:down", (opt) => {
|
||||
if (state.tool !== "eraser") return;
|
||||
const target = canvas.findTarget(opt.e);
|
||||
if (target) {
|
||||
canvas.remove(target);
|
||||
canvas.discardActiveObject();
|
||||
canvas.renderAll();
|
||||
history.save();
|
||||
}
|
||||
});
|
||||
|
||||
// Color picker
|
||||
toolbar.querySelector(".pb-color").oninput = (e) => {
|
||||
state.color = e.target.value;
|
||||
if (canvas.isDrawingMode) {
|
||||
canvas.freeDrawingBrush.color = state.color;
|
||||
}
|
||||
};
|
||||
|
||||
// Width slider
|
||||
toolbar.querySelector(".pb-width").oninput = (e) => {
|
||||
state.width = parseInt(e.target.value);
|
||||
if (canvas.isDrawingMode) {
|
||||
canvas.freeDrawingBrush.width = state.width;
|
||||
}
|
||||
};
|
||||
|
||||
// Action buttons
|
||||
toolbar.querySelector("[data-action=undo]").onclick = () => history.undo();
|
||||
toolbar.querySelector("[data-action=clear]").onclick = () => {
|
||||
canvas.getObjects().forEach(obj => canvas.remove(obj));
|
||||
canvas.renderAll();
|
||||
history.save();
|
||||
};
|
||||
}
|
||||
|
||||
// --- Extension Registration ---
|
||||
app.registerExtension({
|
||||
name: "o1key.paintBrush",
|
||||
|
||||
// Register command for toolbar button
|
||||
commands: [
|
||||
{
|
||||
id: "o1key.PaintBrush",
|
||||
icon: "pi pi-pencil",
|
||||
label: "画笔",
|
||||
tooltip: "画笔",
|
||||
function: () => {
|
||||
const selectedNodes = app.canvas.selected_nodes;
|
||||
const node = selectedNodes ? Object.values(selectedNodes)[0] : null;
|
||||
if (node) openPaintModal(node);
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
// Add title attribute to our toolbar button for native tooltip
|
||||
// Hide "节点信息" button and reorder paint brush next to mask editor
|
||||
setup() {
|
||||
const observer = new MutationObserver(() => {
|
||||
const toolbox = document.querySelector('[class*="selection-toolbox"], [class*="SelectionToolbox"]');
|
||||
if (!toolbox) return;
|
||||
|
||||
// Hide "节点信息" button (matches by aria-label or title)
|
||||
toolbox.querySelectorAll("button").forEach(btn => {
|
||||
const label = btn.title || btn.getAttribute("aria-label") || btn.textContent || "";
|
||||
if (label.includes("节点信息") || label.includes("Node Info") || label.includes("Info")) {
|
||||
btn.style.display = "none";
|
||||
}
|
||||
});
|
||||
|
||||
// Find our paint brush button and move it next to mask editor
|
||||
const pencilIcon = toolbox.querySelector('[class*="pi-pencil"]');
|
||||
if (pencilIcon) {
|
||||
const paintBtn = pencilIcon.closest("button");
|
||||
if (paintBtn && !paintBtn.title) paintBtn.title = "画笔";
|
||||
|
||||
// Find mask editor button (has mask/pen-tool icon)
|
||||
const allBtns = Array.from(toolbox.querySelectorAll("button"));
|
||||
const maskBtn = allBtns.find(b => {
|
||||
const cls = b.innerHTML || "";
|
||||
return cls.includes("mask") || cls.includes("pen-tool") || cls.includes("Mask");
|
||||
}) || allBtns[0];
|
||||
|
||||
if (maskBtn && paintBtn && paintBtn.previousElementSibling !== maskBtn) {
|
||||
maskBtn.after(paintBtn);
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
},
|
||||
|
||||
// Show button in toolbar when LoadImage node is selected
|
||||
getSelectionToolboxCommands(item) {
|
||||
if (item?.comfyClass === "LoadImage" || item?.type === "LoadImage") {
|
||||
return ["o1key.PaintBrush"];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
|
||||
beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name !== "LoadImage") return;
|
||||
|
||||
const origMenu = nodeType.prototype.getExtraMenuOptions;
|
||||
nodeType.prototype.getExtraMenuOptions = function (canvasRef, options) {
|
||||
origMenu?.call(this, canvasRef, options);
|
||||
options.unshift({
|
||||
content: "画笔 (Paint)",
|
||||
callback: () => openPaintModal(this)
|
||||
});
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { app } from "../../../scripts/app.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "o1key.renameConsole",
|
||||
async setup() {
|
||||
const rename = () => {
|
||||
document.querySelectorAll(".side-bar-button, [class*='sidebar'] button, .p-togglebutton, button").forEach(btn => {
|
||||
const label = btn.getAttribute("aria-label") || "";
|
||||
const text = btn.textContent || "";
|
||||
if (label === "控制台" || label === "Console" || text.trim() === "控制台" || text.trim() === "Console") {
|
||||
if (label === "控制台" || label === "Console") {
|
||||
btn.setAttribute("aria-label", "日志");
|
||||
}
|
||||
const span = btn.querySelector("span");
|
||||
if (span && (span.textContent.trim() === "控制台" || span.textContent.trim() === "Console")) {
|
||||
span.textContent = "日志";
|
||||
} else if (!span && (btn.textContent.trim() === "控制台" || btn.textContent.trim() === "Console")) {
|
||||
btn.textContent = "日志";
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const observer = new MutationObserver(rename);
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
setTimeout(rename, 1000);
|
||||
setTimeout(rename, 3000);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { app } from "../../../scripts/app.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "o1key.renameTab",
|
||||
async setup() {
|
||||
const rename = () => {
|
||||
if (document.title.includes("ComfyUI")) {
|
||||
document.title = document.title.replace("ComfyUI", "o1key");
|
||||
}
|
||||
};
|
||||
|
||||
rename();
|
||||
new MutationObserver(rename).observe(
|
||||
document.querySelector("title") || document.head,
|
||||
{ childList: true, subtree: true, characterData: true }
|
||||
);
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user