Codify attribution before the sidebar gets more visibility. NOTICE is the single human-readable source of truth — copyright statement, plain-English attribution request with a copy-pasteable credit line, pointer to LICENSE and forks/THIRD_PARTY.md so it shows the project respects others' attribution too. SPDX-FileCopyrightText / SPDX-License-Identifier headers added at the top of every web/*.js (entry point + sidebar/ + ai_pipeline) so license scanners (GitHub, FOSSA, reuse-tool) parse each file correctly and stripped-attribution forks become an obviously deliberate act rather than "I forgot to copy a file." GPL-3.0-only matches the LICENSE file (no "or later" wording in it). Year range 2025-2026 spans first commit (2025-09-28) to today. Comment-only change — no runtime impact. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
470 lines
20 KiB
JavaScript
470 lines
20 KiB
JavaScript
// SPDX-FileCopyrightText: 2025-2026 Kforge Labs <https://github.com/malkuthro/ComfyUI-Koolook>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
// =============================================================================
|
|
// Node preview — hover card for sidebar leaf rows.
|
|
//
|
|
// Mirrors ComfyUI's official Node Library preview (NodePreview.vue):
|
|
// plain HTML/CSS card with a colored title bar, two columns of type-colored
|
|
// slot dots (inputs / outputs), a widgets list, and an optional description.
|
|
// No real LiteGraph node is instantiated — the card is a styled mock built
|
|
// from the node class's static metadata, so opening a preview costs ~one
|
|
// fixed-position div, not a hidden canvas + simulated graph state.
|
|
//
|
|
// Wiring: `attachHoverPreview(rowEl, type)` registers mouseenter/mouseleave
|
|
// on a leaf row. mouseenter starts a 250ms debounce; on fire we look up
|
|
// `LiteGraph.registered_node_types[type]`, build the card, position it to
|
|
// the right of the row (flipping leftward if it'd overflow the viewport),
|
|
// and append to `<body>`. mouseleave clears the timer and removes any
|
|
// active card. Only one card is visible at a time — the module owns a
|
|
// singleton `activeCard` that's torn down before every fresh build.
|
|
//
|
|
// The card has `pointer-events: none` so it never intercepts mouse events
|
|
// from the rows it floats over — moving the cursor between rows fires
|
|
// the next row's mouseenter as expected, even when the card visually
|
|
// covers part of the sidebar.
|
|
//
|
|
// `teardownPreview()` is exported because `tree.js`'s `renderTree` clears
|
|
// the tree by `innerHTML = ""`, which detaches every row without firing
|
|
// `mouseleave`. Without an explicit teardown call from the renderer, an
|
|
// active card could survive a re-render — pointer-events:none keeps the
|
|
// user from dismissing it manually, so it'd hang there until the next
|
|
// successful hover. Calling `teardownPreview` before each render ensures
|
|
// any stray card is removed in lockstep with its anchor.
|
|
// =============================================================================
|
|
|
|
import { app } from "../../../../scripts/app.js";
|
|
|
|
const HOVER_DELAY_MS = 250;
|
|
const ROW_OFFSET_PX = 12;
|
|
const VIEWPORT_PADDING_PX = 8;
|
|
|
|
// Module-level singletons. We never need more than one card or one timer
|
|
// at a time; clearing both before each fresh build keeps the DOM and
|
|
// scheduling state in lockstep.
|
|
let activeCard = null;
|
|
let hoverTimer = null;
|
|
|
|
export function teardownPreview() {
|
|
if (hoverTimer) {
|
|
clearTimeout(hoverTimer);
|
|
hoverTimer = null;
|
|
}
|
|
if (activeCard) {
|
|
activeCard.remove();
|
|
activeCard = null;
|
|
}
|
|
}
|
|
|
|
// Tab-switch / window-blur teardown. The card's `pointer-events: none`
|
|
// makes it undismissable by mouse if the user navigates away mid-hover —
|
|
// keyboard-switching ComfyUI sidebar tabs, an overlay/modal stealing
|
|
// focus, or the page going hidden never crosses a row's mouseleave
|
|
// region, so the reactive teardown path doesn't fire. These global
|
|
// listeners cover those gaps. Wired exactly once per module load
|
|
// (top-level), and kept module-private so callers don't rewire them.
|
|
if (typeof document !== "undefined") {
|
|
document.addEventListener("visibilitychange", () => {
|
|
if (document.hidden) teardownPreview();
|
|
});
|
|
}
|
|
if (typeof window !== "undefined") {
|
|
window.addEventListener("blur", teardownPreview);
|
|
}
|
|
|
|
export function attachHoverPreview(rowEl, type) {
|
|
rowEl.addEventListener("mouseenter", () => {
|
|
teardownPreview();
|
|
hoverTimer = setTimeout(() => {
|
|
hoverTimer = null;
|
|
const nodeClass = readNodeClass(type);
|
|
const card = renderPreviewCard(nodeClass, type);
|
|
if (!card) return;
|
|
// Two-phase position: append off-screen so we can measure the
|
|
// card's actual rendered size, then move it to the final
|
|
// location based on row + viewport bounds.
|
|
card.style.position = "fixed";
|
|
card.style.visibility = "hidden";
|
|
card.style.left = "-9999px";
|
|
card.style.top = "0";
|
|
document.body.appendChild(card);
|
|
const cardRect = card.getBoundingClientRect();
|
|
const rowRect = rowEl.getBoundingClientRect();
|
|
const vw = window.innerWidth;
|
|
const vh = window.innerHeight;
|
|
// Right-side preferred (the sidebar is on the left in the
|
|
// common Kforge Labs layout). If the card would clip the
|
|
// right edge of the viewport, flip to the left of the row.
|
|
// The flipped position is also clamped so a wide card doesn't
|
|
// negative-overflow off the left edge of the screen.
|
|
const wouldOverflowRight = rowRect.right + ROW_OFFSET_PX + cardRect.width > vw;
|
|
const left = wouldOverflowRight
|
|
? Math.max(VIEWPORT_PADDING_PX, rowRect.left - ROW_OFFSET_PX - cardRect.width)
|
|
: rowRect.right + ROW_OFFSET_PX;
|
|
const top = Math.max(
|
|
VIEWPORT_PADDING_PX,
|
|
Math.min(vh - cardRect.height - VIEWPORT_PADDING_PX, rowRect.top),
|
|
);
|
|
card.style.left = `${left}px`;
|
|
card.style.top = `${top}px`;
|
|
card.style.visibility = "visible";
|
|
activeCard = card;
|
|
}, HOVER_DELAY_MS);
|
|
});
|
|
rowEl.addEventListener("mouseleave", teardownPreview);
|
|
}
|
|
|
|
// =============================================================================
|
|
// Card rendering
|
|
// =============================================================================
|
|
|
|
function readNodeClass(type) {
|
|
if (typeof LiteGraph === "undefined") return null;
|
|
const reg = LiteGraph.registered_node_types || {};
|
|
return reg[type] || null;
|
|
}
|
|
|
|
export function renderPreviewCard(nodeClass, type) {
|
|
const card = document.createElement("div");
|
|
card.className = "koolook-preview-card";
|
|
|
|
if (!nodeClass) {
|
|
// Pack not currently loaded. Same source-of-truth as the
|
|
// (unresolved) bucket in category-mode gather. The stub keeps the
|
|
// hover-preview affordance consistent across resolved/unresolved
|
|
// picks rather than silently no-oping for missing packs. Renders
|
|
// header + stub only, no slot grid — there's nothing to mock.
|
|
appendHeader(card, type, /* unresolved */ true);
|
|
const stub = document.createElement("div");
|
|
stub.className = "koolook-preview-stub";
|
|
stub.textContent = "Pack not loaded — use the ↓ Install missing button in the Tools row.";
|
|
card.appendChild(stub);
|
|
return card;
|
|
}
|
|
|
|
// Category / title / description — try the constructor-level properties
|
|
// first (set by some non-ComfyUI registered nodes), then fall through to
|
|
// `nodeData` (where ComfyUI's `registerNodeDef` parks the original def).
|
|
const def = nodeClass.nodeData || null;
|
|
const cat = (nodeClass.category && String(nodeClass.category))
|
|
|| (def && def.category && String(def.category))
|
|
|| "";
|
|
const titleText = (nodeClass.title && String(nodeClass.title))
|
|
|| (def && def.display_name && String(def.display_name))
|
|
|| type;
|
|
|
|
appendHeader(card, titleText, /* unresolved */ false, cat);
|
|
|
|
// "PREVIEW" badge — purely a visual marker that this is a static mock,
|
|
// not a live node. Mirrors upstream NodePreview.vue's `_sb_preview_badge`.
|
|
const badge = document.createElement("div");
|
|
badge.className = "koolook-preview-badge";
|
|
badge.textContent = "Preview";
|
|
card.appendChild(badge);
|
|
|
|
const slots = readSlots(nodeClass);
|
|
// Slot rows pair inputs and outputs horizontally — row i shows
|
|
// `[dot] [input[i].name] [spacer] [output[i].name] [dot]`. When one
|
|
// side is shorter, the other side's later cells are blank. This is
|
|
// what makes the card read as a node mock: the sockets line up the
|
|
// same way they do on the real node.
|
|
const pairCount = Math.max(slots.inputs.length, slots.outputs.length);
|
|
for (let i = 0; i < pairCount; i++) {
|
|
appendSlotPairRow(card, slots.inputs[i] || null, slots.outputs[i] || null);
|
|
}
|
|
|
|
// Widget rows — each rendered as a rounded "pill" with ◄ ► chrome to
|
|
// mimic LiteGraph widget UI. The triangles are static glyphs, no
|
|
// interaction; the value column shows the widget's default (or its
|
|
// first choice for COMBOs).
|
|
for (const w of slots.widgets) {
|
|
appendWidgetRow(card, w);
|
|
}
|
|
|
|
const description = (nodeClass.description && String(nodeClass.description).trim())
|
|
|| (def && def.description && String(def.description).trim())
|
|
|| "";
|
|
if (description) {
|
|
const desc = document.createElement("div");
|
|
desc.className = "koolook-preview-desc";
|
|
desc.textContent = description;
|
|
card.appendChild(desc);
|
|
}
|
|
|
|
return card;
|
|
}
|
|
|
|
// Header — colored dot + display name. The dot color is derived from the
|
|
// node's category via `categoryColor` so cards from the same category share
|
|
// a hue, making them visually groupable at a glance. Unresolved cards get
|
|
// the same shape with a muted dot and an "italic" title to read as a stub.
|
|
function appendHeader(card, title, unresolved, category) {
|
|
const header = document.createElement("div");
|
|
header.className = "koolook-preview-header";
|
|
if (unresolved) header.classList.add("koolook-preview-header-unresolved");
|
|
const dot = document.createElement("span");
|
|
dot.className = "koolook-preview-headdot";
|
|
dot.style.background = unresolved ? "rgba(180,120,120,0.7)" : categoryColor(category || "");
|
|
header.appendChild(dot);
|
|
const titleEl = document.createElement("span");
|
|
titleEl.className = "koolook-preview-headtitle";
|
|
titleEl.textContent = title;
|
|
header.appendChild(titleEl);
|
|
card.appendChild(header);
|
|
}
|
|
|
|
// One row in the 5-column slot grid: `[dot] [in-name] [middle] [out-name] [dot]`.
|
|
// Either slot may be null when the input/output counts are unequal — the
|
|
// corresponding cells render as empty placeholders so the grid alignment
|
|
// holds.
|
|
function appendSlotPairRow(card, inputSlot, outputSlot) {
|
|
const row = document.createElement("div");
|
|
row.className = "koolook-preview-row koolook-preview-row-slot";
|
|
|
|
// Column 1: input dot
|
|
const inDot = document.createElement("div");
|
|
inDot.className = "koolook-preview-col";
|
|
if (inputSlot) {
|
|
const dot = document.createElement("span");
|
|
dot.className = "koolook-preview-slot-dot";
|
|
dot.style.background = slotColor(inputSlot.type);
|
|
inDot.appendChild(dot);
|
|
}
|
|
row.appendChild(inDot);
|
|
|
|
// Column 2: input name (left-aligned)
|
|
const inName = document.createElement("div");
|
|
inName.className = "koolook-preview-col koolook-preview-col-input";
|
|
if (inputSlot) {
|
|
inName.textContent = inputSlot.name;
|
|
if (inputSlot.optional) inName.classList.add("koolook-preview-slot-optional");
|
|
inName.title = `${inputSlot.name}: ${inputSlot.type}`;
|
|
}
|
|
row.appendChild(inName);
|
|
|
|
// Column 3: middle spacer (empty by design)
|
|
const mid = document.createElement("div");
|
|
mid.className = "koolook-preview-col koolook-preview-col-middle";
|
|
row.appendChild(mid);
|
|
|
|
// Column 4: output name (right-aligned)
|
|
const outName = document.createElement("div");
|
|
outName.className = "koolook-preview-col koolook-preview-col-output";
|
|
if (outputSlot) {
|
|
outName.textContent = outputSlot.name;
|
|
outName.title = `${outputSlot.name}: ${outputSlot.type}`;
|
|
}
|
|
row.appendChild(outName);
|
|
|
|
// Column 5: output dot
|
|
const outDot = document.createElement("div");
|
|
outDot.className = "koolook-preview-col";
|
|
if (outputSlot) {
|
|
const dot = document.createElement("span");
|
|
dot.className = "koolook-preview-slot-dot";
|
|
dot.style.background = slotColor(outputSlot.type);
|
|
outDot.appendChild(dot);
|
|
}
|
|
row.appendChild(outDot);
|
|
|
|
card.appendChild(row);
|
|
}
|
|
|
|
// Widget pill — same 5-column grid shape as slot rows but with `◄` / `►`
|
|
// triangle glyphs in the dot columns and a rounded-bordered background to
|
|
// read as a LiteGraph widget. Value column truncates long defaults.
|
|
function appendWidgetRow(card, widget) {
|
|
const row = document.createElement("div");
|
|
row.className = "koolook-preview-row koolook-preview-row-widget";
|
|
|
|
const arrowL = document.createElement("div");
|
|
arrowL.className = "koolook-preview-col koolook-preview-arrow";
|
|
arrowL.textContent = "◀";
|
|
row.appendChild(arrowL);
|
|
|
|
const name = document.createElement("div");
|
|
name.className = "koolook-preview-col koolook-preview-widget-name";
|
|
name.textContent = widget.name;
|
|
name.title = `${widget.name} (${widget.type})`;
|
|
row.appendChild(name);
|
|
|
|
const mid = document.createElement("div");
|
|
mid.className = "koolook-preview-col koolook-preview-col-middle";
|
|
row.appendChild(mid);
|
|
|
|
const value = document.createElement("div");
|
|
value.className = "koolook-preview-col koolook-preview-widget-value";
|
|
if (widget.default !== undefined && widget.default !== null) {
|
|
value.textContent = truncate(String(widget.default), 24);
|
|
} else {
|
|
value.textContent = widget.type;
|
|
}
|
|
row.appendChild(value);
|
|
|
|
const arrowR = document.createElement("div");
|
|
arrowR.className = "koolook-preview-col koolook-preview-arrow";
|
|
arrowR.textContent = "▶";
|
|
row.appendChild(arrowR);
|
|
|
|
card.appendChild(row);
|
|
}
|
|
|
|
|
|
// =============================================================================
|
|
// Metadata extraction
|
|
//
|
|
// ComfyUI doesn't keep `INPUT_TYPES` / `RETURN_TYPES` as static properties on
|
|
// the registered constructor. Instead, the original Python-side node def
|
|
// (the V1 shape returned by `/object_info`) is stashed at `nodeClass.nodeData`
|
|
// during registration (see Comfy-Org/ComfyUI_frontend `litegraphService.ts`
|
|
// `registerNodeDef` — `node.nodeData = nodeDef` right before
|
|
// `LiteGraph.registerNodeType`). `nodeData` exposes:
|
|
//
|
|
// nodeData.input.required { name: ["TYPE", config?], ... } V1 shape
|
|
// nodeData.input.optional { name: ["TYPE", config?], ... } V1 shape
|
|
// nodeData.output ["TYPE1", "TYPE2", ...] V1 shape
|
|
// nodeData.output_name ["name1", "name2", ...] V1 shape
|
|
// nodeData.description string
|
|
// nodeData.category string
|
|
// nodeData.display_name string
|
|
//
|
|
// Plus a parallel V2 transform (`inputs` record / `outputs` array) — we read
|
|
// V1 because it's a 1-to-1 with the Python convention and matches the
|
|
// upstream `INPUT_TYPES` shape exactly. Fall back to direct
|
|
// `nodeClass.INPUT_TYPES` / `RETURN_TYPES` for any future or non-ComfyUI
|
|
// LiteGraph nodes that follow the old convention.
|
|
//
|
|
// "Widget" classification matches ComfyUI's frontend rule: scalar types
|
|
// (INT, FLOAT, STRING, BOOLEAN) and arrays-of-choices (COMBO). Anything
|
|
// else is a connection slot.
|
|
// =============================================================================
|
|
|
|
const SCALAR_WIDGET_TYPES = new Set(["INT", "FLOAT", "STRING", "BOOLEAN"]);
|
|
|
|
function readSlots(nodeClass) {
|
|
const inputs = [];
|
|
const widgets = [];
|
|
const outputs = [];
|
|
|
|
const def = nodeClass.nodeData || null;
|
|
|
|
// Inputs: prefer `nodeData.input` (V1 shape ComfyUI preserves verbatim);
|
|
// fall back to direct `INPUT_TYPES` for non-ComfyUI registered nodes.
|
|
let inputBlock = null;
|
|
if (def && def.input && typeof def.input === "object") {
|
|
inputBlock = def.input;
|
|
} else {
|
|
try {
|
|
if (typeof nodeClass.INPUT_TYPES === "function") {
|
|
inputBlock = nodeClass.INPUT_TYPES();
|
|
} else if (nodeClass.INPUT_TYPES && typeof nodeClass.INPUT_TYPES === "object") {
|
|
inputBlock = nodeClass.INPUT_TYPES;
|
|
}
|
|
} catch (e) {
|
|
// Some legacy packs throw from INPUT_TYPES under unusual
|
|
// conditions (missing sibling deps, lazy imports). Log so the
|
|
// failure is visible during pack debugging instead of silently
|
|
// rendering an empty-looking node. Matches the project-wide
|
|
// convention (see picks_store.js, canvas_io.js, installer.js).
|
|
console.warn("[Kforge Labs preview] INPUT_TYPES() threw for", nodeClass.title || "<no title>", e);
|
|
inputBlock = null;
|
|
}
|
|
}
|
|
|
|
if (inputBlock && typeof inputBlock === "object") {
|
|
for (const section of ["required", "optional"]) {
|
|
const block = inputBlock[section];
|
|
if (!block || typeof block !== "object") continue;
|
|
for (const [name, spec] of Object.entries(block)) {
|
|
if (!Array.isArray(spec) || spec.length === 0) continue;
|
|
const t = spec[0];
|
|
const config = (spec[1] && typeof spec[1] === "object") ? spec[1] : {};
|
|
if (Array.isArray(t)) {
|
|
// COMBO — first element is the array of choices, default
|
|
// is either explicit or the first choice.
|
|
const fallback = t.length > 0 ? t[0] : undefined;
|
|
widgets.push({
|
|
name,
|
|
type: "COMBO",
|
|
default: config.default !== undefined ? config.default : fallback,
|
|
});
|
|
} else if (typeof t === "string" && SCALAR_WIDGET_TYPES.has(t)) {
|
|
widgets.push({ name, type: t, default: config.default });
|
|
} else {
|
|
inputs.push({
|
|
name,
|
|
type: typeof t === "string" ? t : String(t),
|
|
optional: section === "optional",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Outputs: prefer `nodeData.output` (parallel `output_name` for labels);
|
|
// fall back to direct `RETURN_TYPES` / `RETURN_NAMES` on the constructor.
|
|
let returnTypes = [];
|
|
let returnNames = [];
|
|
if (def && Array.isArray(def.output)) {
|
|
returnTypes = def.output;
|
|
returnNames = Array.isArray(def.output_name) ? def.output_name : [];
|
|
} else if (Array.isArray(nodeClass.RETURN_TYPES)) {
|
|
returnTypes = nodeClass.RETURN_TYPES;
|
|
returnNames = Array.isArray(nodeClass.RETURN_NAMES) ? nodeClass.RETURN_NAMES : [];
|
|
}
|
|
for (let i = 0; i < returnTypes.length; i++) {
|
|
const t = returnTypes[i];
|
|
const tStr = typeof t === "string" ? t : String(t);
|
|
const name = returnNames[i] != null ? String(returnNames[i]) : tStr;
|
|
outputs.push({ name, type: tStr });
|
|
}
|
|
return { inputs, widgets, outputs };
|
|
}
|
|
|
|
// =============================================================================
|
|
// Color helpers
|
|
// =============================================================================
|
|
|
|
// Hash a category path into a stable HSL hue. Keeps related-category cards
|
|
// visually distinct without maintaining a hand-mapped palette. Saturation
|
|
// + lightness are dialed for legibility on the sidebar's dark background.
|
|
function categoryColor(category) {
|
|
if (!category) return "rgba(120,120,120,0.6)";
|
|
let hash = 0;
|
|
for (let i = 0; i < category.length; i++) {
|
|
hash = ((hash << 5) - hash + category.charCodeAt(i)) | 0;
|
|
}
|
|
const hue = Math.abs(hash) % 360;
|
|
return `hsl(${hue}, 38%, 32%)`;
|
|
}
|
|
|
|
// Slot dot color — match the canvas's wire colors so a preview and the
|
|
// on-canvas wires read as the same palette. ComfyUI populates the rich
|
|
// per-type palette at runtime on the canvas instance
|
|
// (`default_connection_color_byType`), while LiteGraph's static
|
|
// `link_type_colors` only carries a stub set (`-1`, `number`, `node`).
|
|
// Read the runtime palette first; fall back through the static map; fall
|
|
// back to neutral grey for unknown slot types so the card still renders
|
|
// rather than emitting transparent-dot rows.
|
|
function slotColor(type) {
|
|
const key = String(type);
|
|
// Both palettes are normally string-keyed maps of CSS color strings,
|
|
// but a misbehaving custom-node pack could mutate the runtime palette
|
|
// to hold a non-string (object, function, etc.). Assigning a
|
|
// non-string to `dot.style.background` silently no-ops, leaving a
|
|
// transparent dot. Guarding both branches with a `typeof === "string"`
|
|
// check makes the fallback chain robust against that, so the worst
|
|
// case is still a visible neutral-grey dot.
|
|
const runtime = (app && app.canvas && app.canvas.default_connection_color_byType) || null;
|
|
if (runtime && typeof runtime[key] === "string") return runtime[key];
|
|
const staticMap = (typeof LiteGraph !== "undefined"
|
|
&& LiteGraph.LGraphCanvas
|
|
&& LiteGraph.LGraphCanvas.link_type_colors) || {};
|
|
if (typeof staticMap[key] === "string") return staticMap[key];
|
|
return "rgba(180,180,180,0.5)";
|
|
}
|
|
|
|
function truncate(s, n) {
|
|
return s.length > n ? `${s.slice(0, n - 1)}…` : s;
|
|
}
|