mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-06-26 12:51:16 -03:00
fix(flash): fix text widget flash in Vue mode, add fade and hover dismissal
- Fix Vue mode: text widgets (CLIPTextEncode, Prompt LM) had no [data-testid=widget-layout-field-label], so findRowEl never matched. Added fallback strategies: bare <label> text match and widget index match. - Fix Vue mode: flash background pulse was never applied — @keyframes was defined but no rule bound it to .lm-flash. Replaced with CSS transition on .lm-flash-host class for value text color fade-in/fade-out. - Fix Vue mode: -webkit-text-fill-color set by ComfyUI overrode even with !important. Added -webkit-text-fill-color override to .lm-flash. - Fix canvas mode: highlight rect was double-offset because onDrawForeground ctx is pre-translated to node.pos. Removed background rect entirely per design decision; kept text_color + inline color only. - Add fade-in (250ms) / fade-out (400ms) for text color in both modes. Canvas-drawn widgets use rAF color interpolation; DOM widgets use CSS transition. Fixed hexToRgb to handle 3-digit hex shorthand (#DDD). - Add hover dismissal to canvas mode via app.canvas.getWidgetAtCursor(). Vue mode already had it via mouseover listener. - Replace 60fps rAF poll with 100ms setInterval for hover detection. - Fix batch cleanup closure bug: isDomWidget evaluated per-widget instead of per-call; fade rAF cancellers tracked per-widget in _lmFadeCancels map. - Unify flash color from #66B3FF to LM brand accent #4299E0. - Fix Vue fade-out: keep .lm-flash-host 300ms after removing .lm-flash so CSS transition persists. Canvas DOM widgets: keep inline transition 300ms after clearing color.
This commit is contained in:
@@ -726,3 +726,25 @@ body.lm-lora-reordering * {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: rgba(226, 232, 240, 0.6);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,62 @@ const TEXT_CAPABLE_CLASSES = new Set([
|
|||||||
"CLIPTextEncode",
|
"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({
|
app.registerExtension({
|
||||||
name: "LoraManager.WorkflowRegistry",
|
name: "LoraManager.WorkflowRegistry",
|
||||||
|
|
||||||
@@ -223,157 +279,289 @@ app.registerExtension({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a temporary visual highlight to a widget after its value is updated.
|
* 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)
|
* Both rendering modes shift the value text color to the LM brand accent
|
||||||
* Highlight fades after 10 seconds or on hover (Vue mode only).
|
* (#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) {
|
flashWidget(node, widget) {
|
||||||
const FLASH_DURATION = 3000;
|
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;
|
const nodeId = node.id;
|
||||||
|
|
||||||
// Colors consistent with canvas mode
|
// ---- Vue Nodes mode: CSS class for value text color ----
|
||||||
const VALUE_COLOR = '#66B3FF';
|
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 <label> text (CLIPTextEncode customtext has a bare <label>)
|
||||||
|
// 3. By widget index — graph node.widgets[i] ↔ Nth DOM row (text widgets
|
||||||
|
// like Prompt LM have no label at all, so index is the only stable match)
|
||||||
|
const widgetIndex = Array.isArray(graphNode?.widgets)
|
||||||
|
? graphNode.widgets.indexOf(widget)
|
||||||
|
: -1;
|
||||||
|
|
||||||
// Helper: find the widget row in the DOM (by label text matching widget name)
|
|
||||||
const findRowEl = () => {
|
const findRowEl = () => {
|
||||||
const container = document.querySelector(`[data-node-id="${nodeId}"]`);
|
const rows = nodeEl.querySelectorAll('[data-testid="node-widget"]');
|
||||||
if (!container) return null;
|
// Strategy 1: data-testid label
|
||||||
const all = container.querySelectorAll('[data-testid="node-widget"]');
|
for (const r of rows) {
|
||||||
for (const w of all) {
|
const label = r.querySelector('[data-testid="widget-layout-field-label"]');
|
||||||
const label = w.querySelector('[data-testid="widget-layout-field-label"]');
|
|
||||||
if (label && label.textContent.trim() === widget.name) {
|
if (label && label.textContent.trim() === widget.name) {
|
||||||
return w;
|
return r;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Strategy 2: bare <label> element
|
||||||
|
for (const r of rows) {
|
||||||
|
const label = r.querySelector('label');
|
||||||
|
if (label && label.textContent.trim() === widget.name) {
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Strategy 3: index match
|
||||||
|
if (widgetIndex >= 0 && widgetIndex < rows.length) {
|
||||||
|
return rows[widgetIndex];
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper: get label and ring elements from a widget row
|
let cleanedUp = false;
|
||||||
const getLabelAndRing = (row) => {
|
const cleanupFns = [];
|
||||||
if (!row) return { labelEl: null, ringEl: null };
|
|
||||||
const labelEl = row.querySelector('[data-testid="widget-layout-field-label"]');
|
|
||||||
const ringEl = labelEl?.nextElementSibling
|
|
||||||
|| row.querySelector('.flex-1.relative.min-w-0')
|
|
||||||
|| row.querySelector('.rounded-lg.transition-all')
|
|
||||||
|| null;
|
|
||||||
return { labelEl, ringEl };
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyFlash = (row) => {
|
const cleanup = () => {
|
||||||
if (!row) return;
|
if (cleanedUp) return;
|
||||||
const { ringEl } = getLabelAndRing(row);
|
cleanedUp = true;
|
||||||
if (ringEl) {
|
for (const fn of cleanupFns) {
|
||||||
const innerRing = ringEl.querySelector('.rounded-lg.transition-all');
|
try { fn(); } catch (e) { /* ignore */ }
|
||||||
if (innerRing) {
|
}
|
||||||
// Target value-displaying elements for all widget types:
|
// Remove .lm-flash to trigger the CSS color fade-out. Keep
|
||||||
// NumberWidget: spinbutton input
|
// .lm-flash-host (which carries the transition rule) until the
|
||||||
// ComboWidget: combobox button
|
// fade-out completes, then remove it.
|
||||||
// Text widgets (CLIPTextEncode, Prompt, etc.): textarea / text input
|
const row = findRowEl();
|
||||||
innerRing.querySelectorAll(
|
if (row) {
|
||||||
'input, textarea, [role="combobox"]'
|
row.classList.remove(FLASH_CLASS);
|
||||||
).forEach(el => {
|
// Remove the host class after the transition completes.
|
||||||
el.style.color = VALUE_COLOR;
|
setTimeout(() => {
|
||||||
});
|
const r = findRowEl();
|
||||||
}
|
if (r) r.classList.remove('lm-flash-host');
|
||||||
|
}, 300);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeFlash = (row) => {
|
// Initial application
|
||||||
if (!row) return;
|
const apply = () => {
|
||||||
const { ringEl } = getLabelAndRing(row);
|
const row = findRowEl();
|
||||||
if (ringEl) {
|
if (row && !row.classList.contains(FLASH_CLASS)) {
|
||||||
const innerRing = ringEl.querySelector('.rounded-lg.transition-all');
|
// Restart the animation by toggling the class off-on.
|
||||||
if (innerRing) {
|
row.classList.remove(FLASH_CLASS);
|
||||||
// Clear color from all inputs/textarea/combobox
|
// Force reflow so the animation restarts.
|
||||||
innerRing.querySelectorAll(
|
void row.offsetWidth;
|
||||||
'input, textarea, [role="combobox"]'
|
row.classList.add('lm-flash-host');
|
||||||
).forEach(el => {
|
row.classList.add(FLASH_CLASS);
|
||||||
el.style.color = '';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
apply();
|
||||||
|
|
||||||
// --- Try Vue Nodes mode first ---
|
// Re-apply if Vue re-renders and drops the class.
|
||||||
const nodeEl = document.querySelector(`[data-node-id="${nodeId}"]`);
|
const observer = new MutationObserver(() => {
|
||||||
if (nodeEl) {
|
if (cleanedUp) return;
|
||||||
// Apply immediately
|
apply();
|
||||||
const initialRow = findRowEl();
|
|
||||||
applyFlash(initialRow);
|
|
||||||
|
|
||||||
// rAF loop: re-apply after Vue re-renders
|
|
||||||
let rafId = null;
|
|
||||||
const poll = () => {
|
|
||||||
if (Date.now() >= flashEnd) {
|
|
||||||
const lastRow = findRowEl();
|
|
||||||
removeFlash(lastRow);
|
|
||||||
rafId = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const currentRow = findRowEl();
|
|
||||||
applyFlash(currentRow);
|
|
||||||
rafId = requestAnimationFrame(poll);
|
|
||||||
};
|
|
||||||
rafId = requestAnimationFrame(poll);
|
|
||||||
|
|
||||||
// Cleanup timeout
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
if (rafId) cancelAnimationFrame(rafId);
|
|
||||||
const lastRow = findRowEl();
|
|
||||||
removeFlash(lastRow);
|
|
||||||
}, FLASH_DURATION);
|
|
||||||
|
|
||||||
// Hover dismissal via event delegation on node container
|
|
||||||
const hoverHandler = (e) => {
|
|
||||||
const row = findRowEl();
|
|
||||||
if (row && row.contains(e.target)) {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
if (rafId) cancelAnimationFrame(rafId);
|
|
||||||
removeFlash(row);
|
|
||||||
nodeEl.removeEventListener('mouseover', hoverHandler);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
nodeEl.addEventListener('mouseover', hoverHandler);
|
|
||||||
|
|
||||||
return; // Vue mode done
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Canvas mode: change widget value text color via instance property shadowing ---
|
|
||||||
// BaseWidget reads text_color (value) from prototype getter. Defining an own
|
|
||||||
// property on the instance shadows the getter without monkey-patching.
|
|
||||||
// Works for ALL widget types — only value text is changed, label is left alone.
|
|
||||||
Object.defineProperty(widget, 'text_color', {
|
|
||||||
value: VALUE_COLOR,
|
|
||||||
writable: true,
|
|
||||||
configurable: true,
|
|
||||||
});
|
});
|
||||||
|
observer.observe(nodeEl, { childList: true, subtree: true });
|
||||||
|
cleanupFns.push(() => observer.disconnect());
|
||||||
|
|
||||||
if (typeof node.setDirtyCanvas === "function") {
|
// Hard timeout: remove the class after FLASH_DURATION.
|
||||||
node.setDirtyCanvas(true);
|
const timeoutId = setTimeout(cleanup, FLASH_DURATION + 100);
|
||||||
|
cleanupFns.push(() => clearTimeout(timeoutId));
|
||||||
|
|
||||||
|
// Hover dismissal: clear the flash when the user mouses over the row.
|
||||||
|
const onHover = (e) => {
|
||||||
|
const row = findRowEl();
|
||||||
|
if (row && row.contains(e.target)) {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
nodeEl.addEventListener('mouseover', onHover);
|
||||||
|
cleanupFns.push(() => nodeEl.removeEventListener('mouseover', onHover));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canvas flash: set text_color (canvas-drawn widgets) and inline color
|
||||||
|
* (DOM widgets). Canvas-drawn widgets get a rAF-driven color fade-in/out;
|
||||||
|
* DOM widgets use CSS transition. A low-frequency poll checks hover
|
||||||
|
* dismissal via app.canvas.getWidgetAtCursor().
|
||||||
|
*/
|
||||||
|
_flashCanvasWidget(node, widget, { FLASH_DURATION, FADE_IN_MS, VALUE_COLOR }) {
|
||||||
|
const FADE_OUT_MS = 400;
|
||||||
|
const FADE_OUT_START = FLASH_DURATION - FADE_OUT_MS;
|
||||||
|
const DEFAULT_RGB = hexToRgb('#DDD'); // LiteGraph WIDGET_TEXT_COLOR
|
||||||
|
const FLASH_RGB = hexToRgb(VALUE_COLOR);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a widget is a DOM-based widget (customtext / autocomplete)
|
||||||
|
* that renders a real <textarea>/<input> element rather than being
|
||||||
|
* canvas-drawn. Evaluated per-widget so batch cleanup handles each
|
||||||
|
* widget correctly regardless of when it was added to the batch.
|
||||||
|
*/
|
||||||
|
const isDomWidget = (w) =>
|
||||||
|
(w.inputEl && (w.inputEl.tagName === 'TEXTAREA' || w.inputEl.tagName === 'INPUT'))
|
||||||
|
|| !!w.element?.querySelector?.('textarea, input');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the DOM element for a DOM-based widget.
|
||||||
|
*/
|
||||||
|
const getDomEl = (w) =>
|
||||||
|
(w.inputEl && (w.inputEl.tagName === 'TEXTAREA' || w.inputEl.tagName === 'INPUT'))
|
||||||
|
? w.inputEl
|
||||||
|
: w.element?.querySelector?.('textarea, input') ?? null;
|
||||||
|
|
||||||
|
// --- Track fade-out cancellers per widget so batch cleanup can stop
|
||||||
|
// any in-progress fade for ALL widgets in the batch, not just the
|
||||||
|
// latest one. ---
|
||||||
|
if (!node._lmFadeCancels) node._lmFadeCancels = {};
|
||||||
|
|
||||||
|
// --- DOM widget color (customtext / autocomplete text) ---
|
||||||
|
// CSS transition handles both fade-in and fade-out automatically.
|
||||||
|
if (isDomWidget(widget)) {
|
||||||
|
const domEl = getDomEl(widget);
|
||||||
|
if (domEl) {
|
||||||
|
domEl.style.transition = `color ${FADE_IN_MS}ms ease`;
|
||||||
|
domEl.style.color = VALUE_COLOR;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track this widget so it gets restored alongside others on the same node
|
// --- Canvas-drawn widget: kick off fade-in via rAF ---
|
||||||
|
if (!isDomWidget(widget)) {
|
||||||
|
// Set immediately to start (rAF will refine from first frame).
|
||||||
|
Object.defineProperty(widget, 'text_color', {
|
||||||
|
value: VALUE_COLOR,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
const cancel = fadeWidgetTextColor(widget, DEFAULT_RGB, FLASH_RGB, FADE_IN_MS);
|
||||||
|
node._lmFadeCancels[widget.name] = cancel;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Track flashed widgets for batch cleanup ---
|
||||||
if (!node._lmFlashedWidgets) node._lmFlashedWidgets = [];
|
if (!node._lmFlashedWidgets) node._lmFlashedWidgets = [];
|
||||||
if (!node._lmFlashedWidgets.includes(widget)) {
|
if (!node._lmFlashedWidgets.includes(widget)) {
|
||||||
node._lmFlashedWidgets.push(widget);
|
node._lmFlashedWidgets.push(widget);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single per-node timer that restores ALL flashed widgets at once.
|
// --- Track fade-out scheduling per widget ---
|
||||||
// Subsequent calls reset the timer but don't orphan previous widgets.
|
if (!node._lmFadeOutTimers) node._lmFadeOutTimers = {};
|
||||||
if (node._lmFlashCleanup) {
|
|
||||||
clearTimeout(node._lmFlashCleanup);
|
if (typeof node.setDirtyCanvas === 'function') {
|
||||||
|
node.setDirtyCanvas(true);
|
||||||
}
|
}
|
||||||
node._lmFlashCleanup = setTimeout(() => {
|
|
||||||
|
// --- Poll for hover dismissal ---
|
||||||
|
let pollId = null;
|
||||||
|
let cleanedUp = false;
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
if (cleanedUp) return;
|
||||||
|
cleanedUp = true;
|
||||||
|
if (pollId) clearInterval(pollId);
|
||||||
|
pollId = null;
|
||||||
|
|
||||||
for (const w of (node._lmFlashedWidgets || [])) {
|
for (const w of (node._lmFlashedWidgets || [])) {
|
||||||
|
// Cancel any pending fade-out timer for this widget
|
||||||
|
if (node._lmFadeOutTimers?.[w.name]) {
|
||||||
|
clearTimeout(node._lmFadeOutTimers[w.name]);
|
||||||
|
delete node._lmFadeOutTimers[w.name];
|
||||||
|
}
|
||||||
|
// Cancel any in-progress fade-in/out rAF for this widget
|
||||||
|
if (node._lmFadeCancels?.[w.name]) {
|
||||||
|
node._lmFadeCancels[w.name]();
|
||||||
|
delete node._lmFadeCancels[w.name];
|
||||||
|
}
|
||||||
delete w.text_color;
|
delete w.text_color;
|
||||||
delete w.secondary_text_color;
|
delete w.secondary_text_color;
|
||||||
|
// Clear DOM widget inline color first (transition plays the
|
||||||
|
// fade-out), then remove the transition property after it
|
||||||
|
// completes. Keeping transition until then is essential.
|
||||||
|
if (isDomWidget(w)) {
|
||||||
|
const el = getDomEl(w);
|
||||||
|
if (el) {
|
||||||
|
el.style.color = '';
|
||||||
|
// Remove the transition property after the fade completes.
|
||||||
|
setTimeout(() => {
|
||||||
|
if (el) el.style.transition = '';
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
delete node._lmFlashedWidgets;
|
delete node._lmFlashedWidgets;
|
||||||
|
delete node._lmFadeOutTimers;
|
||||||
|
delete node._lmFadeCancels;
|
||||||
delete node._lmFlashCleanup;
|
delete node._lmFlashCleanup;
|
||||||
if (typeof node.setDirtyCanvas === "function") {
|
if (typeof node.setDirtyCanvas === 'function') {
|
||||||
node.setDirtyCanvas(true);
|
node.setDirtyCanvas(true);
|
||||||
}
|
}
|
||||||
}, FLASH_DURATION);
|
};
|
||||||
|
|
||||||
|
// Schedule fade-out for canvas-drawn widgets only (DOM widgets fade
|
||||||
|
// automatically when we clear the inline color in cleanup).
|
||||||
|
if (!isDomWidget(widget)) {
|
||||||
|
// Clear any previous fade-out timer for this widget
|
||||||
|
if (node._lmFadeOutTimers[widget.name]) {
|
||||||
|
clearTimeout(node._lmFadeOutTimers[widget.name]);
|
||||||
|
}
|
||||||
|
node._lmFadeOutTimers[widget.name] = setTimeout(() => {
|
||||||
|
if (cleanedUp) return;
|
||||||
|
const cancel = fadeWidgetTextColor(widget, FLASH_RGB, DEFAULT_RGB, FADE_OUT_MS);
|
||||||
|
node._lmFadeCancels[widget.name] = cancel;
|
||||||
|
delete node._lmFadeOutTimers[widget.name];
|
||||||
|
}, FADE_OUT_START);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Low-frequency poll (~100ms) for hover dismissal.
|
||||||
|
const checkHover = () => {
|
||||||
|
if (cleanedUp) return;
|
||||||
|
const canvas = window.app?.canvas;
|
||||||
|
if (canvas) {
|
||||||
|
const hovered = canvas.getWidgetAtCursor?.();
|
||||||
|
if (hovered && node._lmFlashedWidgets?.includes(hovered)) {
|
||||||
|
cleanup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
pollId = setInterval(checkHover, 100);
|
||||||
|
|
||||||
|
// Hard timeout fallback.
|
||||||
|
if (node._lmFlashCleanup) clearTimeout(node._lmFlashCleanup);
|
||||||
|
node._lmFlashCleanup = setTimeout(cleanup, FLASH_DURATION + 50);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user