577 lines
21 KiB
JavaScript
577 lines
21 KiB
JavaScript
import { app } from "../../scripts/app.js";
|
|
|
|
// Module-level counter — guarantees unique widget names across all node instances
|
|
// without storing any state on the node itself.
|
|
let _bishaWidgetIdCounter = 0;
|
|
|
|
function fitString(ctx, str, maxWidth) {
|
|
let width = ctx.measureText(str).width;
|
|
const ellipsis = "…";
|
|
const ellipsisWidth = ctx.measureText(ellipsis).width;
|
|
if (width <= maxWidth || width <= ellipsisWidth) return str;
|
|
let lo = 0, hi = str.length;
|
|
while (lo < hi) {
|
|
const mid = Math.ceil((lo + hi) / 2);
|
|
if (ctx.measureText(str.substring(0, mid)).width + ellipsisWidth <= maxWidth) lo = mid;
|
|
else hi = mid - 1;
|
|
}
|
|
return str.substring(0, lo) + ellipsis;
|
|
}
|
|
|
|
function isLowQuality() {
|
|
const canvas = app.canvas;
|
|
return ((canvas?.ds?.scale) || 1) <= 0.5;
|
|
}
|
|
|
|
function drawRoundedRectangle(ctx, options) {
|
|
const lq = isLowQuality();
|
|
ctx.save();
|
|
ctx.strokeStyle = options.colorStroke || LiteGraph.WIDGET_OUTLINE_COLOR;
|
|
ctx.fillStyle = options.colorBackground || LiteGraph.WIDGET_BGCOLOR;
|
|
ctx.beginPath();
|
|
const r = lq ? 0 : (options.borderRadius ?? options.size[1] * 0.5);
|
|
ctx.roundRect(...options.pos, ...options.size, [r]);
|
|
ctx.fill();
|
|
if (!lq) ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawTogglePart(ctx, { posX, posY, height, value }) {
|
|
const lq = isLowQuality();
|
|
ctx.save();
|
|
const toggleRadius = height * 0.36;
|
|
const toggleBgWidth = height * 1.5;
|
|
if (!lq) {
|
|
ctx.beginPath();
|
|
ctx.roundRect(posX + 4, posY + 4, toggleBgWidth - 8, height - 8, [height * 0.5]);
|
|
ctx.globalAlpha = app.canvas.editor_alpha * 0.25;
|
|
ctx.fillStyle = "rgba(255,255,255,0.45)";
|
|
ctx.fill();
|
|
ctx.globalAlpha = app.canvas.editor_alpha;
|
|
}
|
|
ctx.fillStyle = value === true ? "#89B" : "#888";
|
|
const toggleX = lq || value === false
|
|
? posX + height * 0.5
|
|
: value === true
|
|
? posX + height
|
|
: posX + height * 0.75;
|
|
ctx.beginPath();
|
|
ctx.arc(toggleX, posY + height * 0.5, toggleRadius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.restore();
|
|
return [posX, toggleBgWidth];
|
|
}
|
|
|
|
function drawWidgetButton(ctx, options, text, isPressed) {
|
|
const lq = isLowQuality();
|
|
const borderRadius = lq ? 0 : (options.borderRadius ?? 4);
|
|
ctx.save();
|
|
if (!lq && !isPressed) {
|
|
drawRoundedRectangle(ctx, {
|
|
size: [options.size[0] - 2, options.size[1]],
|
|
pos: [options.pos[0] + 1, options.pos[1] + 1],
|
|
borderRadius,
|
|
colorBackground: "#000000aa",
|
|
colorStroke: "#000000aa",
|
|
});
|
|
}
|
|
drawRoundedRectangle(ctx, {
|
|
size: options.size,
|
|
pos: [options.pos[0], options.pos[1] + (isPressed ? 1 : 0)],
|
|
borderRadius,
|
|
colorBackground: isPressed ? "#444" : LiteGraph.WIDGET_BGCOLOR,
|
|
colorStroke: "transparent",
|
|
});
|
|
if (!lq && text) {
|
|
ctx.textBaseline = "middle";
|
|
ctx.textAlign = "center";
|
|
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
|
|
ctx.fillText(text, options.pos[0] + options.size[0] / 2, options.pos[1] + options.size[1] / 2 + (isPressed ? 1 : 0));
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// BishaBaseWidget — base class with hit-area / mouse-event routing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class BishaBaseWidget {
|
|
constructor(name) {
|
|
this.name = name;
|
|
this.type = "custom";
|
|
this.options = {};
|
|
this.y = 0;
|
|
this.last_y = 0;
|
|
this.mouseDowned = null;
|
|
this.isMouseDownedAndOver = false;
|
|
this.hitAreas = {};
|
|
this._downedForMove = [];
|
|
this._downedForClick = [];
|
|
}
|
|
|
|
serializeValue(node, index) { return this.value; }
|
|
|
|
_within(pos, bounds) {
|
|
const xOk = pos[0] >= bounds[0] && pos[0] <= bounds[0] + bounds[1];
|
|
if (bounds.length === 2) return xOk;
|
|
return xOk && pos[1] >= bounds[1] && pos[1] <= bounds[1] + bounds[3];
|
|
}
|
|
|
|
mouse(event, pos, node) {
|
|
if (event.type === "pointerdown") {
|
|
this.mouseDowned = [...pos];
|
|
this.isMouseDownedAndOver = true;
|
|
this._downedForMove.length = 0;
|
|
this._downedForClick.length = 0;
|
|
let handled = false;
|
|
for (const part of Object.values(this.hitAreas)) {
|
|
if (!this._within(pos, part.bounds)) continue;
|
|
if (part.onMove) this._downedForMove.push(part);
|
|
if (part.onClick) this._downedForClick.push(part);
|
|
if (part.onDown) {
|
|
const r = part.onDown.apply(this, [event, pos, node, part]);
|
|
handled = handled || r === true;
|
|
}
|
|
part.wasClickedAndOver = true;
|
|
}
|
|
return this.onMouseDown(event, pos, node) ?? handled;
|
|
}
|
|
|
|
if (event.type === "pointerup") {
|
|
if (!this.mouseDowned) return true;
|
|
this._downedForMove.length = 0;
|
|
const wasOver = this.isMouseDownedAndOver;
|
|
this.cancelMouseDown();
|
|
let handled = false;
|
|
for (const part of Object.values(this.hitAreas)) {
|
|
if (part.onUp && this._within(pos, part.bounds)) {
|
|
const r = part.onUp.apply(this, [event, pos, node, part]);
|
|
handled = handled || r === true;
|
|
}
|
|
part.wasClickedAndOver = false;
|
|
}
|
|
for (const part of this._downedForClick) {
|
|
if (this._within(pos, part.bounds)) {
|
|
const r = part.onClick.apply(this, [event, pos, node, part]);
|
|
handled = handled || r === true;
|
|
}
|
|
}
|
|
this._downedForClick.length = 0;
|
|
if (wasOver) handled = this.onMouseClick(event, pos, node) || handled;
|
|
return this.onMouseUp(event, pos, node) ?? handled;
|
|
}
|
|
|
|
if (event.type === "pointermove") {
|
|
this.isMouseDownedAndOver = !!this.mouseDowned;
|
|
if (this.mouseDowned &&
|
|
(pos[0] < 15 || pos[0] > node.size[0] - 15 ||
|
|
pos[1] < this.last_y || pos[1] > this.last_y + LiteGraph.NODE_WIDGET_HEIGHT)) {
|
|
this.isMouseDownedAndOver = false;
|
|
}
|
|
for (const part of Object.values(this.hitAreas)) {
|
|
if (this._downedForMove.includes(part)) part.onMove.apply(this, [event, pos, node, part]);
|
|
if (this._downedForClick.includes(part)) part.wasClickedAndOver = this._within(pos, part.bounds);
|
|
}
|
|
return this.onMouseMove(event, pos, node) ?? true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
cancelMouseDown() {
|
|
this.mouseDowned = null;
|
|
this.isMouseDownedAndOver = false;
|
|
this._downedForMove.length = 0;
|
|
}
|
|
|
|
onMouseDown(event, pos, node) {}
|
|
onMouseUp(event, pos, node) {}
|
|
onMouseClick(event, pos, node) {}
|
|
onMouseMove(event, pos, node) {}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// BishaDividerWidget
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class BishaDividerWidget extends BishaBaseWidget {
|
|
constructor(opts = {}) {
|
|
super("divider");
|
|
this.value = {};
|
|
this.options = { serialize: false };
|
|
this.type = "custom";
|
|
this._opts = { marginTop: 7, marginBottom: 7, marginLeft: 15, marginRight: 15, thickness: 1, ...opts };
|
|
}
|
|
draw(ctx, node, width, posY) {
|
|
if (!this._opts.thickness) return;
|
|
ctx.save();
|
|
ctx.strokeStyle = LiteGraph.WIDGET_OUTLINE_COLOR;
|
|
const x = this._opts.marginLeft;
|
|
const y = posY + this._opts.marginTop;
|
|
const w = width - this._opts.marginLeft - this._opts.marginRight;
|
|
ctx.stroke(new Path2D(`M ${x} ${y} h ${w}`));
|
|
ctx.restore();
|
|
}
|
|
computeSize(width) {
|
|
return [width, this._opts.marginTop + this._opts.marginBottom + this._opts.thickness];
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// BishaButtonWidget
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class BishaButtonWidget extends BishaBaseWidget {
|
|
constructor(label, onClick) {
|
|
super(label);
|
|
this.type = "custom";
|
|
this.value = "";
|
|
this.label = label;
|
|
this._cb = onClick;
|
|
}
|
|
draw(ctx, node, width, y, height) {
|
|
drawWidgetButton(ctx, { size: [width - 30, height], pos: [15, y] }, this.label, this.isMouseDownedAndOver);
|
|
}
|
|
onMouseClick(event, pos, node) {
|
|
return this._cb(event, pos, node);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// BishaFileHeaderWidget — "Toggle All" header row
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class BishaFileHeaderWidget extends BishaBaseWidget {
|
|
constructor(node) {
|
|
super("BishaFileHeader");
|
|
this.value = { type: "BishaFileHeader" };
|
|
this.type = "custom";
|
|
this._node = node;
|
|
this.hitAreas = {
|
|
toggle: { bounds: [0, 0], onDown: this._onToggleDown },
|
|
};
|
|
}
|
|
|
|
draw(ctx, node, w, posY, height) {
|
|
const margin = 10;
|
|
const innerMargin = margin * 0.33;
|
|
const lq = isLowQuality();
|
|
const allState = node.allFilesState();
|
|
posY += 2;
|
|
const midY = posY + height * 0.5;
|
|
ctx.save();
|
|
|
|
if (!node.hasBishaFileWidgets()) {
|
|
// Empty state hint
|
|
ctx.globalAlpha = app.canvas.editor_alpha * 0.4;
|
|
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
|
|
ctx.textAlign = "center";
|
|
ctx.textBaseline = "middle";
|
|
if (!lq) ctx.fillText("Нажмите ➕ Add file чтобы добавить файл", node.size[0] / 2, midY);
|
|
ctx.restore();
|
|
return;
|
|
}
|
|
|
|
this.hitAreas.toggle.bounds = drawTogglePart(ctx, { posX: 10, posY, height, value: allState });
|
|
if (!lq) {
|
|
const posX = 10 + this.hitAreas.toggle.bounds[1] + innerMargin;
|
|
ctx.globalAlpha = app.canvas.editor_alpha * 0.55;
|
|
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
|
|
ctx.textAlign = "left";
|
|
ctx.textBaseline = "middle";
|
|
ctx.fillText("Toggle All", posX, midY);
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
_onToggleDown(event, pos, node) {
|
|
node.toggleAllFiles();
|
|
this.cancelMouseDown();
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// BishaFileWidget — one file row
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const DEFAULT_FILE_DATA = { on: true, file: "" };
|
|
|
|
class BishaFileWidget extends BishaBaseWidget {
|
|
constructor(name) {
|
|
super(name);
|
|
this.type = "custom";
|
|
this._value = { ...DEFAULT_FILE_DATA };
|
|
this.hitAreas = {
|
|
toggle: { bounds: [0, 0], onDown: this._onToggleDown },
|
|
file: { bounds: [0, 0], onClick: this._onFileClick },
|
|
};
|
|
}
|
|
|
|
get value() { return this._value; }
|
|
set value(v) {
|
|
this._value = (v && typeof v === "object") ? { ...DEFAULT_FILE_DATA, ...v } : { ...DEFAULT_FILE_DATA };
|
|
}
|
|
|
|
serializeValue() {
|
|
return { ...this._value };
|
|
}
|
|
|
|
draw(ctx, node, w, posY, height) {
|
|
const margin = 10;
|
|
const innerMargin = margin * 0.33;
|
|
const lq = isLowQuality();
|
|
const midY = posY + height * 0.5;
|
|
|
|
ctx.save();
|
|
|
|
// Background rounded rect
|
|
drawRoundedRectangle(ctx, {
|
|
pos: [margin, posY],
|
|
size: [node.size[0] - margin * 2, height],
|
|
});
|
|
|
|
// Toggle
|
|
let posX = margin;
|
|
this.hitAreas.toggle.bounds = drawTogglePart(ctx, { posX, posY, height, value: this._value.on });
|
|
|
|
if (lq) { ctx.restore(); return; }
|
|
|
|
posX += this.hitAreas.toggle.bounds[1] + innerMargin;
|
|
|
|
if (!this._value.on) ctx.globalAlpha = app.canvas.editor_alpha * 0.4;
|
|
|
|
// File path text
|
|
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
|
|
ctx.textAlign = "left";
|
|
ctx.textBaseline = "middle";
|
|
const fileWidth = node.size[0] - margin - posX - innerMargin;
|
|
const label = this._value.file || "Click to set file path…";
|
|
ctx.fillText(fitString(ctx, label, fileWidth), posX, midY);
|
|
this.hitAreas.file.bounds = [posX, fileWidth];
|
|
|
|
ctx.globalAlpha = app.canvas.editor_alpha;
|
|
ctx.restore();
|
|
}
|
|
|
|
_onToggleDown(event, pos, node) {
|
|
this._value.on = !this._value.on;
|
|
this.cancelMouseDown();
|
|
return true;
|
|
}
|
|
|
|
_onFileClick(event, pos, node) {
|
|
app.canvas.prompt("File path", this._value.file, (v) => {
|
|
this._value.file = v;
|
|
node.setDirtyCanvas(true, true);
|
|
}, event);
|
|
this.cancelMouseDown();
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Node class extension
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function moveArrayItem(arr, item, targetIndex) {
|
|
const idx = arr.indexOf(item);
|
|
if (idx === -1) return;
|
|
arr.splice(idx, 1);
|
|
arr.splice(targetIndex, 0, item);
|
|
}
|
|
|
|
function removeArrayItem(arr, item) {
|
|
const idx = arr.indexOf(item);
|
|
if (idx !== -1) arr.splice(idx, 1);
|
|
}
|
|
|
|
function setupBishaLoadDataNode(nodeType) {
|
|
const orig_onNodeCreated = nodeType.prototype.onNodeCreated;
|
|
const orig_configure = nodeType.prototype.configure;
|
|
|
|
// Track button spacer per node instance (no _fileCounter needed)
|
|
nodeType.prototype._bishaSetup = function () {
|
|
if (this._bishaReady) return;
|
|
this._bishaReady = true;
|
|
this._buttonSpacer = null;
|
|
this._bishaInitWidgets();
|
|
};
|
|
|
|
nodeType.prototype._bishaInitWidgets = function () {
|
|
// Clear existing widgets if any
|
|
while (this.widgets?.length) this.removeWidget(this.widgets[0]);
|
|
this._buttonSpacer = null;
|
|
this._addNonFileWidgets();
|
|
};
|
|
|
|
nodeType.prototype._addNonFileWidgets = function () {
|
|
// Divider on top (index 0)
|
|
const topDivider = new BishaDividerWidget({ marginTop: 4, marginBottom: 0, thickness: 0 });
|
|
this.addCustomWidget(topDivider);
|
|
moveArrayItem(this.widgets, topDivider, 0);
|
|
|
|
// Header (index 1)
|
|
const header = new BishaFileHeaderWidget(this);
|
|
this.addCustomWidget(header);
|
|
moveArrayItem(this.widgets, header, 1);
|
|
|
|
// Spacer before the add button
|
|
this._buttonSpacer = new BishaDividerWidget({ marginTop: 4, marginBottom: 0, thickness: 0 });
|
|
this.addCustomWidget(this._buttonSpacer);
|
|
|
|
// "Add file" button
|
|
this.addCustomWidget(new BishaButtonWidget("Add file", (event, pos, node) => {
|
|
this.addNewFileWidget();
|
|
const computed = this.computeSize();
|
|
this.size[1] = Math.max(this.size[1], computed[1]);
|
|
this.setDirtyCanvas(true, true);
|
|
return true;
|
|
}));
|
|
};
|
|
|
|
nodeType.prototype.addNewFileWidget = function (data) {
|
|
// Use module-level counter for a globally unique name — no instance state needed.
|
|
const widget = new BishaFileWidget("file_" + (++_bishaWidgetIdCounter));
|
|
if (data) widget.value = data;
|
|
this.addCustomWidget(widget);
|
|
if (this._buttonSpacer) {
|
|
moveArrayItem(this.widgets, widget, this.widgets.indexOf(this._buttonSpacer));
|
|
}
|
|
return widget;
|
|
};
|
|
|
|
nodeType.prototype.hasBishaFileWidgets = function () {
|
|
return !!this.widgets?.find(w => w.name?.startsWith("file_"));
|
|
};
|
|
|
|
nodeType.prototype.allFilesState = function () {
|
|
let allOn = true, allOff = true;
|
|
for (const w of (this.widgets || [])) {
|
|
if (!w.name?.startsWith("file_")) continue;
|
|
const on = w.value?.on;
|
|
allOn = allOn && on === true;
|
|
allOff = allOff && on === false;
|
|
if (!allOn && !allOff) return null;
|
|
}
|
|
return allOn && this.hasBishaFileWidgets() ? true : false;
|
|
};
|
|
|
|
nodeType.prototype.toggleAllFiles = function () {
|
|
const allOn = this.allFilesState();
|
|
const toggleTo = !allOn;
|
|
for (const w of (this.widgets || [])) {
|
|
if (w.name?.startsWith("file_") && w.value?.on != null) {
|
|
w.value.on = toggleTo;
|
|
}
|
|
}
|
|
};
|
|
|
|
// Right-click context menu for file rows
|
|
const orig_getSlotMenuOptions = nodeType.prototype.getSlotMenuOptions;
|
|
nodeType.prototype.getSlotMenuOptions = function (slot) {
|
|
if (slot?.widget?.name?.startsWith("file_")) {
|
|
const widget = slot.widget;
|
|
const index = this.widgets.indexOf(widget);
|
|
const canUp = !!this.widgets[index - 1]?.name?.startsWith("file_");
|
|
const canDown = !!this.widgets[index + 1]?.name?.startsWith("file_");
|
|
|
|
return [
|
|
{
|
|
content: "Move Up",
|
|
disabled: !canUp,
|
|
callback: () => { moveArrayItem(this.widgets, widget, index - 1); this.setDirtyCanvas(true,true); },
|
|
},
|
|
{
|
|
content: "Move Down",
|
|
disabled: !canDown,
|
|
callback: () => { moveArrayItem(this.widgets, widget, index + 1); this.setDirtyCanvas(true,true); },
|
|
},
|
|
null,
|
|
{
|
|
content: "Remove",
|
|
callback: () => { removeArrayItem(this.widgets, widget); this.setDirtyCanvas(true,true); },
|
|
},
|
|
];
|
|
}
|
|
return orig_getSlotMenuOptions ? orig_getSlotMenuOptions.call(this, slot) : undefined;
|
|
};
|
|
|
|
// Detect right-click on a file widget row (since they are canvas widgets, not real slots)
|
|
const orig_getSlotInPosition = nodeType.prototype.getSlotInPosition;
|
|
nodeType.prototype.getSlotInPosition = function (canvasX, canvasY) {
|
|
const slot = orig_getSlotInPosition ? orig_getSlotInPosition.call(this, canvasX, canvasY) : null;
|
|
if (!slot) {
|
|
let last = null;
|
|
for (const w of (this.widgets || [])) {
|
|
if (!w.last_y) continue;
|
|
if (canvasY > this.pos[1] + w.last_y) { last = w; continue; }
|
|
break;
|
|
}
|
|
if (last?.name?.startsWith("file_")) {
|
|
return { widget: last, output: { type: "FILE WIDGET" } };
|
|
}
|
|
}
|
|
return slot;
|
|
};
|
|
|
|
// onNodeCreated
|
|
nodeType.prototype.onNodeCreated = function () {
|
|
orig_onNodeCreated?.call(this);
|
|
this._bishaSetup();
|
|
const computed = this.computeSize();
|
|
this.size = this.size || [0, 0];
|
|
this.size[0] = Math.max(this.size[0], computed[0]);
|
|
this.size[1] = Math.max(this.size[1], computed[1]);
|
|
this.setDirtyCanvas(true, true);
|
|
};
|
|
|
|
// configure — restore widgets from saved workflow
|
|
nodeType.prototype.configure = function (info) {
|
|
// Clear our widgets first, then call orig_configure only for node
|
|
// meta-properties (position, size, color, flags) — NOT widget restoration.
|
|
while (this.widgets?.length) this.removeWidget(this.widgets[0]);
|
|
this._buttonSpacer = null;
|
|
|
|
if (info.id != null) {
|
|
// Call orig_configure to restore position/size/color/flags.
|
|
// Safe because we already cleared widgets, so it won't create duplicates.
|
|
const widgetsBackup = info.widgets_values;
|
|
info = { ...info, widgets_values: [] }; // hide values so orig doesn't restore widgets
|
|
orig_configure?.call(this, info);
|
|
info.widgets_values = widgetsBackup; // restore for our own use below
|
|
}
|
|
|
|
const tempW = this.size?.[0] ?? 300;
|
|
const tempH = this.size?.[1] ?? 100;
|
|
|
|
// Re-add divider + header + spacer + button
|
|
this._addNonFileWidgets();
|
|
|
|
// Re-add file widgets from serialized values
|
|
for (const v of (info.widgets_values || [])) {
|
|
if (v && typeof v === "object" && "file" in v) {
|
|
this.addNewFileWidget(v);
|
|
}
|
|
}
|
|
|
|
this._bishaReady = true;
|
|
|
|
this.size = this.size || [0, 0];
|
|
this.size[0] = tempW;
|
|
this.size[1] = Math.max(tempH, this.computeSize()[1]);
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Register
|
|
// ---------------------------------------------------------------------------
|
|
|
|
app.registerExtension({
|
|
name: "BishaNodes.LoadDataFromFiles",
|
|
async beforeRegisterNodeDef(nodeType, nodeData) {
|
|
if (nodeData.name === "LoadDataFromFiles") {
|
|
setupBishaLoadDataNode(nodeType);
|
|
}
|
|
},
|
|
});
|