Files
CoCoandClaude Opus 4.6 5cbdafc6d0 feat: fix file browser double-prompt, add test assets and workflows
Replace STRING widget with fresh COCO.PATH widget object to prevent
LiteGraph default "Value" prompt from appearing alongside custom file
browser. Bump version to 0.8.0. Simplify .gitignore and move dev
files to dev/. Add test images and example workflow for release.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:04:42 +00:00

445 lines
17 KiB
JavaScript

import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
// --- File Browser Modal ---
async function fetchDirectory(dirPath, extensions) {
const params = new URLSearchParams();
if (dirPath) params.set("path", dirPath);
if (extensions) params.set("extensions", extensions);
const resp = await api.fetchApi(`/cocotools/browse?${params.toString()}`);
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || `HTTP ${resp.status}`);
}
return resp.json();
}
function formatSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1073741824) return `${(bytes / 1048576).toFixed(1)} MB`;
return `${(bytes / 1073741824).toFixed(1)} GB`;
}
function clearChildren(el) {
while (el.firstChild) {
el.removeChild(el.firstChild);
}
}
function openFileBrowser({ startPath = "", extensions = "", title = "Browse", mode = "file", onSelect, onClose }) {
// Overlay
const overlay = document.createElement("div");
overlay.style.cssText = "position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.6);z-index:10000;display:flex;align-items:center;justify-content:center;";
// Modal container
const modal = document.createElement("div");
modal.style.cssText = "background:#1a1a1a;border:1px solid #444;border-radius:8px;width:600px;max-height:80vh;display:flex;flex-direction:column;font-family:monospace;color:#ddd;box-shadow:0 8px 32px rgba(0,0,0,0.5);";
// Header
const header = document.createElement("div");
header.style.cssText = "padding:12px 16px;border-bottom:1px solid #333;display:flex;align-items:center;gap:8px;";
const titleEl = document.createElement("span");
titleEl.textContent = title;
titleEl.style.cssText = "font-size:14px;font-weight:bold;color:#fff;";
header.appendChild(titleEl);
const spacer = document.createElement("span");
spacer.style.cssText = "flex:1;";
header.appendChild(spacer);
const closeBtn = document.createElement("button");
closeBtn.textContent = "\u2715";
closeBtn.style.cssText = "background:none;border:none;color:#888;font-size:18px;cursor:pointer;padding:0 4px;";
closeBtn.addEventListener("click", close);
header.appendChild(closeBtn);
modal.appendChild(header);
// Path bar
const pathBar = document.createElement("div");
pathBar.style.cssText = "padding:8px 16px;border-bottom:1px solid #333;display:flex;gap:6px;align-items:center;";
const upBtn = document.createElement("button");
upBtn.textContent = "\u2191";
upBtn.title = "Parent directory";
upBtn.style.cssText = "background:#2a2a2a;border:1px solid #444;color:#ddd;font-size:14px;cursor:pointer;padding:4px 8px;border-radius:4px;";
pathBar.appendChild(upBtn);
const pathInput = document.createElement("input");
pathInput.type = "text";
pathInput.style.cssText = "flex:1;background:#111;border:1px solid #444;color:#ddd;font-family:monospace;font-size:12px;padding:6px 8px;border-radius:4px;outline:none;";
pathInput.placeholder = "Enter path...";
pathBar.appendChild(pathInput);
modal.appendChild(pathBar);
// Error display
const errorEl = document.createElement("div");
errorEl.style.cssText = "padding:4px 16px;color:#f66;font-size:11px;display:none;";
modal.appendChild(errorEl);
// File list
const listContainer = document.createElement("div");
listContainer.style.cssText = "flex:1;overflow-y:auto;min-height:200px;max-height:50vh;";
modal.appendChild(listContainer);
// Footer (for directory mode)
const footer = document.createElement("div");
footer.style.cssText = "padding:10px 16px;border-top:1px solid #333;display:flex;justify-content:flex-end;gap:8px;";
if (mode === "directory") {
const selectFolderBtn = document.createElement("button");
selectFolderBtn.textContent = "Select This Folder";
selectFolderBtn.style.cssText = "background:#2563eb;border:none;color:#fff;font-family:monospace;font-size:12px;padding:6px 16px;border-radius:4px;cursor:pointer;";
selectFolderBtn.addEventListener("click", () => {
const currentPath = pathInput.value.trim();
if (currentPath) {
onSelect(currentPath);
close();
}
});
footer.appendChild(selectFolderBtn);
}
const cancelBtn = document.createElement("button");
cancelBtn.textContent = "Cancel";
cancelBtn.style.cssText = "background:#333;border:1px solid #555;color:#ddd;font-family:monospace;font-size:12px;padding:6px 16px;border-radius:4px;cursor:pointer;";
cancelBtn.addEventListener("click", close);
footer.appendChild(cancelBtn);
modal.appendChild(footer);
overlay.appendChild(modal);
document.body.appendChild(overlay);
let currentPath = "";
function close() {
document.body.removeChild(overlay);
document.removeEventListener("keydown", onKey);
onClose?.();
}
function onKey(e) {
if (e.key === "Escape") close();
}
document.addEventListener("keydown", onKey);
overlay.addEventListener("click", (e) => {
if (e.target === overlay) close();
});
async function navigate(dirPath) {
errorEl.style.display = "none";
clearChildren(listContainer);
const loading = document.createElement("div");
loading.textContent = "Loading...";
loading.style.cssText = "padding:20px;text-align:center;color:#888;";
listContainer.appendChild(loading);
try {
const data = await fetchDirectory(dirPath, extensions);
currentPath = data.path || "";
pathInput.value = currentPath;
clearChildren(listContainer);
if (data.items.length === 0) {
const empty = document.createElement("div");
empty.textContent = "Empty directory";
empty.style.cssText = "padding:20px;text-align:center;color:#666;";
listContainer.appendChild(empty);
return;
}
for (const item of data.items) {
const row = document.createElement("div");
row.style.cssText = "padding:6px 16px;cursor:pointer;display:flex;align-items:center;gap:8px;font-size:12px;border-bottom:1px solid #222;";
row.addEventListener("mouseenter", () => { row.style.background = "#2a2a2a"; });
row.addEventListener("mouseleave", () => { row.style.background = "none"; });
const icon = document.createElement("span");
icon.style.cssText = "font-size:14px;width:20px;text-align:center;flex-shrink:0;";
const name = document.createElement("span");
name.style.cssText = "flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;";
name.textContent = item.name;
if (item.type === "directory") {
icon.textContent = "\uD83D\uDCC1";
name.style.color = "#7cb3f0";
row.addEventListener("click", () => {
navigate(item.path || (currentPath ? currentPath + "/" + item.name : item.name));
});
} else {
icon.textContent = "\uD83D\uDCC4";
const sizeEl = document.createElement("span");
sizeEl.textContent = formatSize(item.size || 0);
sizeEl.style.cssText = "color:#888;font-size:11px;flex-shrink:0;";
row.appendChild(icon);
row.appendChild(name);
row.appendChild(sizeEl);
if (mode === "file") {
row.addEventListener("click", () => {
const filePath = currentPath ? currentPath + "/" + item.name : item.name;
onSelect(filePath);
close();
});
}
listContainer.appendChild(row);
continue;
}
row.appendChild(icon);
row.appendChild(name);
listContainer.appendChild(row);
}
} catch (err) {
clearChildren(listContainer);
errorEl.textContent = err.message;
errorEl.style.display = "block";
}
}
upBtn.addEventListener("click", () => {
if (currentPath) {
const parent = currentPath.replace(/[\\/][^\\/]*$/, "") || "";
navigate(parent);
}
});
pathInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
navigate(pathInput.value.trim());
}
});
navigate(startPath);
}
// --- Sequence pattern detection ---
function detectSequencePattern(filePath) {
const dir = filePath.replace(/[\\/][^\\/]*$/, "");
const filename = filePath.split(/[\\/]/).pop();
// Find trailing digits before extension: render_0001.exr -> render_####.exr
const match = filename.match(/^(.*?)(\d+)(\.[^.]+)$/);
if (!match) return filePath;
const prefix = match[1];
const digits = match[2];
const ext = match[3];
const hashes = "#".repeat(digits.length);
return dir + "/" + prefix + hashes + ext;
}
// --- Text helpers (adapted from VHS) ---
function pathStem(path) {
const i = path.lastIndexOf("/");
if (i >= 0) return [path.slice(0, i + 1), path.slice(i + 1)];
const j = path.lastIndexOf("\\");
if (j >= 0) return [path.slice(0, j + 1), path.slice(j + 1)];
return ["", path];
}
function fitText(ctx, text, maxWidth) {
if (maxWidth <= 0) return ["", 0];
const full = ctx.measureText(text).width;
if (full <= maxWidth) return [text, full];
const cutoff = Math.max(0, (maxWidth / full * text.length | 0) - 2);
const shortened = text.slice(0, cutoff) + "\u2026";
return [shortened, ctx.measureText(shortened).width];
}
function fitPath(ctx, path, maxWidth) {
const full = ctx.measureText(path).width;
if (full <= maxWidth) return [path, full];
const len = (maxWidth / full * path.length | 0) - 1;
const filename = pathStem(path)[1];
if (filename.length > len - 2) return [filename.substr(0, len), ctx.measureText(filename.substr(0, len)).width];
const isAbs = path[0] === "/" || (path.length > 1 && path[1] === ":");
const partial = path.substr(path.length - (isAbs ? len - 2 : len - 1));
const cutoff = partial.indexOf("/");
const backCutoff = cutoff < 0 ? partial.indexOf("\\") : cutoff;
let displayPath;
if (backCutoff < 0) {
displayPath = path.substr(path.length - len);
} else {
displayPath = (isAbs ? "/\u2026" : "\u2026") + partial.substr(backCutoff);
}
return [displayPath, ctx.measureText(displayPath).width];
}
// --- Browse button injection (adapted from VHS VHSPATH pattern) ---
const BROWSE_TARGETS = [
{ nodeClass: "ImageLoader", widget: "image_path", extensions: "png,jpg,jpeg,tiff,tif,webp,bmp,exr", mode: "file" },
{ nodeClass: "LoadExr", widget: "image_path", extensions: "exr", mode: "file" },
{ nodeClass: "LoadExrSequence", widget: "sequence_path", extensions: "exr", mode: "file" },
{ nodeClass: "SaverNode", widget: "file_path", extensions: "", mode: "directory" },
];
const ICON_ZONE = 28;
function drawFolderIcon(ctx, cx, cy, size) {
const s = size * 0.4;
ctx.fillStyle = "#b0b0b0";
ctx.beginPath();
ctx.moveTo(cx - s, cy - s * 0.6);
ctx.lineTo(cx - s * 0.3, cy - s * 0.6);
ctx.lineTo(cx - s * 0.1, cy - s * 0.95);
ctx.lineTo(cx - s, cy - s * 0.95);
ctx.closePath();
ctx.fill();
ctx.beginPath();
ctx.roundRect(cx - s, cy - s * 0.6, s * 2, s * 1.4, 1.5);
ctx.fill();
}
// Custom draw: renders the widget background, label, path value, and folder icon
// Adapted from VHS VHSPATH draw + LiteGraph drawNodeWidgets default
function pathWidgetDraw(ctx, node, widget_width, y, H) {
const show_text = app.canvas.ds.scale >= (app.canvas.low_quality_zoom_threshold ?? 0.5);
const margin = 15;
const text_color = LiteGraph.WIDGET_TEXT_COLOR;
const secondary_text_color = LiteGraph.WIDGET_SECONDARY_TEXT_COLOR;
// Background pill
ctx.textAlign = "left";
ctx.strokeStyle = LiteGraph.WIDGET_OUTLINE_COLOR;
ctx.fillStyle = LiteGraph.WIDGET_BGCOLOR;
ctx.beginPath();
if (show_text)
ctx.roundRect(margin, y, widget_width - margin * 2, H, [H * 0.5]);
else
ctx.rect(margin, y, widget_width - margin * 2, H);
ctx.fill();
if (!show_text) return;
if (!this.disabled) ctx.stroke();
ctx.save();
ctx.beginPath();
ctx.rect(margin, y, widget_width - margin * 2, H);
ctx.clip();
// Label on left
let freeWidth = widget_width - (margin * 2 + ICON_ZONE + 20);
ctx.fillStyle = secondary_text_color;
const label = this.label || this.name;
if (label != null) {
const [labelDisplay, labelWidth] = fitText(ctx, label, freeWidth);
freeWidth -= labelWidth;
ctx.fillText(labelDisplay, margin * 2, y + H * 0.7);
}
// Path value on right (before icon zone)
ctx.fillStyle = this.value ? text_color : "#777";
ctx.textAlign = "right";
const valueText = String(this.value || this.options?.placeholder || "");
const [displayText] = fitPath(ctx, valueText, freeWidth);
ctx.fillText(displayText, widget_width - margin - ICON_ZONE, y + H * 0.7);
ctx.restore();
// Folder icon on right edge
ctx.save();
const iconCx = widget_width - margin - ICON_ZONE / 2;
const iconCy = y + H / 2;
// Subtle separator line
ctx.strokeStyle = LiteGraph.WIDGET_OUTLINE_COLOR;
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo(widget_width - margin - ICON_ZONE, y + 3);
ctx.lineTo(widget_width - margin - ICON_ZONE, y + H - 3);
ctx.stroke();
drawFolderIcon(ctx, iconCx, iconCy, H * 0.7);
ctx.restore();
}
// Mouse handler: opens file browser on pointer-down only (VHS searchBox pattern)
// Returning truthy tells LiteGraph we handled this event — skip default prompt
function pathWidgetMouse(event, pos, node) {
if (event.type !== "pointerdown") return true;
if (this._browseOpen) return true;
this._browseOpen = true;
const widget = this;
const config = this._browseConfig;
const currentValue = this.value || "";
let startPath = "";
if (currentValue) {
const dirPart = currentValue.replace(/[\\/][^\\/]*$/, "");
if (dirPart && dirPart !== currentValue) startPath = dirPart;
else startPath = currentValue;
}
openFileBrowser({
startPath,
extensions: config.extensions,
title: config.mode === "directory" ? "Select Folder" : "Select File",
mode: config.mode,
onSelect: (selectedPath) => {
widget.value = config.isSequence ? detectSequencePattern(selectedPath) : selectedPath;
widget.callback?.(widget.value);
app.graph.setDirtyCanvas(true);
},
onClose: () => {
widget._browseOpen = false;
},
});
return true;
}
function injectBrowseWidget(node, target) {
const widgetIndex = node.widgets?.findIndex(w => w.name === target.widget);
if (widgetIndex == null || widgetIndex < 0) return;
const original = node.widgets[widgetIndex];
if (original._hasBrowse) return;
// Replace the original STRING widget with a fresh custom-typed widget.
// Changing type on an existing "text" widget is not enough — LiteGraph
// still treats it as text internally and opens the default "Value" prompt.
// Creating a new widget object (like VHS VHSPATH) ensures it was never
// type "text", so processNodeWidgets skips the prompt entirely.
const replacement = {
name: original.name,
type: "COCO.PATH",
value: original.value || "",
options: original.options || {},
callback: original.callback,
y: original.y,
last_y: original.last_y,
_hasBrowse: true,
_browseOpen: false,
_browseConfig: {
extensions: target.extensions,
mode: target.mode,
isSequence: target.nodeClass === "LoadExrSequence",
},
draw: pathWidgetDraw,
mouse: pathWidgetMouse,
computeSize(width) {
return [width, LiteGraph.NODE_WIDGET_HEIGHT];
},
};
node.widgets[widgetIndex] = replacement;
}
app.registerExtension({
name: "CocoTools.FileBrowser",
async nodeCreated(node) {
const nodeClass = node.constructor.type || node.type;
for (const target of BROWSE_TARGETS) {
if (nodeClass === target.nodeClass) {
injectBrowseWidget(node, target);
break;
}
}
},
});