diff --git a/CHANGELOG.md b/CHANGELOG.md index 511785d..def453d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -144,6 +144,27 @@ The format is inspired by Keep a Changelog and SemVer. [np-vue]: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/components/node/NodePreview.vue +### Changed +- **Hover preview rebuilt to read like a real node mock.** The original + layout (HSL-hashed title strip + two-column slot table + flat widget + text list) read as a generic info card. Reworked to mirror upstream + `NodePreview.vue` more faithfully: a flat header row (small colored + dot + node display name) over a red **PREVIEW** badge, slot rows + laid out as a 5-column grid (`[dot] [input-name] [spacer] [output-name] [dot]`) + so inputs and outputs line up horizontally as parallel sockets, and + widgets rendered as individual rounded pills with `◀ name [spacer] + value ▶` chrome to mimic LiteGraph widget UI. **Bigger fix + underneath:** `readSlots` and the title / category / description + reads were looking at static `nodeClass.INPUT_TYPES` / + `RETURN_TYPES` properties, which **don't exist** on + ComfyUI-registered nodes — ComfyUI parks the original V1 def at + `nodeClass.nodeData` (set by `litegraphService.ts`'s + `registerNodeDef` right before `LiteGraph.registerNodeType`). Every + card was rendering as just-the-header-and-badge because the slot + arrays came back empty. Reading from `nodeData` first (with the old + property names as a legacy fallback for non-ComfyUI registered + nodes) fixes the empty-card class. (#78) + ### Fixed - **`pinExpanded` paths now expand on the immediate render** instead of being delayed by one. `buildFolder`'s expansion-resolution chain gained diff --git a/web/sidebar/constants.js b/web/sidebar/constants.js index 71788b8..0a81ebd 100644 --- a/web/sidebar/constants.js +++ b/web/sidebar/constants.js @@ -181,25 +181,26 @@ export function ensureStyle() { .koolook-mode-toggle-btn.koolook-mode-active { background: rgba(80,140,235,0.25); opacity: 1; } .koolook-leaf-unresolved { opacity: 0.55; font-style: italic; } .koolook-leaf-crumb { opacity: 0.5; font-size: 11px; margin-right: 1px; } -.koolook-preview-card { position: fixed; z-index: 10000; background: var(--comfy-menu-bg, #2a2a2a); border: 1px solid var(--border-color, rgba(255,255,255,0.18)); border-radius: 6px; box-shadow: 0 6px 24px rgba(0,0,0,0.55); color: var(--input-text, #ddd); font-size: 12px; width: 320px; max-width: 90vw; max-height: calc(100vh - 16px); pointer-events: none; overflow: hidden auto; } -.koolook-preview-title { padding: 6px 10px; font-size: 13px; font-weight: 600; color: #fff; } -.koolook-preview-title-unresolved { background: rgba(180,80,80,0.4); } -.koolook-preview-cat { padding: 3px 10px 5px; font-size: 10px; opacity: 0.6; border-bottom: 1px solid rgba(255,255,255,0.05); font-family: monospace; } -.koolook-preview-stub { padding: 10px; font-style: italic; opacity: 0.7; } -.koolook-preview-io { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: rgba(255,255,255,0.06); } -.koolook-preview-col { padding: 7px 9px; background: var(--comfy-menu-bg, #2a2a2a); } -.koolook-preview-section-title { font-size: 10px; opacity: 0.5; text-transform: uppercase; letter-spacing: 0.07em; margin-bottom: 4px; } -.koolook-preview-slot-row { display: flex; align-items: center; gap: 5px; padding: 1px 0; line-height: 1.35; } -.koolook-preview-slot-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; box-shadow: 0 0 0 1px rgba(0,0,0,0.4); } -.koolook-preview-slot-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.koolook-preview-slot-name.koolook-preview-slot-optional { opacity: 0.7; font-style: italic; } -.koolook-preview-slot-type { font-size: 10px; opacity: 0.5; flex-shrink: 0; font-family: monospace; max-width: 50%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.koolook-preview-empty { opacity: 0.4; font-size: 11px; padding: 1px 0; } -.koolook-preview-widgets { padding: 6px 9px; border-top: 1px solid rgba(255,255,255,0.06); } -.koolook-preview-widget-row { display: flex; gap: 6px; padding: 1px 0; line-height: 1.35; } -.koolook-preview-widget-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.koolook-preview-widget-default { opacity: 0.5; font-size: 10px; font-family: monospace; max-width: 60%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-shrink: 0; } -.koolook-preview-desc { padding: 6px 9px; border-top: 1px solid rgba(255,255,255,0.06); font-size: 11px; opacity: 0.78; line-height: 1.4; max-height: 100px; overflow: hidden; } +.koolook-preview-card { position: fixed; z-index: 10000; background: var(--comfy-menu-bg, #232323); border: 1px solid var(--border-color, rgba(255,255,255,0.22)); border-radius: 12px; box-shadow: 0 6px 24px rgba(0,0,0,0.55); color: var(--input-text, #ddd); font-size: 12px; min-width: 300px; max-width: 90vw; max-height: calc(100vh - 16px); pointer-events: none; overflow: hidden auto; padding-bottom: 10px; } +.koolook-preview-header { display: flex; align-items: center; gap: 9px; padding: 8px 13px 7px; font-size: 14px; line-height: 1; white-space: nowrap; overflow: hidden; } +.koolook-preview-headtitle { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; } +.koolook-preview-header-unresolved .koolook-preview-headtitle { font-style: italic; opacity: 0.75; } +.koolook-preview-headdot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; box-shadow: 0 0 0 1px rgba(0,0,0,0.4); } +.koolook-preview-badge { padding: 0 13px 7px; color: #ff5f6d; font-size: 11px; letter-spacing: 0.08em; font-weight: 600; text-transform: uppercase; } +.koolook-preview-stub { margin: 4px 10px 6px; padding: 8px 10px; background: rgba(255,255,255,0.04); border-radius: 6px; font-style: italic; opacity: 0.78; line-height: 1.4; } +.koolook-preview-row { display: grid; grid-template-columns: 14px 1fr 14px 1fr 14px; column-gap: 8px; align-items: center; padding: 0 9px; line-height: 1.55; min-height: 18px; } +.koolook-preview-row-slot { padding: 2px 9px; } +.koolook-preview-col { overflow: hidden; min-width: 0; } +.koolook-preview-col-input { text-align: left; text-overflow: ellipsis; white-space: nowrap; } +.koolook-preview-col-output { text-align: right; text-overflow: ellipsis; white-space: nowrap; } +.koolook-preview-col-middle { /* spacer, intentionally empty */ } +.koolook-preview-slot-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; box-shadow: 0 0 0 1px rgba(0,0,0,0.4); vertical-align: middle; } +.koolook-preview-slot-optional { opacity: 0.7; font-style: italic; } +.koolook-preview-row-widget { background: rgba(0,0,0,0.28); border: 1px solid rgba(255,255,255,0.09); margin: 5px 6px 0; border-radius: 9px; padding: 0 6px; line-height: 1.65; } +.koolook-preview-arrow { color: rgba(255,255,255,0.6); font-size: 9px; text-align: center; user-select: none; } +.koolook-preview-widget-name { text-align: left; text-overflow: ellipsis; white-space: nowrap; } +.koolook-preview-widget-value { text-align: right; font-size: 11px; opacity: 0.85; font-family: monospace; text-overflow: ellipsis; white-space: nowrap; } +.koolook-preview-desc { margin: 10px 9px 0; padding: 7px 9px; background: rgba(255,255,255,0.06); border-radius: 6px; font-style: italic; font-weight: 500; font-size: 11px; line-height: 1.45; word-break: break-word; opacity: 0.85; max-height: 120px; overflow: hidden; } `; document.head.appendChild(s); } diff --git a/web/sidebar/node_preview.js b/web/sidebar/node_preview.js index f357c73..f6bb6c1 100644 --- a/web/sidebar/node_preview.js +++ b/web/sidebar/node_preview.js @@ -129,11 +129,9 @@ export function renderPreviewCard(nodeClass, type) { // 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. - const title = document.createElement("div"); - title.className = "koolook-preview-title koolook-preview-title-unresolved"; - title.textContent = type; - card.appendChild(title); + // 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."; @@ -141,58 +139,48 @@ export function renderPreviewCard(nodeClass, type) { return card; } - const cat = (nodeClass.category && String(nodeClass.category)) || ""; - const titleText = (nodeClass.title && String(nodeClass.title)) || type; + // 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; - const title = document.createElement("div"); - title.className = "koolook-preview-title"; - title.textContent = titleText; - title.style.background = categoryColor(cat); - card.appendChild(title); + appendHeader(card, titleText, /* unresolved */ false, cat); - if (cat) { - const catEl = document.createElement("div"); - catEl.className = "koolook-preview-cat"; - catEl.textContent = cat; - card.appendChild(catEl); - } + // "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); - if (slots.inputs.length > 0 || slots.outputs.length > 0) { - const ioGrid = document.createElement("div"); - ioGrid.className = "koolook-preview-io"; - ioGrid.appendChild(renderSlotsColumn("Inputs", slots.inputs)); - ioGrid.appendChild(renderSlotsColumn("Outputs", slots.outputs)); - card.appendChild(ioGrid); + // 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); } - if (slots.widgets.length > 0) { - const wSec = document.createElement("div"); - wSec.className = "koolook-preview-widgets"; - const wTitle = document.createElement("div"); - wTitle.className = "koolook-preview-section-title"; - wTitle.textContent = "Widgets"; - wSec.appendChild(wTitle); - for (const w of slots.widgets) { - const wRow = document.createElement("div"); - wRow.className = "koolook-preview-widget-row"; - const name = document.createElement("span"); - name.className = "koolook-preview-widget-name"; - name.textContent = w.name; - wRow.appendChild(name); - const def = document.createElement("span"); - def.className = "koolook-preview-widget-default"; - const dStr = w.default !== undefined && w.default !== null - ? ` = ${truncate(String(w.default), 32)}` - : ""; - def.textContent = `${w.type}${dStr}`; - wRow.appendChild(def); - wSec.appendChild(wRow); - } - card.appendChild(wSec); + // 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() : ""; + 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"; @@ -203,51 +191,145 @@ export function renderPreviewCard(nodeClass, type) { return card; } -function renderSlotsColumn(label, slots) { - const col = document.createElement("div"); - col.className = "koolook-preview-col"; +// 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); +} - const t = document.createElement("div"); - t.className = "koolook-preview-section-title"; - t.textContent = label; - col.appendChild(t); +// 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"; - if (slots.length === 0) { - const empty = document.createElement("div"); - empty.className = "koolook-preview-empty"; - empty.textContent = "—"; - col.appendChild(empty); - return col; - } - for (const slot of slots) { - const row = document.createElement("div"); - row.className = "koolook-preview-slot-row"; + // 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(slot.type); - row.appendChild(dot); - const nameEl = document.createElement("span"); - nameEl.className = "koolook-preview-slot-name"; - if (slot.optional) nameEl.classList.add("koolook-preview-slot-optional"); - nameEl.textContent = slot.name; - row.appendChild(nameEl); - const tEl = document.createElement("span"); - tEl.className = "koolook-preview-slot-type"; - tEl.textContent = slot.type; - row.appendChild(tEl); - col.appendChild(row); + dot.style.background = slotColor(inputSlot.type); + inDot.appendChild(dot); } - return col; + 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's INPUT_TYPES is convention-driven: each input is `[type, config?]` -// where `type` is either a string (slot type like "IMAGE", "MODEL", or a -// scalar widget type like "INT") or an array of choices (a COMBO widget). -// The same convention is mirrored on the JS side because LiteGraph reads -// the Python-derived definitions verbatim. +// 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 @@ -261,28 +343,34 @@ function readSlots(nodeClass) { const widgets = []; const outputs = []; - let inputTypes = null; - try { - if (typeof nodeClass.INPUT_TYPES === "function") { - inputTypes = nodeClass.INPUT_TYPES(); - } else if (nodeClass.INPUT_TYPES && typeof nodeClass.INPUT_TYPES === "object") { - inputTypes = nodeClass.INPUT_TYPES; + 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 || "", e); + inputBlock = null; } - } catch (e) { - // Some custom-node packs throw from INPUT_TYPES under unusual - // conditions (missing sibling deps, lazy imports). Treat as - // "no input metadata available" and continue — the card still - // renders the title + outputs + description. 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 || "", e); - inputTypes = null; } - if (inputTypes && typeof inputTypes === "object") { + if (inputBlock && typeof inputBlock === "object") { for (const section of ["required", "optional"]) { - const block = inputTypes[section]; + 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; @@ -310,8 +398,17 @@ function readSlots(nodeClass) { } } - const returnTypes = Array.isArray(nodeClass.RETURN_TYPES) ? nodeClass.RETURN_TYPES : []; - const returnNames = Array.isArray(nodeClass.RETURN_NAMES) ? nodeClass.RETURN_NAMES : []; + // 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);