diff --git a/web/comfyui/lm_styles.css b/web/comfyui/lm_styles.css index 41e4bd92..7b4eec42 100644 --- a/web/comfyui/lm_styles.css +++ b/web/comfyui/lm_styles.css @@ -726,3 +726,25 @@ body.lm-lora-reordering * { font-size: 12px; color: rgba(226, 232, 240, 0.6); } + +/* ---- Widget flash highlight (visual cue after a value is sent to a node) ---- */ +/* Applied to a widget row element when its value is updated by LoRA Manager. + Shifts the value text color to the LM brand accent with a CSS transition + for fade-in/fade-out. Removal (timeout / hover) is handled by JS. + + The transition is declared on .lm-flash-host (added alongside .lm-flash) + rather than on ComfyUI's .lg-node-widget, so we don't impose a global + color transition on every widget input. The host class persists until + cleanup so fade-out still applies after .lm-flash is removed. */ +.lm-flash-host input, +.lm-flash-host textarea, +.lm-flash-host [role="combobox"] { + transition: color 0.25s ease, -webkit-text-fill-color 0.25s ease; +} + +.lm-flash input, +.lm-flash textarea, +.lm-flash [role="combobox"] { + color: #4299E0 !important; + -webkit-text-fill-color: #4299E0 !important; +} diff --git a/web/comfyui/workflow_registry.js b/web/comfyui/workflow_registry.js index fe46fc05..5984dfc4 100644 --- a/web/comfyui/workflow_registry.js +++ b/web/comfyui/workflow_registry.js @@ -18,6 +18,62 @@ const TEXT_CAPABLE_CLASSES = new Set([ "CLIPTextEncode", ]); +/** + * Parse a hex color (#RGB or #RRGGBB) into an [r, g, b] tuple. + */ +function hexToRgb(hex) { + let h = hex.slice(1); + if (h.length === 3) { + h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2]; + } + const n = parseInt(h, 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; +} + +/** + * Linearly interpolate between two [r, g, b] tuples. + */ +function lerpColor(from, to, t) { + return [ + Math.round(from[0] + (to[0] - from[0]) * t), + Math.round(from[1] + (to[1] - from[1]) * t), + Math.round(from[2] + (to[2] - from[2]) * t), + ]; +} + +/** + * Run a short rAF-driven color fade on a canvas-drawn widget's text_color. + * Sets text_color to an interpolated rgb() string each frame. Returns a + * cancel function. + * + * @param widget the widget instance (must have a configurable text_color) + * @param fromColor [r, g, b] start color + * @param toColor [r, g, b] end color + * @param duration fade duration in ms + * @returns {function} cancel function — stops the fade immediately. + */ +function fadeWidgetTextColor(widget, fromColor, toColor, duration) { + let rafId = null; + const start = performance.now(); + const tick = () => { + const elapsed = performance.now() - start; + const t = Math.min(1, elapsed / duration); + // Ease-out cubic for a smooth deceleration. + const eased = 1 - Math.pow(1 - t, 3); + const [r, g, b] = lerpColor(fromColor, toColor, eased); + Object.defineProperty(widget, 'text_color', { + value: `rgb(${r},${g},${b})`, + writable: true, + configurable: true, + }); + if (t < 1) { + rafId = requestAnimationFrame(tick); + } + }; + rafId = requestAnimationFrame(tick); + return () => { if (rafId) cancelAnimationFrame(rafId); }; +} + app.registerExtension({ name: "LoraManager.WorkflowRegistry", @@ -223,157 +279,289 @@ app.registerExtension({ /** * Add a temporary visual highlight to a widget after its value is updated. - * - Vue Nodes mode: change value text color on all non-button elements - * - Canvas mode: define text_color on widget instance (value text only) - * Highlight fades after 10 seconds or on hover (Vue mode only). + * + * Both rendering modes shift the value text color to the LM brand accent + * (#4299E0) with a fade-in/fade-out, then restore it after FLASH_DURATION + * (3s) or on hover: + * - Vue Nodes mode: add a `.lm-flash` class to the widget row. CSS + * `transition: color 0.25s` handles fade-in/out. A MutationObserver + * re-applies the class if Vue re-renders the row during the flash. + * - Canvas mode: DOM widgets (customtext/autocomplete) use inline + * `transition` for fade; canvas-drawn widgets (combo/number/toggle) use + * a short rAF color interpolation for fade-in (250ms) and fade-out + * (400ms). A low-frequency poll checks hover dismissal via + * app.canvas.getWidgetAtCursor(). */ flashWidget(node, widget) { const FLASH_DURATION = 3000; - const flashEnd = Date.now() + FLASH_DURATION; + const FADE_IN_MS = 250; + const VALUE_COLOR = '#4299E0'; // LM brand accent — consistent with selection/border/drop-indicator const nodeId = node.id; - // Colors consistent with canvas mode - const VALUE_COLOR = '#66B3FF'; + // ---- Vue Nodes mode: CSS class for value text color ---- + const nodeEl = document.querySelector(`[data-node-id="${nodeId}"]`); + if (nodeEl) { + this._flashVueWidget(nodeEl, widget, node, { + FLASH_DURATION, VALUE_COLOR, + }); + return; + } + + // ---- Canvas mode ---- + this._flashCanvasWidget(node, widget, { + FLASH_DURATION, FADE_IN_MS, VALUE_COLOR, + }); + }, + + /** + * Vue/DOM flash: add `.lm-flash` class to the widget row for the value text + * color shift. Re-applies on re-render via MutationObserver. Removes on + * timeout / hover. + */ + _flashVueWidget(nodeEl, widget, graphNode, { FLASH_DURATION, VALUE_COLOR }) { + const FLASH_CLASS = 'lm-flash'; + + // Find the widget row in the DOM. Vue renders widget rows as + // [data-testid="node-widget"] elements whose order matches node.widgets[]. + // Match strategy (in priority order): + // 1. By label text via [data-testid="widget-layout-field-label"] (combo/number/toggle) + // 2. By