Files

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);
}
},
});