Files

177 lines
6.1 KiB
JavaScript

// Shared utility for injecting persistent badges into Vue node footer bars.
// Badges are stored in a registry and re-applied whenever a node's DOM
// element is (re)mounted, handling workflow switches and collapse/expand.
// Registry: nodeId (string) -> Map of badgeClass -> { text, className }
const registry = new Map();
// Sets or removes a badge in a Vue node's footer bar and persists it
// in the registry so it survives re-renders.
//
// badgeClass - unique CSS class identifying this badge type
// extraClasses - additional Tailwind classes for the outer wrapper
// nodeId - node ID as a string
// text - display text, or null to remove
// color - optional CSS variable name e.g. "--success-background"
// icon - optional Lucide icon name e.g. "clock"
export function setVueBadge(
badgeClass, extraClasses, nodeId, text, color = null, icon = null) {
if (!registry.has(nodeId)) registry.set(nodeId, new Map());
const nodeBadges = registry.get(nodeId);
if (!text) {
nodeBadges.delete(badgeClass);
_removeBadge(badgeClass, nodeId);
return;
}
nodeBadges.set(badgeClass, { text, extraClasses, color, icon });
_applyBadge(badgeClass, extraClasses, nodeId, text, color, icon);
}
// Clears all badges for a node from both the DOM and the registry.
export function clearVueBadges(nodeId) {
const nodeBadges = registry.get(nodeId);
if (!nodeBadges) return;
for (const badgeClass of nodeBadges.keys()) {
_removeBadge(badgeClass, nodeId);
}
nodeBadges.clear();
}
function _getFooter(nodeId) {
return document.querySelector(`[data-node-id="${nodeId}"] .mt-auto`);
}
function _removeBadge(badgeClass, nodeId) {
_getFooter(nodeId)?.querySelector(`.${badgeClass}`)?.remove();
}
function _applyBadge(badgeClass, extraClasses, nodeId, text, color = null, icon = null) {
const footer = _getFooter(nodeId);
if (!footer) return;
// Ensure footer has a positioning context for absolute badges.
footer.classList.add("relative");
let badge = footer.querySelector(`.${badgeClass}`);
if (!badge) {
badge = document.createElement("div");
badge.className = [
badgeClass,
"flex", "h-6", "items-center", "justify-center",
"overflow-clip", "rounded-full",
"bg-component-node-widget-background",
...extraClasses
].join(" ");
const inner = document.createElement("div");
inner.className = [
"flex", "min-w-max", "items-center", "gap-1",
"rounded-sm", "px-1", "py-0.5", "text-xs", "h-6",
"first:pl-2", "last:pr-2"
].join(" ");
inner.style.cssText =
"color: currentcolor; background-color: transparent;";
badge.appendChild(inner);
footer.appendChild(badge);
}
const inner = badge.querySelector("div");
// Rebuild inner content: optional icon + text.
inner.innerHTML = "";
if (icon) {
const i = document.createElement("i");
i.className = `icon-[lucide--${icon}] size-3`;
inner.appendChild(i);
}
const span = document.createElement("span");
span.textContent = text;
inner.appendChild(span);
// Apply theme color via CSS variable, or reset to transparent.
// Set text color for maximum contrast against the background.
inner.style.backgroundColor = color
? `var(${color})`
: "transparent";
inner.style.color = _contrastColor(color) ?? "currentcolor";
}
// Re-applies all registered badges for a given node ID.
function _reapplyBadges(nodeId) {
const nodeBadges = registry.get(nodeId);
if (!nodeBadges) return;
for (const [badgeClass, { text, extraClasses, color, icon }] of nodeBadges) {
_applyBadge(badgeClass, extraClasses, nodeId, text, color, icon);
}
}
// Cache of CSS variable -> "#fff" or "#000" for contrast text.
const _contrastCache = new Map();
// Returns "#fff" or "#000" for maximum contrast against the given CSS
// variable's resolved color. Result is cached per variable name.
function _contrastColor(cssVar) {
if (!cssVar) return null;
if (_contrastCache.has(cssVar)) return _contrastCache.get(cssVar);
const el = document.createElement("div");
el.style.cssText = `position:absolute;visibility:hidden;background-color:var(${cssVar})`;
document.body.appendChild(el);
const rgb = getComputedStyle(el).backgroundColor;
document.body.removeChild(el);
// Parse rgb(r, g, b) or rgba(r, g, b, a).
const m = rgb.match(/\d+/g);
let result = "#000";
if (m) {
const [r, g, b] = m.map(Number);
// Relative luminance per WCAG 2.1.
const lum = (c) => {
const s = c / 255;
return s <= 0.03928
? s / 12.92
: Math.pow((s + 0.055) / 1.055, 2.4);
};
const L = 0.2126 * lum(r) + 0.7152 * lum(g) + 0.0722 * lum(b);
result = L > 0.179 ? "#000" : "#fff";
}
_contrastCache.set(cssVar, result);
return result;
}
// Single MutationObserver watching for [data-node-id] elements being
// added to the DOM. Fires for workflow switches and collapse/expand.
let _observer = null;
export function startBadgeObserver() {
if (_observer) return;
_observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (!(node instanceof HTMLElement)) continue;
// The node element itself may be the added element.
if (node.dataset?.nodeId) {
_reapplyBadges(node.dataset.nodeId);
continue;
}
// Or it may be a subtree — scan descendants.
for (const el of node.querySelectorAll("[data-node-id]")) {
_reapplyBadges(el.dataset.nodeId);
}
}
}
});
// Observe the whole document body since node elements can be
// reparented into various containers across workflow switches.
_observer.observe(document.body, {
childList: true,
subtree: true
});
}