From 9ef27d0442aeecc164ec6dea22fe605e3fe010cf Mon Sep 17 00:00:00 2001 From: millerlight Date: Sun, 10 Aug 2025 05:29:12 +0200 Subject: [PATCH] improve text view --- web/js/extension.js | 860 ++++++++++++++++++++++++++++++-------------- 1 file changed, 600 insertions(+), 260 deletions(-) diff --git a/web/js/extension.js b/web/js/extension.js index 0cced59..839bd5b 100644 --- a/web/js/extension.js +++ b/web/js/extension.js @@ -1,43 +1,30 @@ -// ComfyUI-KVTools — Frontend -// Version: x-0 -// - Dropdown (key_select) + Text-Preview + Bild-Preview -// - Buttons: refresh / random / default set & load -// - Inline edit for KVLoadInline (pause outputs while editing) -// - Autorun + Queue-Prompt-Fallback -// - Random autorun: when random=true, do NOT default to the first key; pick randomly earlier -// - Registry-Helpers (peek/image) +// ComfyUI-KVTools — Frontend UI +// - No auto-run on UI changes +// - Real multi-line preview with scrollbar (custom widget "preview_value") +// - Global event routing in CAPTURE phase (pointer + wheel) to prevent canvas zoom over the preview +// - Inline image preview (spacer stack) +// - Random key via input/toggle (UI only) + random initialization +// - Inline edit mode for KVLoadInline (disables outputs while editing) +// - "as_type" is placed directly below the preview_value block import { app } from "/scripts/app.js"; import { api } from "/scripts/api.js"; const EXT_NAME = "ComfyUI-KVTools.UI"; -const KVTOOLS_AUTORUN = true; +const KVTOOLS_AUTORUN = false; // absolutely no auto-queue from UI events -// --- Preview target height & layout --- -const KV_PREVIEW_HEIGHT = 140; // desired preview height -const KV_PREVIEW_GAP = 6; // when hidden: small gap -const KV_UNIT_FALLBACK = 20; // expected default per-widget height (your build: 20) -const KV_DEBUG_LAYOUT = false; // set true for layout logs - -let kvtoolsIsProcessing = false; -api.addEventListener("status", (e) => { kvtoolsIsProcessing = !!e?.detail?.processing; }); - -const kvRunLock = { busy: false, last: 0, cooldownMs: 500 }; -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); - -function dlog(...a){ if(KV_DEBUG_LAYOUT) try{ console.debug("[KVTools][layout]",...a);}catch{} } +// Inline image preview layout +const KV_PREVIEW_IMG_HEIGHT = 140; +const KV_IMG_UNIT = 20; +const KV_IMG_GAP = 6; // ----------------------------------------------------- // Helpers // ----------------------------------------------------- +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); function W(node, name) { return (node?.widgets || []).find((w) => w.name === name) || null; } function hideWidget(node, name) { const w = W(node, name); if (!w) return; w.hidden = true; w.draw = () => {}; w.computeSize = () => [0,-4]; } -function moveWidgetToTop(node, widgetOrName) { - const w = typeof widgetOrName === "string" ? W(node, widgetOrName) : widgetOrName; - if (!node?.widgets || !w) return; - const i = node.widgets.indexOf(w); - if (i > 0) { node.widgets.splice(i,1); node.widgets.unshift(w); } -} + function ensureAsTypeDefault(node) { const w = W(node, "as_type"); if (w && !["string","int","float","bool"].includes(String(w.value))) w.value = "string"; @@ -54,7 +41,7 @@ function upstreamFileName(node) { } // ----------------------------------------------------- -// Registry + Store Parsing +// Registry + store parsing // ----------------------------------------------------- let REGISTRY = null; async function loadRegistry() { @@ -102,41 +89,502 @@ function keysFromRegistryByFile(fileName) { } // ----------------------------------------------------- -// Text preview (without Run) +// Scrollable multi-line widget for preview_value (+ spacer stack) // ----------------------------------------------------- -function ensurePreviewWidget(node) { - let pv = W(node, "_kvtools_preview_value"); - if (!pv) { pv = node.addWidget("text","_kvtools_preview_value","",null); pv.serialize=false; } +function wrapLines(ctx, text, maxW) { + const lines = []; + const paras = String(text ?? "").split(/\r?\n/); + for (const para of paras) { + const words = para.split(/\s+/); + let line = ""; + for (const w of words) { + const t = line ? line + " " + w : w; + if (ctx.measureText(t).width <= maxW) line = t; + else { + if (line) lines.push(line); + if (ctx.measureText(w).width > maxW) { + let chunk = ""; + for (const ch of w) { + const tmp = chunk + ch; + if (ctx.measureText(tmp).width > maxW) { + if (chunk) lines.push(chunk); + chunk = ch; + } else { + chunk = tmp; + } + } + if (chunk) lines.push(chunk); + line = ""; + } else line = w; + } + } + lines.push(line); + } + return lines; +} + +function createScrollablePreviewWidget(node) { + const addCW = (spec) => { + if (typeof node.addCustomWidget === "function") return node.addCustomWidget(spec); + const fw = node.addWidget("string", spec.name, "", null); + Object.assign(fw, spec, { type: "custom" }); + return fw; + }; + + const pv = addCW({ + name: "preview_value", + serialize: false, + hidden: false, + + // layout/state + __unit: 20, // base height per slot (aligned with image stack unit) + __pad: 8, + __lineH: 18, + __font: "12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace", + __visibleLines: 4, // visible lines (height provided by spacers) + __scroll: 0, + __contentH: 0, + __rect: { x: 0, y: 0, w: 0, h: 0 }, // node-local rect + __rect_canvas: null, // canvas-global rect (for hit-tests) + __sb: null, // {x,y,w,h,trackX,trackY,trackH,trackW} + __drag: false, + __dragStartY: 0, + __scrollStart: 0, + value: "", + + computeSize(width) { return [width || 0, this.__unit]; }, // total height comes from spacers + + draw(ctx, nodeRef, widgetWidth, y, height) { + const nodeW = (nodeRef.size && nodeRef.size[0]) || 320; + const wIn = Math.max(0, (widgetWidth ?? nodeW) - 20); + const x = 10; + + // total height = primary slot + N spacers + const extraCount = Array.isArray(nodeRef.__kv_pv_spacers) ? nodeRef.__kv_pv_spacers.length : 0; + const totalH = this.__unit * (1 + extraCount); + + // store rects (node-local + canvas-global) + this.__rect = { x, y, w: wIn, h: totalH }; + const nx = (nodeRef.pos?.[0] || 0); + const ny = (nodeRef.pos?.[1] || 0); + this.__rect_canvas = { x: nx + x, y: ny + y, w: wIn, h: totalH }; + + // box styling + ctx.save(); + ctx.fillStyle = "#161616"; + ctx.fillRect(x, y, wIn, totalH); + ctx.strokeStyle = "#2b2b2b"; + ctx.lineWidth = 1; + ctx.strokeRect(x + 0.5, y + 0.5, wIn - 1, totalH - 1); + + // inner viewport + const left = x + this.__pad; + const top = y + this.__pad; + const maxW = wIn - this.__pad * 2; + const maxH = totalH - this.__pad * 2; + + ctx.font = this.__font; + ctx.fillStyle = "#d7d7d7"; + ctx.textBaseline = "top"; + + const lines = wrapLines(ctx, this.value ?? "", maxW); + this.__contentH = Math.max(lines.length * this.__lineH, 0); + + // clamp scroll + const maxScroll = Math.max(this.__contentH - maxH, 0); + if (this.__scroll > maxScroll) this.__scroll = maxScroll; + if (this.__scroll < 0) this.__scroll = 0; + + // clip + draw visible lines + ctx.save(); + ctx.beginPath(); + ctx.rect(left, top, maxW, maxH); + ctx.clip(); + + const firstLine = Math.floor(this.__scroll / this.__lineH); + const offsetY = -(this.__scroll % this.__lineH); + + for (let i = firstLine, yOff = offsetY; i < lines.length && (yOff < maxH); i++, yOff += this.__lineH) { + ctx.fillText(lines[i], left, top + yOff); + } + ctx.restore(); + + // scrollbar (store in CANVAS coordinates for hit-tests) + if (this.__contentH > maxH) { + const trackW = 10; // easier to grab + const trackX = x + wIn - trackW - 4; + const trackY = y + 2; + const trackH = totalH - 4; + + ctx.fillStyle = "#202020"; + ctx.fillRect(trackX, trackY, trackW, trackH); + + const thumbH = Math.max((maxH / this.__contentH) * trackH, 20); + const thumbY = trackY + (this.__scroll / Math.max(this.__contentH, 1)) * (trackH - thumbH); + + ctx.fillStyle = "#5a5a5a"; + ctx.fillRect(trackX, thumbY, trackW, thumbH); + + // store in CANVAS coords + this.__sb = { + x: nx + trackX, y: ny + thumbY, w: trackW, h: thumbH, + trackX: nx + trackX, trackY: ny + trackY, trackH, trackW + }; + } else { + this.__sb = null; + } + + ctx.restore(); + }, + + // Interactions — pos is in CANVAS coordinates + onMouseWheel(e, pos, nodeRef) { + const r = this.__rect_canvas; if (!r) return false; + const { x, y, w, h } = r; const [px, py] = pos; + if (px < x || px > x + w || py < y || py > y + h) return false; + + const maxH = h - this.__pad * 2; + const step = Math.max(this.__lineH, maxH * 0.15); + const delta = e.deltaY || e.wheelDelta || 0; + + this.__scroll += (delta > 0 ? step : -step); + + const maxScroll = Math.max(this.__contentH - maxH, 0); + if (this.__scroll > maxScroll) this.__scroll = maxScroll; + if (this.__scroll < 0) this.__scroll = 0; + + nodeRef.setDirtyCanvas?.(true, true); + return true; // handled -> router will block Comfy canvas zoom + }, + + onMouseDown(e, pos, nodeRef) { + const sb = this.__sb; + if (!sb) return false; + const [px, py] = pos; + + // hit slop around thumb (6px left/right) + const hx = sb.x - 6, hw = sb.w + 12; + const thumbHit = (px >= hx && px <= hx + hw && py >= sb.y && py <= sb.y + sb.h); + if (thumbHit) { + this.__drag = true; + this.__dragStartY = py; + this.__scrollStart = this.__scroll; + return true; + } + + // clicking the track jumps thumb there and begins dragging + const inTrack = (px >= sb.trackX - 6 && px <= sb.trackX + sb.trackW + 6 && py >= sb.trackY && py <= sb.trackY + sb.trackH); + if (inTrack) { + const maxH = this.__rect_canvas.h - this.__pad * 2; + const maxScroll = Math.max(this.__contentH - maxH, 0); + const trackScrollable = Math.max(sb.trackH - sb.h, 1); + const rel = Math.min(Math.max(py - sb.trackY - sb.h / 2, 0), trackScrollable); + const ratio = rel / trackScrollable; + + this.__scroll = ratio * maxScroll; + this.__drag = true; + this.__dragStartY = py; + this.__scrollStart = this.__scroll; + + nodeRef.setDirtyCanvas?.(true, true); + return true; + } + + return false; + }, + + onMouseMove(e, pos, nodeRef) { + if (!this.__drag || !this.__rect_canvas || !this.__sb) return false; + const maxH = this.__rect_canvas.h - this.__pad * 2; + const maxScroll = Math.max(this.__contentH - maxH, 0); + if (maxScroll <= 0) return false; + + const trackScrollable = Math.max(this.__sb.trackH - this.__sb.h, 1); + const dy = pos[1] - this.__dragStartY; + const scrollDelta = (dy / trackScrollable) * maxScroll; + + this.__scroll = Math.min(Math.max(this.__scrollStart + scrollDelta, 0), maxScroll); + nodeRef.setDirtyCanvas?.(true, true); + return true; + }, + + onMouseUp() { this.__drag = false; return false; } + }); + + // place directly under key_select + const ks = W(node, "key_select"); + if (ks) { + const idxKS = node.widgets.indexOf(ks); + const idxPV = node.widgets.indexOf(pv); + const want = Math.min(idxKS + 1, node.widgets.length); + if (idxPV !== -1 && idxPV !== want) { + node.widgets.splice(idxPV, 1); + node.widgets.splice(want, 0, pv); + } + } + + pv.readonly = true; + pv.disabled = true; + pv.click = () => false; + + ensurePVSpacerStack(node, pv.__visibleLines); + node.widgets_dirty = true; + node.setDirtyCanvas?.(true, true); return pv; } -async function updateTextPreview(node) { - const pv = ensurePreviewWidget(node); - const up = upstreamNode(node); - pv.value = ""; - if (!up) { node.setDirtyCanvas(true,true); return; } - const key = String(W(node,"key_select")?.value || W(node,"key")?.value || "").trim(); - if (!key) { node.setDirtyCanvas(true,true); return; } +function ensurePreviewWidgetPresent(node) { + const idx = (node.widgets || []).findIndex(w => w.name === "preview_value"); + if (idx !== -1) node.widgets.splice(idx, 1); + return createScrollablePreviewWidget(node); +} + +function ensurePVSpacerStack(node, visibleLines = 4) { + const pv = W(node, "preview_value"); + if (!pv) return; + + const target = pv.__pad * 2 + pv.__lineH * Math.max(1, visibleLines|0); + const unit = pv.__unit || 20; + + const needExtras = Math.max(0, Math.ceil(target / unit) - 1); + const current = Array.isArray(node.__kv_pv_spacers) ? node.__kv_pv_spacers : (node.__kv_pv_spacers = []); + + while (current.length > needExtras) { + const w = current.pop(); + const idx = node.widgets.indexOf(w); + if (idx >= 0) node.widgets.splice(idx, 1); + } + + const addCW = (spec)=>{ + if (typeof node.addCustomWidget === "function") return node.addCustomWidget(spec); + const fw = node.addWidget("string", spec.name, "", null); + Object.assign(fw, spec, { type:"custom", value:"" }); + return fw; + }; + while (current.length < needExtras) { + const idx = current.length + 1; + const w = addCW({ + name: `_kvtools_pv_sp${idx}`, + serialize: false, + draw(ctx, nodeRef, widgetWidth, y, height) { /* spacer */ }, + computeSize(width){ return [width || 0, unit]; } + }); + current.push(w); + } + + // keep group (preview + spacers) directly after key_select + const ks = W(node, "key_select"); + if (ks) { + const idxKS = node.widgets.indexOf(ks); + const wantPV = Math.min(idxKS + 1, node.widgets.length); + const idxPV = node.widgets.indexOf(pv); + if (idxPV !== -1 && idxPV !== wantPV) { + node.widgets.splice(idxPV, 1); + node.widgets.splice(wantPV, 0, pv); + } + for (let i = 0; i < current.length; i++) { + const w = current[i]; + const should = node.widgets.indexOf(pv) + 1 + i; + const idxW = node.widgets.indexOf(w); + if (idxW !== should) { + if (idxW !== -1) node.widgets.splice(idxW, 1); + node.widgets.splice(should, 0, w); + } + } + } + + // ensure as_type is right below the preview block + placeAsTypeBelowPreview(node); + + node.widgets_dirty = true; + node.setDirtyCanvas?.(true, true); +} + +// Place as_type directly under the preview_value block (after its spacers) +function placeAsTypeBelowPreview(node) { + if (!node) return; + const pv = W(node, "preview_value"); + const as = W(node, "as_type"); + if (!pv || !as) return; + + const widgets = node.widgets || []; + const idxPV = widgets.indexOf(pv); + if (idxPV < 0) return; + + // find the last spacer belonging to preview_value + let lastIdx = idxPV; + for (let i = idxPV + 1; i < widgets.length; i++) { + const w = widgets[i]; + if (w && typeof w.name === "string" && /^_kvtools_pv_sp\d+$/.test(w.name)) { + lastIdx = i; + } else { + break; + } + } + + // move as_type behind the preview block + const idxAS = widgets.indexOf(as); + if (idxAS < 0) return; + const target = lastIdx + 1; + + if (idxAS !== target) { + widgets.splice(idxAS, 1); + const clamped = Math.min(target, widgets.length); + widgets.splice(clamped, 0, as); + node.widgets_dirty = true; + node.setDirtyCanvas?.(true, true); + } +} + +// set text into preview (and cache) +async function updateTextPreview(node) { + const up = upstreamNode(node); + const pv = ensurePreviewWidgetPresent(node); + ensurePVSpacerStack(node, 4); + if (pv) { pv.hidden = false; pv.value = ""; } + node.__kv_preview_text = ""; + + if (!up) { node.setDirtyCanvas(true, true); return; } + + const key = String( + (node?.widgets || []).find(w => w.name === "key_select")?.value || + (node?.widgets || []).find(w => w.name === "key")?.value || + "" + ).trim(); + + if (!key) { node.setDirtyCanvas(true, true); return; } if (up.comfyClass === "KVLoadInline") { - if (up.__kv_edit === true) { pv.value=""; node.setDirtyCanvas(true,true); return; } - const dataW = W(up,"data"); const text = String(dataW?.value||""); - const obj = text.trim().startsWith("{") ? parseStoreObject(text) : parseEnvFile(text); - let v = obj[key]; if (v && typeof v==="object"){ try{ v=JSON.stringify(v);}catch{} } - pv.value = v != null ? String(v) : ""; node.setDirtyCanvas(true,true); return; + if (up.__kv_edit === true) { if (pv) pv.value = ""; node.setDirtyCanvas(true, true); return; } + const dataW = (up.widgets || []).find(w => w.name === "data"); + const raw = String(dataW?.value || ""); + const obj = raw.trim().startsWith("{") ? parseStoreObject(raw) : parseEnvFile(raw); + let v = obj[key]; + if (v && typeof v === "object") { try { v = JSON.stringify(v, null, 2); } catch {} } + const s = v != null ? String(v) : ""; + node.__kv_preview_text = s; + if (pv) pv.value = s; + node.setDirtyCanvas(true, true); + return; } + if (up.comfyClass === "KVLoadFromRegistry") { - await serverRefreshRegistry().catch(()=>{}); await loadRegistry().catch(()=>{}); - const fileName = upstreamFileName(node); pv.value = await serverPeek(fileName,key); node.setDirtyCanvas(true,true); return; + await serverRefreshRegistry().catch(() => {}); + await loadRegistry().catch(() => {}); + const fileName = upstreamFileName(node); + const s = await serverPeek(fileName, key); + node.__kv_preview_text = s; + if (pv) pv.value = s; + node.setDirtyCanvas(true, true); + return; } - node.setDirtyCanvas(true,true); + + node.setDirtyCanvas(true, true); } // ----------------------------------------------------- -// Image preview: primary spacer draws image; extra spacers reserve height +// Canvas event routing (CAPTURE) for preview_value // ----------------------------------------------------- +function setupCanvasPreviewEventRouting() { + const c = app?.canvas?.canvas; + if (!c || window.__kvtools_events_bound) return; + window.__kvtools_events_bound = true; -// Primary spacer (draws the image and defines rect/height) + // improve pointer behavior on touch/precision devices + try { c.style.touchAction = "none"; } catch {} + + function ds() { return app?.canvas?.ds || null; } + + function screenToCanvas(e) { + const rect = c.getBoundingClientRect(); + const sx = e.clientX - rect.left; + const sy = e.clientY - rect.top; + const d = ds(); + if (!d) return [sx, sy]; + return [ sx / d.scale - d.offset[0], sy / d.scale - d.offset[1] ]; + } + + function hitPreviewWidgetCanvasXY(px, py) { + const nodes = (app.graph?._nodes || []); + for (let i = nodes.length - 1; i >= 0; i--) { + const n = nodes[i]; + if (n.comfyClass !== "KVGet") continue; + const pv = W(n, "preview_value"); + const r = pv && pv.__rect_canvas; + if (!r) continue; + if (px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h) { + return { node: n, pv }; + } + } + return null; + } + + function stopAll(e) { try{ e.preventDefault(); }catch{} try{ e.stopImmediatePropagation(); }catch{} try{ e.stopPropagation(); }catch{} } + + // Pointer events have priority in ComfyUI; we intercept in CAPTURE phase + let activeDrag = null; // { node, pv } + + window.addEventListener("pointerdown", (e) => { + if (!app?.canvas?.canvas) return; + const [px, py] = screenToCanvas(e); + const hit = hitPreviewWidgetCanvasXY(px, py); + if (hit?.pv?.onMouseDown) { + const handled = hit.pv.onMouseDown(e, [px, py], hit.node); + if (handled) { + activeDrag = hit; + stopAll(e); // prevent node selection/drag & canvas zoom + try { c.setPointerCapture?.(e.pointerId); } catch {} + } + } + }, { passive: false, capture: true }); + + window.addEventListener("pointermove", (e) => { + if (!app?.canvas?.canvas) return; + const [px, py] = screenToCanvas(e); + + // keep focus on preview while dragging + const target = activeDrag || hitPreviewWidgetCanvasXY(px, py); + if (target?.pv?.onMouseMove) { + const handled = target.pv.onMouseMove(e, [px, py], target.node); + if (handled) stopAll(e); + } + }, { passive: false, capture: true }); + + window.addEventListener("pointerup", (e) => { + if (!app?.canvas?.canvas) return; + const [px, py] = screenToCanvas(e); + + let consumed = false; + for (const n of (app.graph?._nodes || [])) { + if (n.comfyClass !== "KVGet") continue; + const pv = W(n, "preview_value"); + if (pv?.__drag && pv.onMouseUp) { + pv.onMouseUp(e, [px, py], n); + consumed = true; + } + } + if (consumed) { + stopAll(e); + try { c.releasePointerCapture?.(e.pointerId); } catch {} + } + activeDrag = null; + }, { passive: false, capture: true }); + + // wheel -> scroll inside preview, not canvas zoom + window.addEventListener("wheel", (e) => { + if (!app?.canvas?.canvas) return; + const [px, py] = screenToCanvas(e); + const hit = hitPreviewWidgetCanvasXY(px, py); + if (hit?.pv?.onMouseWheel) { + const handled = hit.pv.onMouseWheel(e, [px, py], hit.node); + if (handled) stopAll(e); + } + }, { passive: false, capture: true }); +} + +// ----------------------------------------------------- +// Inline image preview (custom + spacer stack) +// ----------------------------------------------------- function ensureImagePreviewPrimary(node){ let w = W(node, "_kvtools_img_sp0"); if (w) return w; @@ -151,48 +599,37 @@ function ensureImagePreviewPrimary(node){ w = addCW({ name: "_kvtools_img_sp0", serialize: false, - __unit: KV_UNIT_FALLBACK, - __visible: true, // only 'large' when there is actually an image + __unit: KV_IMG_UNIT, + __visible: true, - // We return the height; the framework passes 'width' into draw() computeSize(width) { - if (!this.__visible) return [width || 0, KV_PREVIEW_GAP]; // If no image is present → keep a single-line gap + if (!this.__visible) return [width || 0, KV_IMG_GAP]; return [width || 0, this.__unit]; }, - // Draw inside the widget area (with left/right padding) draw(ctx, nodeRef, widgetWidth, y, height) { const nodeW = (nodeRef.size && nodeRef.size[0]) || 320; const wIn = Math.max(0, (widgetWidth ?? nodeW) - 20); const x = 10; - // read actual unit height (most builds ~20) - this.__unit = height || KV_UNIT_FALLBACK; + this.__unit = height || KV_IMG_UNIT; - // total reserved height = unit * (1 + extra spacers) const extraCount = Array.isArray(nodeRef.__kv_img_spacers) ? nodeRef.__kv_img_spacers.length : 0; - const totalH = this.__visible ? (this.__unit * (1 + extraCount)) : KV_PREVIEW_GAP; + const totalH = this.__visible ? (this.__unit * (1 + extraCount)) : KV_IMG_GAP; - nodeRef.__kv_img_rect = { x, y, w: wIn, h: totalH }; - - // background & border ctx.save(); ctx.fillStyle = "#111"; ctx.fillRect(x, y, wIn, totalH); ctx.strokeStyle = "#333"; ctx.strokeRect(x + 0.5, y + 0.5, wIn - 1, totalH - 1); - // draw image if available if (this.__visible && nodeRef.__kv_img && nodeRef.__kv_img_visible) { const img = nodeRef.__kv_img; const iw = img.naturalWidth || 1, ih = img.naturalHeight || 1; const arI = iw / ih, arB = wIn / totalH; let dw, dh, dx, dy; - if (arI > arB) { // wider than the area → fit to width - dw = wIn - 10; dh = dw / arI; dx = x + 5; dy = y + (totalH - dh) / 2; - } else { // taller than area → fit to height - dh = totalH - 10; dw = dh * arI; dx = x + (wIn - dw) / 2; dy = y + 5; - } + if (arI > arB) { dw = wIn - 10; dh = dw / arI; dx = x + 5; dy = y + (totalH - dh) / 2; } + else { dh = totalH - 10; dw = dh * arI; dx = x + (wIn - dw) / 2; dy = y + 5; } ctx.drawImage(img, dx, dy, dw, dh); } @@ -203,30 +640,21 @@ function ensureImagePreviewPrimary(node){ return w; } -// Create/adjust spacer stack to reach ~KV_PREVIEW_HEIGHT function ensureSpacerStack(node, visible=true) { const sp0 = ensureImagePreviewPrimary(node); - // if unit not known yet, use fallback - const unit = sp0.__unit || KV_UNIT_FALLBACK; - - // target height - const target = visible ? KV_PREVIEW_HEIGHT : KV_PREVIEW_GAP; - - // number of extra spacers (primary already contributes one unit) + const unit = sp0.__unit || KV_IMG_UNIT; + const target = visible ? KV_PREVIEW_IMG_HEIGHT : KV_IMG_GAP; const needExtras = visible ? Math.max(0, Math.ceil(target / unit) - 1) : 0; - // current extras const current = Array.isArray(node.__kv_img_spacers) ? node.__kv_img_spacers : (node.__kv_img_spacers = []); - // remove surplus while (current.length > needExtras) { const w = current.pop(); const idx = node.widgets.indexOf(w); if (idx >= 0) node.widgets.splice(idx, 1); } - // add missing extras const addCW = (spec)=>{ if (typeof node.addCustomWidget === "function") return node.addCustomWidget(spec); const fw = node.addWidget("string", spec.name, "", null); @@ -238,42 +666,25 @@ function ensureSpacerStack(node, visible=true) { const w = addCW({ name: `_kvtools_img_sp${idx}`, serialize: false, - draw(ctx, nodeRef, widgetWidth, y, height) { /* placeholder only */ }, + draw(ctx, nodeRef, widgetWidth, y, height) { /* spacer */ }, }); current.push(w); } - // visibility of the primary spacer controls reserved area & image drawing sp0.__visible = !!visible; - // keep stack together and on top: extras first (reverse), primary last - for (let i = current.length - 1; i >= 0; i--) moveWidgetToTop(node, current[i].name); - moveWidgetToTop(node, sp0.name); + // keep image spacers at the very end (visually below others) + const imgNames = new Set((node.widgets || []).map(w => w.name).filter(n => /^_kvtools_img_sp\d*$/.test(n))); + if (imgNames.size) { + const imgs = []; + node.widgets = node.widgets.filter(w => { if (imgNames.has(w.name)) { imgs.push(w); return false; } return true; }); + for (const s of imgs) node.widgets.push(s); + } + + node.widgets_dirty = true; + node.setDirtyCanvas?.(true, true); } -// enforce re-layout (+ optional logging) -function kvRelayout(node, reason="") { - try { - node.widgets_dirty = true; - if (typeof node.computeSize === "function") { - const s = node.computeSize(); - if (Array.isArray(s) && s.length >= 2) { - if (typeof node.setSize === "function") node.setSize(s); - else node.size = s; - } - } - node.setDirtyCanvas(true,true); - if (KV_DEBUG_LAYOUT) { - const sizes = (node.widgets||[]).map((w,i)=>{ - let h=0; try{ h= (typeof w.computeSize==="function") ? (w.computeSize(node.size?.[0])||[0,0])[1] : (w.height||0);}catch{} - return {i,name:w.name,type:w.type,height:h}; - }); - dlog(reason||"relayout",{node:node.id,nodeSize:node.size,sizes}); - } - } catch(e){ console.warn("[KVTools] relayout error",e); } -} - -// URL build unchanged function buildImageURL(node, key, ext) { const file = upstreamFileName(node); if (!file || !key) return ""; const u = new URL(location.origin + "/kvtools/image"); @@ -284,15 +695,13 @@ function buildImageURL(node, key, ext) { return u.toString(); } -// Main update: stack + image load function updateImagePreview(node, force=false) { const up = upstreamNode(node); - // no preview while inline edit is active if (up?.comfyClass === "KVLoadInline" && up.__kv_edit === true) { ensureSpacerStack(node, false); node.__kv_img = null; node.__kv_img_visible = false; - kvRelayout(node,"inline-edit"); + node.setDirtyCanvas(true,true); return; } @@ -300,7 +709,7 @@ function updateImagePreview(node, force=false) { const visible = !!key; ensureSpacerStack(node, visible); - kvRelayout(node, visible ? "preview-visible" : "preview-hidden"); + node.setDirtyCanvas(true,true); if (!visible) { node.__kv_img = null; node.__kv_img_visible = false; return; } @@ -312,13 +721,13 @@ function updateImagePreview(node, force=false) { const img = new Image(); img.crossOrigin = "anonymous"; try{ img.decoding="async"; }catch{} - img.onload = ()=>{ node.__kv_img = img; node.__kv_img_url = url; node.__kv_img_visible = true; kvRelayout(node,"img-onload"); }; - img.onerror = ()=>{ node.__kv_img = null; node.__kv_img_url = url; node.__kv_img_visible = false; kvRelayout(node,"img-onerror"); try{ console.warn("[KVTools] inline image failed:",url);}catch{} }; + img.onload = ()=>{ node.__kv_img = img; node.__kv_img_url = url; node.__kv_img_visible = true; node.setDirtyCanvas(true,true); }; + img.onerror = ()=>{ node.__kv_img = null; node.__kv_img_url = url; node.__kv_img_visible = false; node.setDirtyCanvas(true,true); try{ console.warn("[KVTools] inline image failed:",url);}catch{} }; img.src = url; } // ----------------------------------------------------- -// Random (UI) + autorun orchestration +// Random helpers (UI only; run remains manual) // ----------------------------------------------------- function hasRandomEnabled(node) { const rw = W(node,"random"); @@ -338,15 +747,11 @@ function collectKeys(node){ } function pickNewKey(keys,current){ if(!keys?.length) return ""; if(keys.length===1) return keys[0]; let i=Math.floor(Math.random()*keys.length); if(keys[i]===current) i=(i+1)%keys.length; return keys[i]; } -async function applyRandomIfEnabled(node, force=false){ +async function applyRandomIfEnabled(node){ if(!hasRandomEnabled(node)) return false; const ks = W(node,"key_select"); const keyW = W(node,"key"); if(!ks||!keyW) return false; const keys = ks.options?.values?.length ? ks.options.values : collectKeys(node); if(!keys?.length) return false; - const now = Date.now(); - if(!force && node.__kv_last_rand && (now - node.__kv_last_rand) < 120) return false; - node.__kv_last_rand = now; - const pick = pickNewKey(keys, ks.value); ks.value = pick; keyW.value = pick; node.setDirtyCanvas(true,true); @@ -360,7 +765,6 @@ async function startRandomizeKVGet(node){ node.__kvtools_random_bootstrapped = true; const ks = W(node,"key_select"); const keyW = W(node,"key"); if(!ks||!keyW) return; - // wait until keys are available for(let i=0;i<12;i++){ let keys = (ks.options && Array.isArray(ks.options.values)) ? ks.options.values : []; if(!keys||!keys.length){ try{ keys = collectKeys(node)||[]; }catch{} } @@ -379,119 +783,71 @@ async function startRandomizeKVGet(node){ async function startRandomizeAllKVGets(){ try{ const nodes=(app.graph?._nodes||[]).filter(n=>n.comfyClass==="KVGet"); for(const n of nodes) await startRandomizeKVGet(n);}catch(e){ console.warn("[KVTools] startRandomizeAllKVGets error:",e);} } // ----------------------------------------------------- -// Prompt/Queue +// Pre-run safety (only on manual run) // ----------------------------------------------------- -function sanitizeTextWidgets(){ +function sanitizeTextWidgetsGlobal(){ if(!app?.graph?._nodes) return; for(const n of app.graph._nodes){ - for(const w of (n.widgets||[])){ - if((w.type==="text"||w.type==="string") && w.value==null) w.value = ""; + for(const w of (n.widgets || [])){ + const t = String(w.type || w.widget_type || "").toLowerCase(); + const name = String(w.name || ""); + const isTextish = (t === "text" || t === "string" || /prompt|text|negative|string|json|key|default|preview_value/i.test(name)); + if (isTextish && (w.value == null)) w.value = ""; + } + if (n.comfyClass === "KVGet") ensureAsTypeDefault(n); + } +} +function syncKVGetInputsFromUI(node){ + if(!node || node.comfyClass !== "KVGet") return; + const ks = W(node, "key_select"); + const kW = W(node, "key"); + const keys = (ks && ks.options && Array.isArray(ks.options.values)) ? ks.options.values : []; + let chosen = String(ks?.value ?? kW?.value ?? "").trim(); + if(!chosen && keys.length) chosen = String(keys[0]); + if (kW) kW.value = chosen || ""; + if (ks && chosen && ks.value !== chosen) ks.value = chosen; + node.setDirtyCanvas?.(true, true); +} +async function preRunSafetyAndSync(){ + sanitizeTextWidgetsGlobal(); + for (const n of (app.graph?._nodes || [])) { + if (n.comfyClass === "KVGet") { + try { + const changed = await applyRandomIfEnabled(n); + syncKVGetInputsFromUI(n); + if (changed) { await updateTextPreview(n); updateImagePreview(n, true); } + } catch {} } } } -async function buildPromptFiltered(){ - try{ - sanitizeTextWidgets(); - let r=null; - if(typeof app.graphToPrompt==="function") r=await app.graphToPrompt(); - else if(typeof app.getPrompt==="function") r=await app.getPrompt(); - const raw = r?.prompt ?? r ?? null; - if(!raw || typeof raw!=="object") return {prompt:null, workflow:r?.workflow}; - - const clean={}; - for(const [k,v] of Object.entries(raw)){ - if(typeof k==="string" && k.startsWith("#")) continue; - if(!v || typeof v!=="object") continue; - if(!("class_type" in v)) continue; - clean[k]=v; - } - - for(const n of (app.graph?._nodes||[])){ - if(n?.comfyClass!=="KVGet") continue; - const item = clean?.[String(n.id)]; - if(!item || item.class_type!=="KVGet" || !item.inputs) continue; - - const uiPick = String(W(n,"key_select")?.value || W(n,"key")?.value || ""); - if(uiPick) item.inputs.key = uiPick; - - const wAT = W(n,"as_type"); - if(wAT && !["string","int","float","bool"].includes(String(wAT.value))) wAT.value = "string"; - } - - if(!Object.keys(clean).length) return {prompt:null, workflow:r?.workflow}; - return {prompt:clean, workflow:r?.workflow}; - }catch(e){ console.warn("[KVTools] buildPromptFiltered error:",e); return {prompt:null, workflow:null}; } -} -async function queueViaApi(prompt, workflow){ - try{ - const body = { prompt, client_id: app?.clientId || api?.clientId || "kvtools", extra_data:{ extra_pnginfo:{ workflow } } }; - const res = await api.fetchApi("/prompt",{ method:"POST", headers:{"content-type":"application/json"}, body: JSON.stringify(body) }); - return !!res?.ok; - }catch{ return false; } -} -async function queueViaUI(){ - try{ if(typeof app.queuePrompt==="function"){ await app.queuePrompt(); return true; } }catch{} - try{ - const btn = [...document.querySelectorAll("button")].find(b=>/queue\s*prompt/i.test(b?.textContent||"")||/queue/i.test(b?.title||"")); - if(btn){ btn.click(); return true; } - }catch{} - return false; -} -async function preRunRandomAll(){ - try{ - for(const n of (app.graph?._nodes||[])){ - if(n.comfyClass==="KVGet"){ - const changed = await applyRandomIfEnabled(n,true); - if(changed){ await updateTextPreview(n); updateImagePreview(n,true); } - } - } - }catch{} -} -async function tryQueueRun(delay=200){ - if(!KVTOOLS_AUTORUN || !app?.graph || kvtoolsIsProcessing) return; - - await preRunRandomAll(); // Random (if externally connected) before the run - - const now=Date.now(); - if(kvRunLock.busy || (now-kvRunLock.last){ - try{ - let {prompt, workflow} = await buildPromptFiltered(); - if(prompt && await queueViaApi(prompt, workflow)) return; - await sleep(200); - ({prompt, workflow} = await buildPromptFiltered()); - if(prompt && await queueViaApi(prompt, workflow)) return; - if(await queueViaUI()) return; - if(typeof app.queueGraph==="function"){ await app.queueGraph(true); return; } - console.warn("[KVTools] tryQueueRun: prompt=null (not ready yet)"); - }catch(e){ - console.error("[KVTools] autorun failed:",e); - }finally{ - kvRunLock.last = Date.now(); - kvRunLock.busy = false; - } - }, delay); -} - -// hook manual queue: random first -(function wrapQueuePrompt(){ - const orig = app.queuePrompt; - if(!orig || app.__kvtools_wrapped_queue) return; - app.__kvtools_wrapped_queue = true; - app.queuePrompt = async function(...args){ await preRunRandomAll(); return await orig.apply(this,args); }; +(function wrapQueueBoth(){ + const origQP = app.queuePrompt; + if(origQP && !app.__kvtools_wrapped_queuePrompt){ + app.__kvtools_wrapped_queuePrompt = true; + app.queuePrompt = async function(...args){ + try{ await preRunSafetyAndSync(); }catch(e){ console.warn("[KVTools] preRun (queuePrompt) failed:", e); } + return await origQP.apply(this, args); + }; + } + const origQG = app.queueGraph; + if(origQG && !app.__kvtools_wrapped_queueGraph){ + app.__kvtools_wrapped_queueGraph = true; + app.queueGraph = async function(...args){ + try{ await preRunSafetyAndSync(); }catch(e){ console.warn("[KVTools] preRun (queueGraph) failed:", e); } + return await origQG.apply(this, args); + }; + } })(); // ----------------------------------------------------- -// UI for KVGet + inline edit +// UI wiring for KVGet + inline edit // ----------------------------------------------------- function ensureKeySelectCombo(node){ let ks=W(node,"key_select"); if(!ks){ ks=node.addWidget("combo","key_select","",null,{values:[]}); ks.serialize=false; } return ks; } function ensureRefreshButton(node){ if(node.__kvtools_refresh_btn) return node.__kvtools_refresh_btn; const btn=node.addWidget("button","KVTools: refresh keys",null,async()=>{ await serverRefreshRegistry(); await loadRegistry(); await syncKeyList(node); ensureAsTypeDefault(node); - await updateTextPreview(node); updateImagePreview(node,true); await tryQueueRun(); + await updateTextPreview(node); updateImagePreview(node,true); }); btn.serialize=false; node.__kvtools_refresh_btn=btn; return btn; } @@ -500,7 +856,7 @@ function ensureRandomButton(node){ const btn=node.addWidget("button","Random key",null,async()=>{ const ks=W(node,"key_select"); const keys=ks?.options?.values||[]; if(!keys.length) return; const pick=pickNewKey(keys, ks.value); ks.value=pick; const keyW=W(node,"key"); if(keyW) keyW.value=pick; - node.setDirtyCanvas(true,true); await updateTextPreview(node); updateImagePreview(node,true); await tryQueueRun(); + node.setDirtyCanvas(true,true); await updateTextPreview(node); updateImagePreview(node,true); }); btn.serialize=false; node.__kvtools_random_btn=btn; return btn; } @@ -517,18 +873,18 @@ function ensureDefaultButtons(node){ const b=node.addWidget("button","Load default",null,async()=>{ const def=ensureDefaultKeyStorage(node); const ks=W(node,"key_select"); const keyW=W(node,"key"); if(def?.value){ ks.value=String(def.value); if(keyW) keyW.value=String(def.value); - node.setDirtyCanvas(true,true); await updateTextPreview(node); updateImagePreview(node,true); await tryQueueRun(); } + node.setDirtyCanvas(true,true); await updateTextPreview(node); updateImagePreview(node,true); } }); b.serialize=false; node.__kvtools_btn_load=b; } } function hideBuiltIns(node){ - hideWidget(node,"value"); - hideWidget(node,"key"); + hideWidget(node,"value"); // backend output + hideWidget(node,"key"); // mirrored from key_select hideWidget(node,"keys_hint"); hideWidget(node,"default"); hideWidget(node,"default_key"); - hideWidget(node,"random"); // random hidden in UI; port still works + hideWidget(node,"random"); // random via input port } async function syncKeyList(node){ @@ -544,14 +900,12 @@ async function syncKeyList(node){ } ks.options.values = Array.isArray(keys) ? keys : []; - // do not overwrite; only align if valid if(!ks.options.values.includes(ks.value)) ks.value = ks.options.values[0] ?? ""; keyW.value = ks.value || keyW.value || ""; node.setDirtyCanvas(true,true); } -// Inline edit (KVLoadInline) function setTextEditable(widget, editable){ if(!widget) return; try{ @@ -585,7 +939,9 @@ function ensureInlineEditToggle(inlineNode){ const link=inp?.link!=null ? app.graph.links?.[inp.link] : null; if(link?.origin_id===inlineNode.id){ if(editing){ - const pv=ensurePreviewWidget(n); pv.value=""; + n.__kv_preview_text = ""; + ensurePreviewWidgetPresent(n); + ensurePVSpacerStack(n, 4); ensureSpacerStack(n,false); n.__kv_img=null; n.__kv_img_visible=false; n.setDirtyCanvas(true,true); @@ -595,7 +951,6 @@ function ensureInlineEditToggle(inlineNode){ } } }catch{} - if(!editing) await tryQueueRun(150); }; const dataW=W(inlineNode,"data"); @@ -610,7 +965,9 @@ function ensureInlineEditToggle(inlineNode){ const inp=(n.inputs||[]).find(i=>i.name==="store"); const link=inp?.link!=null ? app.graph.links?.[inp.link] : null; if(link?.origin_id===inlineNode.id){ - const pv=ensurePreviewWidget(n); pv.value=""; + n.__kv_preview_text = ""; + ensurePreviewWidgetPresent(n); + ensurePVSpacerStack(n, 4); ensureSpacerStack(n,false); n.__kv_img=null; n.__kv_img_visible=false; n.setDirtyCanvas(true,true); @@ -618,34 +975,24 @@ function ensureInlineEditToggle(inlineNode){ } return; } - await tryQueueRun(150); + // not editing: previews will be refreshed by upstream hooks when applicable }; } } -// ----------------------------------------------------- -// Hooks + registration -// ----------------------------------------------------- -function isKVGet(node){ return node?.comfyClass==="KVGet"; } -function ensureTopOrder(node){ - // Order: key_select → _kvtools_preview_value → _kvtools_img_preview - moveWidgetToTop(node, "_kvtools_img_preview"); // bring to top first … - moveWidgetToTop(node, "_kvtools_preview_value"); // … then above it - moveWidgetToTop(node, "key_select"); // … and the dropdown at the very top -} - function attachToKVGet(node){ - if(!isKVGet(node) || node.__kvtools_attached) return; + if(!node || node.comfyClass!=="KVGet" || node.__kvtools_attached) return; node.__kvtools_attached = true; hideBuiltIns(node); ensureAsTypeDefault(node); - ensurePreviewWidget(node); - ensureSpacerStack(node, true); // ensure stack exists + ensurePreviewWidgetPresent(node); + ensurePVSpacerStack(node, 4); + ensureSpacerStack(node, true); + const ks=ensureKeySelectCombo(node); const keyW=W(node,"key"); ensureRefreshButton(node); ensureRandomButton(node); ensureDefaultKeyStorage(node); ensureDefaultButtons(node); - ensureTopOrder(node); if(!ks.__kvtools_cb){ ks.__kvtools_cb=true; @@ -653,7 +1000,6 @@ function attachToKVGet(node){ if(keyW) keyW.value = String(ks.value||""); node.setDirtyCanvas(true,true); await updateTextPreview(node); updateImagePreview(node,true); - await tryQueueRun(); }; } if(keyW && !keyW.__kvtools_cb){ @@ -664,7 +1010,6 @@ function attachToKVGet(node){ if(ks && ks.options?.values?.includes(keyW.value)) ks.value = keyW.value; node.setDirtyCanvas(true,true); await updateTextPreview(node); updateImagePreview(node,true); - await tryQueueRun(); }; } @@ -674,10 +1019,9 @@ function attachToKVGet(node){ hookUpstream(node); await loadRegistry(); await syncKeyList(node); ensureAsTypeDefault(node); await updateTextPreview(node); updateImagePreview(node,true); - await tryQueueRun(); }; - // Initial attach → early random initialization & first run + // initial attach after upstream is present const wait=setInterval(async ()=>{ const up=upstreamNode(node); if(up){ @@ -690,10 +1034,8 @@ function attachToKVGet(node){ await updateTextPreview(node); updateImagePreview(node,true); - // Kick early: first randomize all KVGet, then run + // randomize selection at UI start setTimeout(()=>{ startRandomizeAllKVGets(); },40); - setTimeout(()=>{ preRunRandomAll(); },80); - setTimeout(()=>{ tryQueueRun(140); },140); } },150); } @@ -711,7 +1053,6 @@ function hookUpstream(node){ if(w?.name==="data" || w?.name==="file_name" || w?.name==="path"){ await loadRegistry(); await syncKeyList(node); ensureAsTypeDefault(node); await updateTextPreview(node); updateImagePreview(node,true); - await tryQueueRun(); } }; const onConnOrig=up.onConnectionsChange; @@ -719,7 +1060,6 @@ function hookUpstream(node){ if(onConnOrig) onConnOrig.apply(this,args); await loadRegistry(); await syncKeyList(node); ensureAsTypeDefault(node); await updateTextPreview(node); updateImagePreview(node,true); - await tryQueueRun(); }; if(up.comfyClass==="KVLoadFromRegistry"){ @@ -733,35 +1073,35 @@ function hookUpstream(node){ await serverRefreshRegistry(); await loadRegistry(); await syncKeyList(node); ensureAsTypeDefault(node); await updateTextPreview(node); updateImagePreview(node,true); - await tryQueueRun(); }; } } } } -// registration +// ----------------------------------------------------- +// Register extension +// ----------------------------------------------------- app.registerExtension({ name: EXT_NAME, setup(){ const ready=setInterval(()=>{ - if(app.graph && Array.isArray(app.graph._nodes)){ + if(app.graph && Array.isArray(app.graph._nodes) && app.canvas?.canvas){ clearInterval(ready); + setupCanvasPreviewEventRouting(); // capture router for preview_value + for(const n of app.graph._nodes) attachToKVGet(n); for(const n of app.graph._nodes) if(n.comfyClass==="KVLoadInline") ensureInlineEditToggle(n); - // 1) First randomize ALL KVGet keys (fill dropdown + preview) – always, independent of the random flag setTimeout(()=>{ startRandomizeAllKVGets(); },40); - // 2) Then as before your two async blocks: first random/sync, then autorun - setTimeout(()=>{ try{ preRunRandomAll(); }catch{} },80); - setTimeout(()=>{ try{ tryQueueRun(140); }catch{} },140); const addOrig=app.graph.add; app.graph.add=function(node){ const res=addOrig.call(this,node); attachToKVGet(node); if(node.comfyClass==="KVLoadInline") ensureInlineEditToggle(node); + setupCanvasPreviewEventRouting(); // in case canvas was rebound return res; }; }