237 lines
12 KiB
JavaScript
237 lines
12 KiB
JavaScript
import { app } from "../../scripts/app.js";
|
|
|
|
app.registerExtension({
|
|
name: "phazei.MULTI_BUTTON",
|
|
getCustomWidgets() {
|
|
return {
|
|
MULTI_BUTTON: (node, name, data) => {
|
|
// normalize options
|
|
const opts = data?.options || (Array.isArray(data) ? (data[1] || {}) : (data && typeof data === "object" ? data : {}));
|
|
const btns = Array.isArray(opts.buttons) && opts.buttons.length ? opts.buttons : [{ label: "Action", callback: () => { } }];
|
|
const H = Math.max(22, opts.height ?? 28);
|
|
const pad = Math.max(0, opts.pad ?? 6);
|
|
const gap = Math.max(0, opts.gap ?? 6);
|
|
const alternate = opts.alternate ?? true;
|
|
const selectMode = opts.selectMode ?? null; // "single" | "multi" | null
|
|
const selectedInit = Array.isArray(opts.selected) ? new Set(opts.selected) : new Set();
|
|
|
|
// theme keys available in your build
|
|
const T = (LiteGraph?.Themes?.current || LiteGraph);
|
|
const colors = {
|
|
bg: T.WIDGET_BGCOLOR || "#2b2b2b",
|
|
fg: T.WIDGET_TEXT_COLOR || "#DDD",
|
|
border: T.WIDGET_OUTLINE_COLOR || "#666",
|
|
hi: T.WIDGET_ADVANCED_OUTLINE_COLOR || T.WIDGET_OUTLINE_COLOR || "#888",
|
|
fontSize: T.NODE_TEXT_SIZE || 12,
|
|
};
|
|
|
|
// tiny shade for alternating
|
|
const hexToRgb = (hex) => {
|
|
const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
|
if (!m) return { r: 34, g: 34, b: 34 };
|
|
return { r: parseInt(m[1], 16), g: parseInt(m[2], 16), b: parseInt(m[3], 16) };
|
|
};
|
|
const rgbToHex = (r, g, b) => `#${[r, g, b].map(v => Math.max(0, Math.min(255, v | 0)).toString(16).padStart(2, "0")).join("")}`;
|
|
const lighten = (hex, p = 0.06) => { const { r, g, b } = hexToRgb(hex); const L = c => c + (255 - c) * p; return rgbToHex(L(r), L(g), L(b)); };
|
|
const darken = (hex, p = 0.06) => { const { r, g, b } = hexToRgb(hex); const D = c => c * (1 - p); return rgbToHex(D(r), D(g), D(b)); };
|
|
const { r: _r, g: _g, b: _b } = hexToRgb(colors.bg);
|
|
const lum = (0.2126 * _r + 0.7152 * _g + 0.0722 * _b) / 255;
|
|
const bgAlt = lum < 0.5 ? lighten(colors.bg, 0.06) : darken(colors.bg, 0.06);
|
|
|
|
const state = { regions: [], pressed: -1, selected: selectedInit };
|
|
|
|
// Redraw helper: setDirtyCanvas for LiteGraph, triggerDraw for Vue (Nodes 2.0).
|
|
// WidgetLegacy.vue sets widget.triggerDraw when binding; it's undefined in LG mode.
|
|
const redraw = () => {
|
|
node.setDirtyCanvas(true, true);
|
|
widget?.triggerDraw?.();
|
|
};
|
|
|
|
const rr = (ctx, x, y, w, h, r = 6) => {
|
|
ctx.beginPath();
|
|
ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.quadraticCurveTo(x + w, y, x + w, y + r);
|
|
ctx.lineTo(x + w, y + h - r); ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
|
ctx.lineTo(x + r, y + h); ctx.quadraticCurveTo(x, y + h, x, y + h - r);
|
|
ctx.lineTo(x, y + r); ctx.quadraticCurveTo(x, y, x + r, y); ctx.closePath();
|
|
};
|
|
|
|
// expose a few controls
|
|
const api = {
|
|
setDisabled(idx, val) { if (btns[idx]) { btns[idx].disabled = !!val; redraw(); } },
|
|
setSelected(idxOrArr, val) {
|
|
if (Array.isArray(idxOrArr)) { state.selected = new Set(idxOrArr); }
|
|
else { if (val) state.selected.add(idxOrArr); else state.selected.delete(idxOrArr); }
|
|
redraw();
|
|
opts.onSelect?.(Array.from(state.selected));
|
|
},
|
|
getSelected() { return Array.from(state.selected); },
|
|
setButtons(newBtns) {
|
|
if (!Array.isArray(newBtns) || !newBtns.length) return;
|
|
// mutate in place so external refs remain valid
|
|
btns.length = 0; for (const b of newBtns) btns.push(b);
|
|
// clamp selections
|
|
state.selected.forEach(i => { if (i < 0 || i >= btns.length) state.selected.delete(i); });
|
|
redraw();
|
|
},
|
|
update() { redraw(); },
|
|
buttons: btns, // convenience reference
|
|
};
|
|
|
|
const widget = {
|
|
name,
|
|
type: "custom",
|
|
serialize: false,
|
|
|
|
// single row; height fixed
|
|
computeSize: (w) => [w, H],
|
|
|
|
draw(ctx, node, width, y) {
|
|
ctx.save();
|
|
|
|
const n = Math.max(1, btns.length);
|
|
const inner = Math.max(0, width - pad * 2);
|
|
const totalGaps = Math.max(0, gap * (n - 1));
|
|
const bw = (inner > totalGaps ? (inner - totalGaps) : inner) / n;
|
|
const bh = H - 6;
|
|
const y0 = y + 3;
|
|
|
|
state.regions = [];
|
|
|
|
ctx.font = `${colors.fontSize}px sans-serif`;
|
|
ctx.textAlign = "center";
|
|
ctx.textBaseline = "middle";
|
|
|
|
for (let i = 0; i < n; i++) {
|
|
const x = pad + i * (bw + gap);
|
|
state.regions.push({ x, y: y0, w: bw, h: bh });
|
|
|
|
const disabled = !!btns[i]?.disabled;
|
|
const isSel = state.selected.has(i);
|
|
|
|
// fill
|
|
rr(ctx, x, y0, bw, bh, 6);
|
|
ctx.save();
|
|
ctx.fillStyle = alternate ? (i % 2 ? bgAlt : colors.bg) : colors.bg;
|
|
if (disabled) ctx.globalAlpha = 0.5;
|
|
ctx.fill();
|
|
ctx.restore();
|
|
|
|
// selected overlay (subtle)
|
|
if (isSel) {
|
|
ctx.save();
|
|
ctx.globalAlpha = 0.18;
|
|
ctx.fillStyle = colors.hi;
|
|
rr(ctx, x, y0, bw, bh, 6);
|
|
ctx.fill();
|
|
ctx.restore();
|
|
}
|
|
|
|
// normal border
|
|
ctx.save();
|
|
ctx.lineWidth = 1;
|
|
ctx.strokeStyle = colors.border;
|
|
rr(ctx, x, y0, bw, bh, 6);
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
|
|
// pressed border
|
|
if (state.pressed === i) {
|
|
ctx.save();
|
|
ctx.lineWidth = 2;
|
|
ctx.strokeStyle = colors.hi;
|
|
rr(ctx, x, y0, bw, bh, 6);
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
|
|
// label
|
|
ctx.save();
|
|
ctx.fillStyle = colors.fg;
|
|
if (disabled) ctx.globalAlpha = 0.6;
|
|
ctx.beginPath(); ctx.rect(x + 4, y0, Math.max(0, bw - 8), bh); ctx.clip();
|
|
ctx.fillText(btns[i]?.label ?? `B${i + 1}`, x + bw / 2, y0 + bh / 2);
|
|
ctx.restore();
|
|
}
|
|
|
|
ctx.restore();
|
|
},
|
|
|
|
// supports (event, x, y) and (event, [x,y])
|
|
mouse(event, a, b) {
|
|
const lx = Array.isArray(a) ? a[0] : a;
|
|
const ly = Array.isArray(a) ? a[1] : b;
|
|
const hit = state.regions.findIndex(r => lx >= r.x && lx <= r.x + r.w && ly >= r.y && ly <= r.y + r.h);
|
|
|
|
if (event.type === "pointerdown") {
|
|
if (hit !== -1 && !btns[hit]?.disabled) {
|
|
state.pressed = hit;
|
|
redraw();
|
|
return true;
|
|
}
|
|
}
|
|
if (event.type === "pointerup") {
|
|
const i = state.pressed;
|
|
state.pressed = -1;
|
|
|
|
// In the Vue renderer (Nodes 2.0), WidgetLegacy wraps this widget
|
|
// in a mini-canvas. The pointerup coordinates arrive in node-local
|
|
// space (offset by title bar + widgets above), while state.regions
|
|
// are in mini-canvas-local space. This makes the hit-test fail.
|
|
// Fix: if we recorded a valid press on pointerdown (where coords
|
|
// ARE correct), use that index directly instead of re-hit-testing.
|
|
// Fall back to hit-test when available (works in LiteGraph mode).
|
|
const target = (hit !== -1) ? hit : i;
|
|
if (i !== -1 && target === i && !btns[i]?.disabled) {
|
|
|
|
// confirm (optional) -> action -> selection
|
|
const b = btns[i];
|
|
let go = true;
|
|
if (b?.confirm && typeof window !== "undefined" && window.confirm) {
|
|
const msg = typeof b.confirm === "string" ? b.confirm : "Are you sure?";
|
|
go = window.confirm(msg);
|
|
}
|
|
|
|
if (go) {
|
|
|
|
// action
|
|
b?.callback?.(node, i);
|
|
|
|
// selection behavior
|
|
if (selectMode === "single") {
|
|
state.selected.clear();
|
|
state.selected.add(i);
|
|
opts.onSelect?.(Array.from(state.selected));
|
|
} else if (selectMode === "multi") {
|
|
if (state.selected.has(i)) state.selected.delete(i);
|
|
else state.selected.add(i);
|
|
opts.onSelect?.(Array.from(state.selected));
|
|
}
|
|
}
|
|
|
|
redraw();
|
|
return true;
|
|
}
|
|
redraw();
|
|
}
|
|
if (event.type === "pointerleave" || event.type === "pointercancel") {
|
|
state.pressed = -1;
|
|
redraw();
|
|
}
|
|
return false;
|
|
},
|
|
|
|
// expose control surface
|
|
setDisabled: (...a) => api.setDisabled(...a),
|
|
setSelected: (...a) => api.setSelected(...a),
|
|
getSelected: () => api.getSelected(),
|
|
setButtons: (...a) => api.setButtons(...a),
|
|
update: () => api.update(),
|
|
buttons: api.buttons,
|
|
};
|
|
|
|
return widget;
|
|
},
|
|
};
|
|
},
|
|
});
|