261 lines
9.2 KiB
JavaScript
261 lines
9.2 KiB
JavaScript
import { app } from "../../scripts/app.js";
|
|
|
|
/**
|
|
* Tab Cycle - Cycle through number/slider widgets (only), within a single node, using Tab / Shift+Tab
|
|
* ------------------
|
|
* Node/widget identity is found via a mousedown hit-test on the canvas:
|
|
* screen coords -> graph coords (using canvas pan/zoom transform) -> which
|
|
* node's bounding box contains that point -> which widget row (via
|
|
* widget.last_y, set by litegraph's own render pass).
|
|
*/
|
|
|
|
const DEBUG = false; // flip to true if you need to debug again
|
|
const log = (...args) => DEBUG && console.log("[tab-cycle]", ...args);
|
|
|
|
const ALLOWED_TYPES = new Set(["number", "slider"]);
|
|
|
|
const activeIndexMap = new WeakMap(); // node -> last active widget index
|
|
const dialogXMap = new WeakMap(); // node -> screen clientX of the original click
|
|
let currentNode = null; // last node hit via pointerdown on canvas
|
|
|
|
function getEligibleWidgets(node) {
|
|
if (!node || !node.widgets) return [];
|
|
return node.widgets.filter((w) => ALLOWED_TYPES.has(w.type) && !w.disabled && !w.hidden);
|
|
}
|
|
|
|
function getOpenDialog() {
|
|
return document.querySelector(".graphdialog");
|
|
}
|
|
|
|
function commitOpenDialog() {
|
|
const dialog = getOpenDialog();
|
|
if (!dialog) return;
|
|
const input = dialog.querySelector("input");
|
|
if (!input) return;
|
|
input.dispatchEvent(
|
|
new KeyboardEvent("keydown", {
|
|
key: "Enter",
|
|
code: "Enter",
|
|
keyCode: 13,
|
|
which: 13,
|
|
bubbles: true,
|
|
})
|
|
);
|
|
}
|
|
|
|
function screenToGraph(clientX, clientY) {
|
|
const canvas = app.canvas;
|
|
const rect = canvas.canvas.getBoundingClientRect();
|
|
const scale = canvas.ds.scale;
|
|
const offset = canvas.ds.offset;
|
|
const gx = (clientX - rect.left) / scale - offset[0];
|
|
const gy = (clientY - rect.top) / scale - offset[1];
|
|
log("screenToGraph:", { clientX, clientY, rectLeft: rect.left, rectTop: rect.top, scale, offset, gx, gy });
|
|
return [gx, gy];
|
|
}
|
|
|
|
function findWidgetByLocalY(node, localY) {
|
|
const candidates = (node.widgets || []).filter((w) => !w.hidden);
|
|
if (!candidates.length) return null;
|
|
const hasLastY = candidates.every((w) => typeof w.last_y === "number");
|
|
log("findWidgetByLocalY:", { nodeTitle: node.title, localY, hasLastY, widgetYs: candidates.map((w) => ({ name: w.name, last_y: w.last_y })) });
|
|
if (hasLastY) {
|
|
let match = null;
|
|
for (const w of candidates) {
|
|
if (w.last_y <= localY) match = w;
|
|
else break;
|
|
}
|
|
return match || candidates[0];
|
|
}
|
|
const titleHeight = (window.LiteGraph && LiteGraph.NODE_TITLE_HEIGHT) || 30;
|
|
const rowHeight = (window.LiteGraph && LiteGraph.NODE_WIDGET_HEIGHT) || 20;
|
|
const idx = Math.floor((localY - titleHeight) / rowHeight);
|
|
return candidates[Math.max(0, Math.min(idx, candidates.length - 1))];
|
|
}
|
|
|
|
function findNodeAndWidgetAtPoint(clientX, clientY) {
|
|
const canvas = app.canvas;
|
|
const graph = canvas?.graph;
|
|
if (!graph) {
|
|
log("no canvas.graph!");
|
|
return null;
|
|
}
|
|
|
|
const [gx, gy] = screenToGraph(clientX, clientY);
|
|
const nodes = graph._nodes || [];
|
|
log("checking", nodes.length, "nodes for hit; first few:", nodes.slice(0, 3).map((n) => ({ title: n.title, pos: n.pos, size: n.size })));
|
|
|
|
for (const node of nodes) {
|
|
const [nx, ny] = node.pos;
|
|
const [w, h] = node.size;
|
|
if (gx >= nx && gx <= nx + w && gy >= ny && gy <= ny + h) {
|
|
const localY = gy - ny;
|
|
const widget = findWidgetByLocalY(node, localY);
|
|
return { node, widget };
|
|
}
|
|
}
|
|
log("no node bounding box contained the click point");
|
|
return null;
|
|
}
|
|
|
|
function hookNodeMouseDown(node) {
|
|
if (node.__tabCycleHooked) return;
|
|
node.__tabCycleHooked = true;
|
|
const original = node.onMouseDown;
|
|
node.onMouseDown = function (e, pos, canvas) {
|
|
currentNode = node;
|
|
const localY = pos ? pos[1] : null;
|
|
log("node.onMouseDown:", node.title, "localY:", localY);
|
|
if (localY != null) {
|
|
const widget = findWidgetByLocalY(node, localY);
|
|
log("hit widget:", widget?.name, widget?.type);
|
|
if (widget && ALLOWED_TYPES.has(widget.type)) {
|
|
const widgets = getEligibleWidgets(node);
|
|
const idx = widgets.indexOf(widget);
|
|
if (idx !== -1) activeIndexMap.set(node, idx);
|
|
log("set activeIndexMap idx:", idx);
|
|
}
|
|
}
|
|
return original?.apply(this, arguments);
|
|
};
|
|
}
|
|
|
|
function buildFakeEventForWidget(node, widget) {
|
|
const canvas = app.canvas;
|
|
const rect = canvas.canvas.getBoundingClientRect();
|
|
const scale = canvas.ds.scale;
|
|
const offset = canvas.ds.offset;
|
|
|
|
const localX = node.size[0] / 2;
|
|
const localY = typeof widget.last_y === "number" ? widget.last_y : 40;
|
|
|
|
const graphX = node.pos[0] + localX;
|
|
const graphY = node.pos[1] + localY;
|
|
|
|
// Prefer the x-position of the original real click, so the dialog
|
|
// doesn't jump around horizontally as we cycle through widgets.
|
|
const clientX = dialogXMap.has(node)
|
|
? dialogXMap.get(node)
|
|
: rect.left + (graphX + offset[0]) * scale;
|
|
const clientY = rect.top + (graphY + offset[1]) * scale;
|
|
|
|
return { clientX, clientY, target: canvas.canvas };
|
|
}
|
|
|
|
function activateWidget(node, widget) {
|
|
const fakeEvent = buildFakeEventForWidget(node, widget);
|
|
|
|
app.canvas.prompt(
|
|
"Value",
|
|
widget.value,
|
|
(v) => {
|
|
widget.value = v;
|
|
if (widget.callback) {
|
|
widget.callback(widget.value, app.canvas, node, fakeEvent);
|
|
}
|
|
node.graph?.setDirtyCanvas(true, true);
|
|
},
|
|
fakeEvent
|
|
);
|
|
|
|
requestAnimationFrame(() => {
|
|
const dialog = getOpenDialog();
|
|
if (!dialog) return;
|
|
const nameSpan = dialog.querySelector(".name");
|
|
if (nameSpan) nameSpan.textContent = widget.name;
|
|
const input = dialog.querySelector("input");
|
|
if (input) {
|
|
input.focus();
|
|
input.select();
|
|
}
|
|
});
|
|
}
|
|
|
|
app.registerExtension({
|
|
name: "tab.widget.cycle",
|
|
async nodeCreated(node) {
|
|
hookNodeMouseDown(node);
|
|
},
|
|
setup() {
|
|
log("extension setup() ran");
|
|
|
|
// Nodes already present (e.g. loaded from a saved workflow before
|
|
// this extension registered) won't have gone through nodeCreated
|
|
// for us, so hook them retroactively too.
|
|
const existing = app.graph?._nodes || [];
|
|
log("retroactively hooking", existing.length, "existing nodes");
|
|
existing.forEach(hookNodeMouseDown);
|
|
|
|
app.canvas.canvas.addEventListener(
|
|
"pointerdown",
|
|
(e) => {
|
|
log("pointerdown fired at", e.clientX, e.clientY);
|
|
const hit = findNodeAndWidgetAtPoint(e.clientX, e.clientY);
|
|
if (!hit) {
|
|
log("pointerdown: no hit");
|
|
return;
|
|
}
|
|
currentNode = hit.node;
|
|
log("pointerdown hit node:", hit.node.title, "widget:", hit.widget?.name, "widget.type:", hit.widget?.type);
|
|
if (hit.widget && ALLOWED_TYPES.has(hit.widget.type)) {
|
|
const widgets = getEligibleWidgets(hit.node);
|
|
const idx = widgets.indexOf(hit.widget);
|
|
if (idx !== -1) activeIndexMap.set(hit.node, idx);
|
|
dialogXMap.set(hit.node, e.clientX);
|
|
log("set activeIndexMap idx:", idx);
|
|
}
|
|
},
|
|
true
|
|
);
|
|
document.addEventListener(
|
|
"pointerdown",
|
|
(e) => {
|
|
const dialog = getOpenDialog();
|
|
if (!dialog) return;
|
|
if (dialog.contains(e.target)) return;
|
|
log("click outside dialog — committing instead of cancelling");
|
|
commitOpenDialog();
|
|
},
|
|
true
|
|
);
|
|
document.addEventListener(
|
|
"keydown",
|
|
(e) => {
|
|
if (e.key !== "Tab") return;
|
|
log("Tab keydown captured. currentNode:", currentNode?.title);
|
|
|
|
const node = currentNode;
|
|
if (!node) return;
|
|
|
|
const widgets = getEligibleWidgets(node);
|
|
log("eligible widgets:", widgets.map((w) => w.name));
|
|
if (!widgets.length) return;
|
|
|
|
const dialogOpen = !!getOpenDialog();
|
|
log("dialogOpen:", dialogOpen);
|
|
if (!dialogOpen) {
|
|
log("bailing: no dialog open");
|
|
return;
|
|
}
|
|
|
|
log("intercepting Tab");
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
let idx = activeIndexMap.has(node) ? activeIndexMap.get(node) : 0;
|
|
log("current idx:", idx);
|
|
|
|
commitOpenDialog();
|
|
|
|
const dir = e.shiftKey ? -1 : 1;
|
|
const nextIdx = (idx + dir + widgets.length) % widgets.length;
|
|
activeIndexMap.set(node, nextIdx);
|
|
log("advancing to idx:", nextIdx, widgets[nextIdx]?.name);
|
|
|
|
requestAnimationFrame(() => activateWidget(node, widgets[nextIdx]));
|
|
},
|
|
true
|
|
);
|
|
},
|
|
});
|