Implement hierarchical directory autocomplete

This commit is contained in:
2026-04-22 10:49:47 -07:00
parent 9250a40341
commit fd778de331

View File

@@ -1,116 +1,217 @@
import { app } from "../../../scripts/app.js"; import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
function getAllNodesOfType(type) {
const nodes = [];
for (const node of app.graph._nodes) {
if (node.type === type) nodes.push(node);
}
return nodes;
}
function parseDirectorySegments(value) {
const parts = value.split("/").filter(Boolean);
return {
prefix: parts.length > 0 ? parts.slice(0, -1).join("/") : "",
filter: parts.length > 0 ? parts[parts.length - 1] : value,
isAtStart: !value.endsWith("/"),
};
}
function filterDirectories(allDirs, prefix, filter) {
const prefixLower = prefix.toLowerCase();
const filterLower = filter.toLowerCase();
return allDirs
.filter((d) => {
const dParts = d.split("/").filter(Boolean);
if (prefixLower) {
if (dParts.length < 2) return false;
const dirPrefix = dParts.slice(0, -1).join("/").toLowerCase();
if (dirPrefix !== prefixLower) return false;
} else {
if (dParts.length < 1) return false;
}
const lastPart = dParts[dParts.length - 1].toLowerCase();
return lastPart.startsWith(filterLower);
})
.map((d) => {
const dParts = d.split("/").filter(Boolean);
return dParts[prefix ? dParts.length - 1 : dParts.length - 1];
});
}
function uniqueSorted(arr) {
return [...new Set(arr)].sort();
}
function showDropdown(listEl, items, widget, inputEl) {
listEl.innerHTML = "";
if (items.length === 0) {
listEl.style.display = "none";
return;
}
items.forEach((item) => {
const li = document.createElement("li");
li.textContent = item;
li.style.padding = "4px 8px";
li.style.cursor = "pointer";
li.style.color = "#ccc";
li.style.listStyle = "none";
li.addEventListener("mouseenter", () => {
document.querySelectorAll("#compass_dir_list li").forEach((el) => {
el.style.background = "transparent";
});
li.style.background = "#444";
});
li.addEventListener("click", () => {
const newValue = inputEl._prefix + item + "/";
inputEl.value = newValue;
inputEl._prefix = newValue;
widget.value = newValue;
listEl.innerHTML = "";
inputEl.focus();
});
listEl.appendChild(li);
});
listEl.style.display = "block";
listEl.style.background = "#222";
listEl.style.border = "1px solid #444";
listEl.style.position = "absolute";
listEl.style.zIndex = "1000";
listEl.style.width = inputEl.offsetWidth + "px";
listEl.style.maxHeight = "200px";
listEl.style.overflowY = "auto";
}
function hideDropdown(listEl) {
listEl.innerHTML = "";
listEl.style.display = "none";
}
function setupAutocomplete(node, directoryWidget, inputEl, listEl) {
inputEl._prefix = directoryWidget.value || "";
inputEl._all_values = [...(directoryWidget._all_values || [])];
inputEl.addEventListener("input", () => {
const value = inputEl.value;
const { prefix, filter, isAtStart } = parseDirectorySegments(value);
// Determine the effective prefix: use everything up to the last slash
let effectivePrefix = "";
const lastSlash = value.lastIndexOf("/");
if (lastSlash >= 0) {
effectivePrefix = value.substring(0, lastSlash + 1);
}
const items = filterDirectories(inputEl._all_values, effectivePrefix, filter);
const unique = uniqueSorted(items);
showDropdown(listEl, unique, directoryWidget, inputEl);
// Sync prefix back
inputEl._prefix = effectivePrefix;
});
inputEl.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
hideDropdown(listEl);
return;
}
if (e.key === "Enter" || e.key === "Tab") {
const visible = listEl.querySelectorAll("li");
if (visible.length === 1) {
visible[0].click();
e.preventDefault();
} else if (visible.length > 0) {
// If user presses Tab with multiple options, pick the first match
visible[0].click();
e.preventDefault();
} else {
hideDropdown(listEl);
}
}
});
inputEl.addEventListener("blur", () => {
setTimeout(() => {
if (!listEl.contains(document.activeElement)) {
hideDropdown(listEl);
}
}, 150);
});
}
app.registerExtension({ app.registerExtension({
name: "CompassImageLoader", name: "CompassImageLoader",
async beforeRegisterNodeDef(nodeType, nodeData) { async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "CompassImageLoader") return; if (nodeData.name !== "CompassImageLoader") return;
const origOnNodeCreated = nodeType.prototype.onNodeCreated; const origOnNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () { nodeType.prototype.onNodeCreated = function () {
if (origOnNodeCreated) origOnNodeCreated.apply(this, []); if (origOnNodeCreated) origOnNodeCreated.apply(this, []);
const directoryWidget = this.widgets.find( const directoryWidget = this.widgets?.find((w) => w.name === "directory");
(w) => w.name === "directory" if (!directoryWidget) return;
);
if (!directoryWidget) return;
directoryWidget._all_values = [...(directoryWidget.options.values || [])]; // Build the full list from the node definition values
const defDirs = nodeData.input?.required?.directory;
if (defDirs && Array.isArray(defDirs[0])) {
directoryWidget._all_values = defDirs[0];
} else {
directoryWidget._all_values = [];
}
try { // Find or create the text input for filtering
if (typeof this.addDOMWidget === "function") { const filterWidget = this.widgets?.find((w) => w.name === "directory_filter");
const inputEl = document.createElement("input"); const inputEl = filterWidget?.inputEl;
inputEl.type = "text";
inputEl.value = directoryWidget.value;
inputEl.addEventListener("change", () => { if (inputEl) {
const filter = inputEl.value.toLowerCase(); // Create dropdown list element
const filtered = directoryWidget._all_values.filter((v) => const listEl = document.createElement("ul");
v.toLowerCase().includes(filter) listEl.id = "compass_dir_list";
); listEl.style.display = "none";
directoryWidget.options.values = filtered; listEl.style.position = "absolute";
directoryWidget.value = filtered[0] || ""; listEl.style.zIndex = "9999";
this.onResize?.(); document.body.appendChild(listEl);
});
const dropdownEl = document.createElement("div"); inputEl._prefix = directoryWidget.value || "";
dropdownEl.style.overflowY = "auto"; setupAutocomplete(this, directoryWidget, inputEl, listEl);
dropdownEl.style.maxHeight = "200px";
const listEl = document.createElement("ul"); // When the original combo value changes from workflow load, sync
listEl.style.padding = "4px"; const origSetValue = directoryWidget.callback
listEl.style.margin = "0"; ? directoryWidget.callback
listEl.style.background = "#333"; : () => {};
directoryWidget.callback = (value) => {
inputEl.addEventListener("focus", () => { origSetValue.call(directoryWidget, value);
listEl.innerHTML = ""; if (inputEl) {
directoryWidget._all_values.forEach((v) => { inputEl._prefix = value || "";
const li = document.createElement("li"); inputEl.value = value || "";
li.textContent = v; }
li.style.padding = "4px 8px";
li.style.cursor = "pointer";
li.addEventListener("click", () => {
inputEl.value = v;
directoryWidget.value = v;
listEl.innerHTML = "";
});
listEl.appendChild(li);
});
});
inputEl.addEventListener("blur", (e) => {
setTimeout(() => {
if (!listEl.contains(e.relatedTarget)) {
listEl.innerHTML = "";
}
}, 0);
});
const textWidget = this.addDOMWidget(
"directory_filter",
"Directory Filter",
inputEl,
{},
{ widget: directoryWidget }
);
textWidget.computeSize = () => [1, 40];
}
} catch (e) {
console.warn("[CompassImageLoader] Widget setup failed:", e);
}
}; };
},
async loadedGraphNode(node) { // Cleanup when node is removed
if (node.type !== "CompassImageLoader") return; const origOnRemoved = this.onRemoved;
this.onRemoved = () => {
const directoryWidget = node.widgets?.find((w) => w.name === "directory"); if (origOnRemoved) origOnRemoved.apply(this, []);
if (!directoryWidget) return; listEl.remove();
};
}
};
},
setTimeout(() => { async loadedGraphNode(node) {
try { if (node.type !== "CompassImageLoader") return;
app.api.addWebSocketMessageEventListener("fresh-node-defs", (event) => {
const defs = event.detail || {};
const compassDef = defs["CompassImageLoader"];
if (!compassDef) return;
const dirs = compassDef.input?.required?.directory; const directoryWidget = node.widgets?.find((w) => w.name === "directory");
if (!dirs || !Array.isArray(dirs)) return; if (!directoryWidget) return;
directoryWidget._all_values = [...dirs]; const filterWidget = node.widgets?.find((w) => w.name === "directory_filter");
directoryWidget.options.values = [...dirs]; const inputEl = filterWidget?.inputEl;
const listEl = document.getElementById("compass_dir_list");
if (!directoryWidget._all_values.includes(directoryWidget.value)) { if (inputEl && listEl) {
directoryWidget.value = directoryWidget._all_values[0] || ""; setupAutocomplete(node, directoryWidget, inputEl, listEl);
}
});
} catch (e) {
console.warn("[CompassImageLoader] Refresh listener failed:", e);
}
}, 100);
},
async getCustomWidgets() {
return null;
} }
},
}); });