528 lines
26 KiB
JavaScript
528 lines
26 KiB
JavaScript
import { app } from "../../scripts/app.js";
|
||
import { isNodes2, sizeDomWidget, refreshWidgets } from "./xb_compat.js";
|
||
|
||
// ============================================================
|
||
// XB_CanvasLabel — 文字标签节点
|
||
// ============================================================
|
||
// 【Nodes 2.0 适配说明】
|
||
// 经典模式:整个节点靠劫持 LGraphCanvas.drawNode + node.draw() 画文字(本文件上半部分,逻辑不变)。
|
||
// Nodes 2.0:节点由 DOM 渲染,drawNode() 会直接 early-return —— 上面那套一根线都画不出来。
|
||
// → 追加一个 DOM widget 承载文字(CSS 做颜色/字号/对齐/背景/圆角/旋转),并把节点底色设为透明,
|
||
// 让它看起来与经典模式一样是“一张贴纸”。
|
||
// onDblClick(画布坐标)在 2.0 不触发 → 双击编辑文字改挂在 DOM 元素上;右键菜单两边都可用。
|
||
|
||
// ---- 1. 劫持 drawNode(隐藏默认边框/标题)----
|
||
const oldDrawNode = LGraphCanvas.prototype.drawNode;
|
||
LGraphCanvas.prototype.drawNode = function (node, ctx) {
|
||
if (node.comfyClass !== "XB_CanvasLabel" && node.type !== "XB_CanvasLabel")
|
||
return oldDrawNode.apply(this, arguments);
|
||
|
||
// ⚠️ Nodes 2.0:文字改由 DOM 贴纸渲染。画布这边**必须一件事都不做**——
|
||
// 旧写法在调用原实现(2.0 会 early-return)后仍然执行 `node.draw(ctx)`,
|
||
// 而 2.0 的画布依旧渲染在 DOM 节点后面 ⇒ 画布文字 + DOM 贴纸重叠 = 用户看到的「两排字/重影」
|
||
// (实测截图:同一行文字前后错位叠了两层)。
|
||
if (isNodes2()) return oldDrawNode.apply(this, arguments);
|
||
|
||
node.bgcolor = node.color = "transparent";
|
||
// ⚠️ 不要再「临时把 node.title 置空」来隐藏标题:前端经典渲染器在标题为空时会**回退绘制
|
||
// `#<节点id> <包名>`**(实测出现 "#122 XB_ToolBox")→ 反而多出一行字。
|
||
// 标题栏统一交给 rgthree 同款 `title_mode = NO_TITLE` 类静态属性(见 beforeRegisterNodeDef)。
|
||
const result = oldDrawNode.apply(this, arguments);
|
||
if (node.draw) node.draw(ctx);
|
||
return result;
|
||
};
|
||
|
||
// ---- 2. Nodes 2.0:DOM 渲染的“贴纸” ----
|
||
// 兜底 CSS:本节点的“节点徽标”一律不显示(2.0 下徽标是 DOM,普通节点上是 `#id 包名`)
|
||
const XB_LABEL_STYLE_ID = "xb-canvas-label-style";
|
||
function ensureXbLabelStyle() {
|
||
if (document.getElementById(XB_LABEL_STYLE_ID)) return;
|
||
const st = document.createElement("style");
|
||
st.id = XB_LABEL_STYLE_ID;
|
||
st.textContent = `[data-xb-label-root] [class*="badge" i]{display:none!important}\n[data-xb-label-root] [class*="node-id" i]{display:none!important}`;
|
||
document.head.appendChild(st);
|
||
}
|
||
|
||
/** 安全取文本:某些环境(内嵌预览 / 禁用弹窗)会直接抛 "prompt() is not supported",不能让它冒出来 */
|
||
function askText(message, dflt) {
|
||
try {
|
||
return prompt(message, dflt);
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 调起系统取色器。只在用户**真的换了颜色**时才回调 ——
|
||
* 旧写法把初始色写在 oninput/onchange 里,光打开取色器然后取消,也会把
|
||
* 「背景 = 黑色」写进节点(默认的透明背景被意外变成不透明,用户报障)。
|
||
*/
|
||
function pickColor(initial, onPick) {
|
||
const inp = document.createElement("input");
|
||
inp.type = "color";
|
||
inp.value = initial;
|
||
inp.style.cssText = "position:fixed;top:50%;left:50%;width:1px;height:1px;opacity:0.01;z-index:99999;";
|
||
document.body.appendChild(inp);
|
||
let changed = false;
|
||
inp.oninput = function () {
|
||
if (this.value === initial) return; // 只是打开取色器,值没变 → 不写回
|
||
changed = true;
|
||
onPick(this.value);
|
||
};
|
||
inp.onchange = function () {
|
||
if (changed || this.value !== initial) onPick(this.value);
|
||
if (document.body.contains(this)) document.body.removeChild(this);
|
||
};
|
||
requestAnimationFrame(() => inp.click());
|
||
}
|
||
|
||
/**
|
||
* 统一的「改文字」入口:右键菜单 / 2.0 双击共用。
|
||
* ⚠️ 写成模块级函数(而不是挂在 node 上):2.0 下 node 对象可能被前端包装/替换,
|
||
* 闭包里拿到的 `node` 和 `onNodeCreated` 里的 `this` 不一定是同一个引用(实测取到过 undefined)。
|
||
*/
|
||
function editLabelTitle(node) {
|
||
if (!node) return;
|
||
const old = node.title || "";
|
||
const val = askText("输入文字(\\n 换行):", old);
|
||
if (val !== null && val !== old) {
|
||
node.title = val || "机智罗";
|
||
node._xbLabelRender?.();
|
||
node.setDirtyCanvas?.(true, true);
|
||
node.graph?.setDirtyCanvas?.(true, true);
|
||
}
|
||
}
|
||
|
||
/** 量文字宽度(与贴纸同字体):Nodes 2.0 下用来把节点/贴纸宽度收口到文字宽度 */
|
||
let _xbMeasureCtx = null;
|
||
function measureTextWidth(fontSize, lines) {
|
||
try {
|
||
_xbMeasureCtx = _xbMeasureCtx || document.createElement("canvas").getContext("2d");
|
||
_xbMeasureCtx.font = fontSize + "px Arial";
|
||
let maxW = 1;
|
||
for (const s of lines) maxW = Math.max(maxW, _xbMeasureCtx.measureText(s || " ").width);
|
||
return maxW;
|
||
} catch (_) {
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
function installDomLabel(node) {
|
||
ensureXbLabelStyle();
|
||
const el = document.createElement("div");
|
||
el.dataset.xbLabel = "1";
|
||
el.style.cssText = [
|
||
"box-sizing:border-box",
|
||
"white-space:pre",
|
||
"display:inline-block",
|
||
"width:max-content",
|
||
"max-width:100%",
|
||
"transform-origin:center center",
|
||
"user-select:none",
|
||
].join(";");
|
||
|
||
const w = node.addDOMWidget("xb_label_view", "xb_label_view", el, {
|
||
serialize: false,
|
||
hideOnZoom: false,
|
||
getMinHeight: () => 40,
|
||
});
|
||
sizeDomWidget(node, w, 40);
|
||
node.__xbLabelDom = w;
|
||
node.__xbLabelEl = el;
|
||
|
||
// ── 鼠标穿透:Nodes 2.0 的节点拖拽由 Vue 转发(useNodePointerInteractions),
|
||
// 但只要 pointerdown 落在 widget 行上就不再拖 —— 本节点整块都是贴纸 ⇒ 完全拖不动。
|
||
// 把贴纸与官方 WidgetDOM 行 wrapper 都设成 pointer-events:none,事件直接命中节点根元素
|
||
// ⇒ 抓哪里都能拖(与原画布版手感一致),双击则改用节点根元素上的监听(见 bindRootDblClick)。
|
||
const applyPassthrough = () => {
|
||
try {
|
||
el.style.pointerEvents = "none";
|
||
const wrap = el.parentElement;
|
||
if (wrap && wrap.style.pointerEvents !== "none") wrap.style.pointerEvents = "none";
|
||
} catch (_) { /* ignore */ }
|
||
};
|
||
applyPassthrough();
|
||
|
||
const clamp = (v, lo, hi, dflt) => {
|
||
const n = Number(v);
|
||
return Number.isFinite(n) ? Math.max(lo, Math.min(hi, n)) : dflt;
|
||
};
|
||
|
||
// Nodes 2.0 的 DOM 规则:节点根高度 = node.size[1] + 固定余量,贴纸(本元素)又比根小一圈。
|
||
// 只按字号估 node.size 会让「文字横向溢出节点」「贴纸比文字高一截(背景大出一圈)」。
|
||
// 这里按文字内容的实测尺寸与贴纸尺寸的差值,把 node.size 收敛到贴纸正好包住文字(两拍即稳定)。
|
||
const fitNodeToText = () => {
|
||
try {
|
||
if (!node.__xbLabelDom || !node.size || !el.isConnected) return;
|
||
const rng = document.createRange();
|
||
rng.selectNodeContents(el);
|
||
const rect = rng.getBoundingClientRect();
|
||
if (!rect || !rect.width) return;
|
||
const p = node.properties || {};
|
||
const fontSize = clamp(p.fontSize, 1, 800, 40);
|
||
const pad = Math.max(0, Number(p.padding) || 0);
|
||
const lineCount = Math.max(1, String(node.title ?? "").replace(/\\n/g, "\n").split("\n").length);
|
||
// ⚠️ getBoundingClientRect 是屏幕像素(含画布缩放),offsetWidth/offsetHeight 是布局像素,
|
||
// 两者不能直接相减:高倍缩放下差值会被反复累加 → 节点宽度失控膨胀。
|
||
const scale = Number(app?.canvas?.ds?.scale) || 1;
|
||
const needW = Math.ceil(rect.width / scale) + pad * 2;
|
||
const needH = Math.ceil(Math.max(rect.height / scale, fontSize * 1.15 * lineCount)) + pad * 2;
|
||
const dw = needW - el.offsetWidth;
|
||
const dh = needH - el.offsetHeight;
|
||
if (Math.abs(dw) <= 1 && Math.abs(dh) <= 1) return;
|
||
const curW = Math.round(node.size[0] || 0);
|
||
const curH = Math.round(node.size[1] || 0);
|
||
const newW = Math.max(120, curW + Math.round(dw));
|
||
const newH = Math.max(24, curH + Math.round(dh));
|
||
if (newW === curW && newH === curH) return;
|
||
node.setSize?.([newW, newH]);
|
||
} catch (_) { /* ignore */ }
|
||
};
|
||
|
||
const render = () => {
|
||
const p = node.properties || {};
|
||
const fontSize = clamp(p.fontSize, 1, 800, 40);
|
||
const pad = Math.max(0, Number(p.padding) || 0);
|
||
const raw = String(node.title ?? "").replace(/\\n/g, "\n").replace(/\n*$/, "");
|
||
const lines = raw.length === 0 ? [""] : raw.split("\n");
|
||
const angle = parseInt(p.angle) || 0;
|
||
|
||
el.textContent = lines.join("\n") || " ";
|
||
el.style.font = fontSize + "px Arial";
|
||
el.style.lineHeight = "1.15";
|
||
el.style.color = p.fontColor || "#ffffff";
|
||
el.style.textAlign = p.textAlign || "left";
|
||
el.style.padding = pad + "px";
|
||
el.style.background =
|
||
p.backgroundColor && p.backgroundColor !== "transparent" ? p.backgroundColor : "transparent";
|
||
el.style.borderRadius = clamp(p.borderRadius, 0, 400, 0) + "px";
|
||
el.style.transform = angle ? `rotate(${angle}deg)` : "";
|
||
|
||
// 让节点尺寸跟着文字走(Nodes 2.0 下贴纸尺寸由节点尺寸 + 前端固定余量决定):
|
||
// · 宽 = 文字宽 + 内边距 + 节点左右留白(24)
|
||
// · 高 = 行高总和 + 内边距 − 前端固定余量(贴纸高 = 节点高 + 10)
|
||
// 这两组余量是实测值,前端改版后由挂载后的 fitNodeToText() 按真实 DOM 再收口。
|
||
try {
|
||
const maxW = measureTextWidth(fontSize, lines);
|
||
const textW = maxW > 0 ? maxW : Math.max(1, (node.size?.[0] || 320) - pad * 2 - 24);
|
||
const textH = fontSize * 1.15 * lines.length;
|
||
node.setSize?.([
|
||
Math.max(120, Math.round(textW + pad * 2 + 24)),
|
||
Math.max(24, Math.round(textH + pad * 2 - 10)),
|
||
]);
|
||
} catch (_) { /* ignore */ }
|
||
try {
|
||
if (node.__xbLabelDom) {
|
||
requestAnimationFrame(fitNodeToText);
|
||
setTimeout(fitNodeToText, 60);
|
||
setTimeout(fitNodeToText, 300);
|
||
}
|
||
} catch (_) { /* ignore */ }
|
||
};
|
||
|
||
node._xbLabelRender = render;
|
||
node._xbLabelPassthrough = applyPassthrough;
|
||
render();
|
||
|
||
// 2.0:给节点根元素打标记(Vue 重建 DOM 后需要重打)→ 配合上面的 CSS 关掉徽标
|
||
// 同时把「双击编辑文字」挂到根元素上:贴纸已 pointer-events:none,dblclick 只会落在根元素。
|
||
const bindRoot = () => {
|
||
try {
|
||
const root = el.closest?.("[data-node-id]") || node.domElement?.closest?.("[data-node-id]");
|
||
if (!root) return;
|
||
root.setAttribute("data-xb-label-root", "1");
|
||
if (!root.__xbLabelDbl) {
|
||
root.__xbLabelDbl = true;
|
||
root.addEventListener("dblclick", (e) => {
|
||
if (!node.__xbLabelDom) return; // 已切回经典模式
|
||
// ⚠️ 只判事件目标本身:ComfyUI 画布容器的祖先链上有 contenteditable 元素,
|
||
// 用 closest() 会一路匹配到它 → 双击永远被当成"正在输入"而吞掉。
|
||
const t = e.target;
|
||
const tag = (t?.tagName || "").toUpperCase();
|
||
if (tag === "INPUT" || tag === "TEXTAREA" || t?.isContentEditable) return;
|
||
e.stopPropagation();
|
||
editLabelTitle(node);
|
||
});
|
||
}
|
||
} catch (_) { /* ignore */ }
|
||
};
|
||
bindRoot();
|
||
[60, 300, 1000].forEach((t) => setTimeout(bindRoot, t));
|
||
|
||
// 属性面板改 @fontSize / 通过其它入口改 properties 时没有统一回调 → 轻量轮询兜底(只在变化时重绘)
|
||
let sig = "";
|
||
node._xbLabelTick = () => {
|
||
const p = node.properties || {};
|
||
const s = [node.title, p.fontSize, p.fontColor, p.textAlign, p.backgroundColor, p.padding, p.borderRadius, p.angle].join("|");
|
||
if (s !== sig) { sig = s; render(); }
|
||
fitNodeToText(); // Vue 晚挂载 / 重建 DOM 后,按真实 DOM 再收口一次
|
||
applyPassthrough(); // Vue 重建行 wrapper 后要重新打穿透
|
||
bindRoot();
|
||
};
|
||
}
|
||
|
||
/** 拆掉 DOM 贴纸(切回经典模式时用):widget 一并摘掉,否则画布文字 + DOM 贴纸 = 双层文字 */
|
||
function uninstallDomLabel(node) {
|
||
const el = node.__xbLabelEl;
|
||
const w = node.__xbLabelDom;
|
||
node.__xbLabelDom = null;
|
||
node.__xbLabelEl = null;
|
||
node._xbLabelRender = null;
|
||
node._xbLabelTick = null;
|
||
node._xbLabelPassthrough = null;
|
||
try {
|
||
if (w && Array.isArray(node.widgets)) {
|
||
node.widgets = node.widgets.filter((x) => x !== w && !(x && x.name === "xb_label_view"));
|
||
}
|
||
} catch (_) { /* ignore */ }
|
||
try { el?.remove(); } catch (_) { /* ignore */ }
|
||
// 切回经典时官方渲染器会把 DOM 部件重新挂到画布容器里 ⇒ 残留元素必须一起清掉,
|
||
// 否则画布文字 + 残留贴纸 = 用户说的「双层文字」。
|
||
try {
|
||
document.querySelectorAll("[data-xb-label]").forEach((e) => {
|
||
const root = e.closest?.("[data-node-id]");
|
||
if (!root || root.getAttribute("data-node-id") === String(node.id)) e.remove();
|
||
});
|
||
} catch (_) { /* ignore */ }
|
||
try {
|
||
refreshWidgets(node);
|
||
node.setDirtyCanvas?.(true, true);
|
||
node.graph?.setDirtyCanvas?.(true, true);
|
||
} catch (_) { /* ignore */ }
|
||
}
|
||
|
||
/**
|
||
* 按当前渲染模式装配/拆除 DOM 贴纸。
|
||
* ⚠️ 用户可以在设置里**运行时**切换 Nodes 2.0(前端不刷新页面):
|
||
* · 2.0 → 经典:DOM 贴纸会残留在画布上,而经典渲染又照常画一遍文字 ⇒ 两层字(用户报障);
|
||
* · 经典 → 2.0:onNodeCreated 时没建过 DOM 贴纸,画布这边又 early-return ⇒ 文字直接消失,
|
||
* 只有刷新页面(重建节点)才恢复(用户报障)。
|
||
* 所以模式判定不能只在创建时做一次,必须每次 tick 都对一遍。
|
||
*/
|
||
function syncLabelMode(node, { immediate = false } = {}) {
|
||
const want = isNodes2();
|
||
// 设置项是异步回写服务器的,切换瞬间可能读到来回跳的值 → 要求模式稳定两拍再动手,
|
||
// 否则会出现「刚拆掉又装上」→ 旧 DOM 元素被官方 WidgetLegacy 重新挂回画布 = 又变成两层字。
|
||
if (!immediate && node.__xbLabelWant !== want) {
|
||
node.__xbLabelWant = want;
|
||
return;
|
||
}
|
||
node.__xbLabelWant = want;
|
||
if (want) {
|
||
if (!node.__xbLabelDom) installDomLabel(node);
|
||
node.color = node.bgcolor = "transparent";
|
||
} else {
|
||
if (node.__xbLabelDom) uninstallDomLabel(node);
|
||
node.color = node.bgcolor = "#fff0";
|
||
}
|
||
}
|
||
|
||
// ---- 3. 注册扩展 ----
|
||
app.registerExtension({
|
||
name: "XB_ToolBox.CanvasLabel",
|
||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||
if (nodeData.name !== "XB_CanvasLabel") return;
|
||
|
||
nodeData.title_mode = LiteGraph.NO_TITLE;
|
||
nodeData.collapsable = false;
|
||
// ✅ 照抄 rgthree-comfy 的 Label(web/comfyui/label.js 结尾 `Label.title_mode = LiteGraph.NO_TITLE`):
|
||
// title_mode 必须是**类静态属性**才会被 LiteGraph / 前端读取。
|
||
// ⚠️ 只写 nodeData.title_mode 在 Nodes 2.0 不生效(实测:标题栏照样渲染,
|
||
// 于是「节点名一行 + 贴纸文字一行」= 用户看到的双重文字);写成静态后标题行高度 66 → 0。
|
||
nodeType.title_mode = LiteGraph.NO_TITLE;
|
||
nodeType.collapsable = false;
|
||
|
||
// 属性面板定义(颜色不设 @ → 不会在画布上生成输入框)
|
||
nodeData["@fontSize"] = { type: "number", default: 40 };
|
||
nodeData["@textAlign"] = { type: "combo", values: ["left", "center", "right"], default: "left" };
|
||
nodeData["@padding"] = { type: "number", default: 0 };
|
||
nodeData["@borderRadius"] = { type: "number", default: 0 };
|
||
nodeData["@angle"] = { type: "number", default: 0 };
|
||
|
||
// ---- onNodeCreated ----
|
||
const origOnNodeCreated = nodeType.prototype.onNodeCreated;
|
||
nodeType.prototype.onNodeCreated = function () {
|
||
origOnNodeCreated?.apply(this, arguments);
|
||
|
||
const p = this.properties = this.properties || {};
|
||
p.fontSize = p.fontSize ?? 40;
|
||
p.fontColor = p.fontColor ?? "#ffffff";
|
||
p.textAlign = p.textAlign ?? "left";
|
||
p.backgroundColor = p.backgroundColor ?? "transparent";
|
||
p.padding = p.padding ?? 0;
|
||
p.borderRadius = p.borderRadius ?? 0;
|
||
p.angle = p.angle ?? 0;
|
||
|
||
// 2.0 会用「节点显示名」预填 title → 贴纸文字跟着变成节点名(与标题栏重影)。
|
||
// 只在「看起来仍是默认名」时换成默认文案(用户自己改过的文字不动)。
|
||
const defaultTitles = [nodeData.title, nodeData.display_name, nodeData.name, this.type].filter(Boolean);
|
||
if (!this.title || defaultTitles.includes(this.title) || /Canvas\s*Label|文字标签/.test(String(this.title))) {
|
||
this.title = "机智罗";
|
||
}
|
||
// 照抄 rgthree:贴纸不可拉伸(尺寸由文字排版自动定),透明色用 8 位 hex 写法
|
||
this.resizable = false;
|
||
this.color = this.bgcolor = "#fff0";
|
||
this.size = [320, 80];
|
||
|
||
const self = this;
|
||
|
||
// 2.0:节点本体透明 + DOM 贴纸承载内容(经典模式:画布自己画,不建 DOM 贴纸)
|
||
syncLabelMode(this, { immediate: true });
|
||
|
||
// 运行时切换「Nodes 2.0」不会重建节点 → 每次 tick 核对一次模式(300ms,开关注释见 syncLabelMode)
|
||
this._xbLabelTimer = setInterval(() => {
|
||
syncLabelMode(self);
|
||
self._xbLabelTick?.();
|
||
}, 300);
|
||
|
||
const origRemoved = this.onRemoved;
|
||
this.onRemoved = function () {
|
||
try { clearInterval(self._xbLabelTimer); } catch (_) { /* ignore */ }
|
||
uninstallDomLabel(self);
|
||
return origRemoved?.apply(this, arguments);
|
||
};
|
||
};
|
||
|
||
// ---- draw():自定义绘制(仅经典模式会被调用)----
|
||
nodeType.prototype.draw = function (ctx) {
|
||
if (!this.flags) this.flags = {};
|
||
this.flags.allow_interaction = !this.flags.pinned;
|
||
ctx.save();
|
||
|
||
const p = this.properties || {};
|
||
const fontSize = Math.max(1, Math.min(800, +p.fontSize || 40));
|
||
const fontColor = p.fontColor || "#ffffff";
|
||
const textAlign = p.textAlign || "left";
|
||
const bgColor = p.backgroundColor || "";
|
||
const pad = +p.padding || 0;
|
||
const borderRadius = +p.borderRadius || 0;
|
||
const angleDeg = parseInt(p.angle) || 0;
|
||
|
||
ctx.font = fontSize + "px Arial";
|
||
|
||
const raw = (this.title || "").replace(/\\n/g, "\n").replace(/\n*$/, "");
|
||
const lines = raw.length === 0 ? [""] : raw.split("\n");
|
||
|
||
let maxW = 1;
|
||
lines.forEach(s => { const w = ctx.measureText(s).width; if (w > maxW) maxW = w; });
|
||
this.size[0] = maxW + pad * 2;
|
||
this.size[1] = fontSize * lines.length + pad * 2;
|
||
|
||
if (angleDeg) {
|
||
const cx = this.size[0] / 2, cy = this.size[1] / 2;
|
||
ctx.translate(cx, cy);
|
||
ctx.rotate((angleDeg * Math.PI) / 180);
|
||
ctx.translate(-cx, -cy);
|
||
}
|
||
|
||
if (bgColor && bgColor !== "transparent") {
|
||
ctx.fillStyle = bgColor;
|
||
ctx.beginPath();
|
||
if (ctx.roundRect) {
|
||
ctx.roundRect(0, 0, this.size[0], this.size[1], [borderRadius]);
|
||
} else if (borderRadius > 0) {
|
||
const r = borderRadius, w = this.size[0], h = this.size[1];
|
||
ctx.moveTo(r, 0); ctx.lineTo(w - r, 0);
|
||
ctx.quadraticCurveTo(w, 0, w, r); ctx.lineTo(w, h - r);
|
||
ctx.quadraticCurveTo(w, h, w - r, h); ctx.lineTo(r, h);
|
||
ctx.quadraticCurveTo(0, h, 0, h - r); ctx.lineTo(0, r);
|
||
ctx.quadraticCurveTo(0, 0, r, 0);
|
||
} else {
|
||
ctx.rect(0, 0, this.size[0], this.size[1]);
|
||
}
|
||
ctx.fill();
|
||
}
|
||
|
||
ctx.fillStyle = fontColor;
|
||
ctx.textBaseline = "top";
|
||
ctx.textAlign = textAlign;
|
||
|
||
let tx = textAlign === "center" ? this.size[0] / 2 : textAlign === "right" ? this.size[0] - pad : pad;
|
||
lines.forEach((line, i) => ctx.fillText(line || " ", tx, pad + i * fontSize));
|
||
|
||
ctx.restore();
|
||
};
|
||
|
||
// ---- 关掉「节点徽标」(经典模式)----
|
||
// 经典模式下前端会给**每个**节点画徽标(`ComfyNode.drawBadges` → `LGraphBadge`),内容是
|
||
// `#<节点id> <来源/包名>`,实测本节点是 "#127 XB_ToolBox"。普通节点上它贴在标题栏里看不出问题,
|
||
// 但在透明贴纸上就是凭空多出来的一行“节点名称”(用户报障点)→ 本类型直接不画。
|
||
// 2.0 下徽标是 DOM,用 `[data-xb-label-root]` + CSS 兜底(见 ensureXbLabelStyle)。
|
||
nodeType.prototype.drawBadges = function () {};
|
||
|
||
// ---- 双击 → 属性面板(经典模式;2.0 由 DOM 元素上的 dblclick 处理文字编辑)----
|
||
// 照抄 rgthree Label 的 `inResizeCorner() { return this.resizable; }`:贴纸不可拉伸
|
||
nodeType.prototype.inResizeCorner = function () { return false; };
|
||
const origDblClick = nodeType.prototype.onDblClick;
|
||
nodeType.prototype.onDblClick = function (e, pos, gc) {
|
||
if (origDblClick) { const r = origDblClick.apply(this, arguments); if (r !== undefined && r !== false) return r; }
|
||
LGraphCanvas.active_canvas.showShowNodePanel(this);
|
||
return true;
|
||
};
|
||
|
||
// ---- 右键菜单 ----
|
||
const origMenu = nodeType.prototype.getExtraMenuOptions;
|
||
nodeType.prototype.getExtraMenuOptions = function (_, options) {
|
||
origMenu?.apply(this, arguments);
|
||
const self = this;
|
||
const refresh = () => {
|
||
self._xbLabelRender?.();
|
||
self.setDirtyCanvas(true, true);
|
||
app.graph.setDirtyCanvas(true, false);
|
||
};
|
||
options.unshift(
|
||
{ content: "✏️ 编辑文字", callback: () => { editLabelTitle(self); } },
|
||
// 2.0 下节点标题栏被隐藏(照抄 rgthree 的 title_mode),属性面板在右键菜单里也没有入口
|
||
// → 标签的排版参数直接放进右键菜单,两个模式都能改
|
||
{ content: "🔤 字号…", callback: () => {
|
||
const cur = Number(self.properties.fontSize) || 40;
|
||
const val = askText("字号(1~800):", String(cur));
|
||
const num = Number(val);
|
||
if (val !== null && Number.isFinite(num)) { self.properties.fontSize = Math.max(1, Math.min(800, Math.round(num))); refresh(); }
|
||
}},
|
||
{ content: "↔️ 切换对齐(左→中→右)", callback: () => {
|
||
const order = ["left", "center", "right"];
|
||
const i = order.indexOf(self.properties.textAlign || "left");
|
||
self.properties.textAlign = order[(i + 1) % order.length];
|
||
refresh();
|
||
}},
|
||
{ content: "📐 内边距…", callback: () => {
|
||
const cur = Number(self.properties.padding) || 0;
|
||
const val = askText("内边距 px(0~400):", String(cur));
|
||
const num = Number(val);
|
||
if (val !== null && Number.isFinite(num)) { self.properties.padding = Math.max(0, Math.min(400, Math.round(num))); refresh(); }
|
||
}},
|
||
{ content: "⭕ 圆角…", callback: () => {
|
||
const cur = Number(self.properties.borderRadius) || 0;
|
||
const val = askText("圆角 px(0~400):", String(cur));
|
||
const num = Number(val);
|
||
if (val !== null && Number.isFinite(num)) { self.properties.borderRadius = Math.max(0, Math.min(400, Math.round(num))); refresh(); }
|
||
}},
|
||
{ content: "🔄 旋转角度…", callback: () => {
|
||
const cur = Number(self.properties.angle) || 0;
|
||
const val = askText("旋转角度(度,负值=逆时针):", String(cur));
|
||
const num = Number(val);
|
||
if (val !== null && Number.isFinite(num)) { self.properties.angle = Math.round(num); refresh(); }
|
||
}},
|
||
{ content: "🎨 字体颜色…", callback: () => {
|
||
pickColor(self.properties.fontColor || "#ffffff", (v) => {
|
||
self.properties.fontColor = v;
|
||
refresh();
|
||
});
|
||
}},
|
||
{ content: "🖌️ 背景颜色…", callback: () => {
|
||
const cur = self.properties.backgroundColor || "transparent";
|
||
pickColor(cur === "transparent" ? "#000000" : cur, (v) => {
|
||
self.properties.backgroundColor = v;
|
||
refresh();
|
||
});
|
||
}},
|
||
{ content: "🧽 背景恢复透明", callback: () => {
|
||
self.properties.backgroundColor = "transparent";
|
||
refresh();
|
||
}}
|
||
);
|
||
};
|
||
},
|
||
});
|