Add SVG snapshot previews and bump to v2.5.0

Render workflow graphs as SVG previews so users can visually inspect
snapshots without restoring them. Adds hover tooltips, a full-size
preview modal (eye button), and side-by-side SVG comparison with
color-coded highlights in the diff view.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 09:38:02 +01:00
parent 4b392a89cf
commit 31b846cd5f
2 changed files with 551 additions and 3 deletions

View File

@@ -39,6 +39,9 @@ let timelineRefresh = null; // callback to re-render timeline
let activeSnapshotId = null; // ID of the snapshot currently loaded via swap
let currentSnapshotId = null; // ID of the auto-saved "Current" snapshot before a swap
let diffBaseSnapshot = null; // snapshot record selected as diff base (shift+click)
const svgCache = new Map(); // "snapshotId:WxH" -> SVGElement template
let svgClipCounter = 0; // unique prefix for SVG clipPath IDs
let sidebarTooltipEl = null; // tooltip element for sidebar hover previews
// ─── Server API Layer ───────────────────────────────────────────────
@@ -502,6 +505,291 @@ function computeDetailedDiff(baseGraph, targetGraph) {
};
}
// ─── SVG Graph Renderer ─────────────────────────────────────────────
const SVG_NS = "http://www.w3.org/2000/svg";
const SVG_NODE_TITLE_HEIGHT = 25;
const SVG_SLOT_SPACING = 20;
const SVG_DEFAULTS = { width: 140, height: 80, color: "#333", bgcolor: "#353535" };
const SVG_LINK_TYPE_COLORS = {
IMAGE: "#64b5f6", CLIP: "#ffa726", MODEL: "#b39ddb",
CONDITIONING: "#ef9a9a", LATENT: "#ff63c9", VAE: "#ff6e6e",
MASK: "#81c784", INT: "#7986cb", FLOAT: "#7986cb", STRING: "#7986cb",
};
const SVG_HIGHLIGHT_COLORS = { added: "#22c55e", removed: "#dc2626", modified: "#f59e0b" };
function renderGraphSVG(graphData, options = {}) {
const {
width = 400, height = 300, padding = 40,
highlightNodes = null, showLabels = true,
showLinks = true, showSlots = true, showGroups = true,
backgroundColor = "#1a1a2e",
} = options;
const nodes = graphData?.nodes;
if (!nodes || nodes.length === 0) return null;
// Build node map (skip null entries)
const nodeMap = new Map();
for (const n of nodes) {
if (n == null) continue;
nodeMap.set(n.id, n);
}
if (nodeMap.size === 0) return null;
// Compute bounding box
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const n of nodeMap.values()) {
const x = n.pos?.[0] ?? 0;
const y = n.pos?.[1] ?? 0;
const w = n.size?.[0] ?? SVG_DEFAULTS.width;
const h = n.flags?.collapsed ? SVG_NODE_TITLE_HEIGHT : (n.size?.[1] ?? SVG_DEFAULTS.height);
if (x < minX) minX = x;
if (y < minY) minY = y;
if (x + w > maxX) maxX = x + w;
if (y + h > maxY) maxY = y + h;
}
// Include groups in bbox
const groups = graphData.groups || [];
if (showGroups) {
for (const g of groups) {
if (!g?.bounding) continue;
const [gx, gy, gw, gh] = g.bounding;
if (gx < minX) minX = gx;
if (gy < minY) minY = gy;
if (gx + gw > maxX) maxX = gx + gw;
if (gy + gh > maxY) maxY = gy + gh;
}
}
// Guard zero-area bbox (single node edge case)
if (maxX - minX < 1) maxX = minX + SVG_DEFAULTS.width;
if (maxY - minY < 1) maxY = minY + SVG_DEFAULTS.height;
const bboxW = maxX - minX + padding * 2;
const bboxH = maxY - minY + padding * 2;
const vbX = minX - padding;
const vbY = minY - padding;
const svg = document.createElementNS(SVG_NS, "svg");
svg.setAttribute("viewBox", `${vbX} ${vbY} ${bboxW} ${bboxH}`);
svg.setAttribute("width", width);
svg.setAttribute("height", height);
svg.style.display = "block";
// Auto-simplify for thumbnails
const effectiveLabels = width < 200 ? false : showLabels;
const effectiveSlots = width < 200 ? false : showSlots;
const clipPrefix = `sc${svgClipCounter++}`;
// Background
const bg = document.createElementNS(SVG_NS, "rect");
bg.setAttribute("x", vbX);
bg.setAttribute("y", vbY);
bg.setAttribute("width", bboxW);
bg.setAttribute("height", bboxH);
bg.setAttribute("fill", backgroundColor);
svg.appendChild(bg);
// Groups
if (showGroups && groups.length > 0) {
for (const g of groups) {
if (!g?.bounding) continue;
const [gx, gy, gw, gh] = g.bounding;
const gRect = document.createElementNS(SVG_NS, "rect");
gRect.setAttribute("x", gx);
gRect.setAttribute("y", gy);
gRect.setAttribute("width", gw);
gRect.setAttribute("height", gh);
gRect.setAttribute("rx", "5");
const gColor = g.color || "#335";
gRect.setAttribute("fill", gColor);
gRect.setAttribute("fill-opacity", "0.3");
gRect.setAttribute("stroke", gColor);
gRect.setAttribute("stroke-opacity", "0.5");
gRect.setAttribute("stroke-width", "1");
svg.appendChild(gRect);
// Group title
if (effectiveLabels && g.title) {
const gText = document.createElementNS(SVG_NS, "text");
gText.setAttribute("x", gx + 8);
gText.setAttribute("y", gy + 16);
gText.setAttribute("fill", "#aaa");
gText.setAttribute("font-size", "14");
gText.setAttribute("font-family", "system-ui, sans-serif");
gText.textContent = g.title;
svg.appendChild(gText);
}
}
}
// Slot position helpers
function getOutputSlotPos(node, slotIndex) {
const x = node.pos?.[0] ?? 0;
const y = node.pos?.[1] ?? 0;
const w = node.size?.[0] ?? SVG_DEFAULTS.width;
return [x + w, y + SVG_NODE_TITLE_HEIGHT + slotIndex * SVG_SLOT_SPACING + SVG_SLOT_SPACING / 2];
}
function getInputSlotPos(node, slotIndex) {
const x = node.pos?.[0] ?? 0;
const y = node.pos?.[1] ?? 0;
return [x, y + SVG_NODE_TITLE_HEIGHT + slotIndex * SVG_SLOT_SPACING + SVG_SLOT_SPACING / 2];
}
// Links
const links = (graphData.links || []).filter(Boolean);
if (showLinks && links.length <= 2000) {
for (const link of links) {
const srcNodeId = link[1];
const srcSlot = link[2];
const destNodeId = link[3];
const destSlot = link[4];
const linkType = link[5];
const srcNode = nodeMap.get(srcNodeId);
const destNode = nodeMap.get(destNodeId);
if (!srcNode || !destNode) continue;
// Skip links from collapsed source nodes where slot is hidden
const srcCollapsed = srcNode.flags?.collapsed;
const destCollapsed = destNode.flags?.collapsed;
const [srcX, srcY] = srcCollapsed
? [(srcNode.pos?.[0] ?? 0) + (srcNode.size?.[0] ?? SVG_DEFAULTS.width), (srcNode.pos?.[1] ?? 0) + SVG_NODE_TITLE_HEIGHT / 2]
: getOutputSlotPos(srcNode, srcSlot);
const [destX, destY] = destCollapsed
? [(destNode.pos?.[0] ?? 0), (destNode.pos?.[1] ?? 0) + SVG_NODE_TITLE_HEIGHT / 2]
: getInputSlotPos(destNode, destSlot);
const dx = Math.max(Math.abs(destX - srcX) * 0.5, 50);
const d = `M ${srcX} ${srcY} C ${srcX + dx} ${srcY}, ${destX - dx} ${destY}, ${destX} ${destY}`;
const path = document.createElementNS(SVG_NS, "path");
path.setAttribute("d", d);
path.setAttribute("fill", "none");
const color = SVG_LINK_TYPE_COLORS[linkType] || "#888";
path.setAttribute("stroke", color);
path.setAttribute("stroke-width", "2");
path.setAttribute("stroke-opacity", "0.5");
svg.appendChild(path);
}
}
// Nodes
for (const n of nodeMap.values()) {
const x = n.pos?.[0] ?? 0;
const y = n.pos?.[1] ?? 0;
const w = n.size?.[0] ?? SVG_DEFAULTS.width;
const isCollapsed = n.flags?.collapsed;
const h = isCollapsed ? SVG_NODE_TITLE_HEIGHT : (n.size?.[1] ?? SVG_DEFAULTS.height);
const bgcolor = n.bgcolor || SVG_DEFAULTS.bgcolor;
const color = n.color || SVG_DEFAULTS.color;
const highlightType = highlightNodes?.get(n.id);
const highlightColor = highlightType ? SVG_HIGHLIGHT_COLORS[highlightType] : null;
// Node body
const body = document.createElementNS(SVG_NS, "rect");
body.setAttribute("x", x);
body.setAttribute("y", y);
body.setAttribute("width", w);
body.setAttribute("height", h);
body.setAttribute("rx", "4");
body.setAttribute("fill", bgcolor);
if (highlightColor) {
body.setAttribute("stroke", highlightColor);
body.setAttribute("stroke-width", "3");
} else {
body.setAttribute("stroke", "#555");
body.setAttribute("stroke-width", "1");
}
svg.appendChild(body);
// Title bar
const titleBar = document.createElementNS(SVG_NS, "rect");
titleBar.setAttribute("x", x);
titleBar.setAttribute("y", y);
titleBar.setAttribute("width", w);
titleBar.setAttribute("height", SVG_NODE_TITLE_HEIGHT);
titleBar.setAttribute("rx", "4");
titleBar.setAttribute("fill", color);
svg.appendChild(titleBar);
// Title text
if (effectiveLabels) {
const titleText = document.createElementNS(SVG_NS, "text");
titleText.setAttribute("x", x + 8);
titleText.setAttribute("y", y + 16);
titleText.setAttribute("fill", "#eee");
titleText.setAttribute("font-size", "11");
titleText.setAttribute("font-family", "system-ui, sans-serif");
// Truncate to fit node width
const maxChars = Math.max(4, Math.floor(w / 7));
const title = n.title || n.type || "";
titleText.textContent = title.length > maxChars ? title.slice(0, maxChars - 1) + "\u2026" : title;
// Clip to node width
const clipId = `${clipPrefix}-${n.id}`;
const clipPath = document.createElementNS(SVG_NS, "clipPath");
clipPath.setAttribute("id", clipId);
const clipRect = document.createElementNS(SVG_NS, "rect");
clipRect.setAttribute("x", x);
clipRect.setAttribute("y", y);
clipRect.setAttribute("width", w);
clipRect.setAttribute("height", SVG_NODE_TITLE_HEIGHT);
clipPath.appendChild(clipRect);
svg.appendChild(clipPath);
titleText.setAttribute("clip-path", `url(#${clipId})`);
svg.appendChild(titleText);
}
// Slots
if (effectiveSlots && !isCollapsed) {
const inputs = n.inputs || [];
for (let i = 0; i < inputs.length; i++) {
const [sx, sy] = getInputSlotPos(n, i);
const circle = document.createElementNS(SVG_NS, "circle");
circle.setAttribute("cx", sx);
circle.setAttribute("cy", sy);
circle.setAttribute("r", "4");
const slotType = inputs[i]?.type || "";
circle.setAttribute("fill", SVG_LINK_TYPE_COLORS[slotType] || "#888");
svg.appendChild(circle);
}
const outputs = n.outputs || [];
for (let i = 0; i < outputs.length; i++) {
const [sx, sy] = getOutputSlotPos(n, i);
const circle = document.createElementNS(SVG_NS, "circle");
circle.setAttribute("cx", sx);
circle.setAttribute("cy", sy);
circle.setAttribute("r", "4");
const slotType = outputs[i]?.type || "";
circle.setAttribute("fill", SVG_LINK_TYPE_COLORS[slotType] || "#888");
svg.appendChild(circle);
}
}
}
return svg;
}
function getCachedSVG(snapshotId, graphData, options = {}) {
const { width = 400, height = 300 } = options;
const key = `${snapshotId}:${width}x${height}`;
if (svgCache.has(key)) {
return svgCache.get(key).cloneNode(true);
}
const svg = renderGraphSVG(graphData, options);
if (svg) {
svgCache.set(key, svg);
return svg.cloneNode(true);
}
return null;
}
// ─── Restore Lock ───────────────────────────────────────────────────
async function withRestoreLock(fn) {
@@ -562,7 +850,7 @@ async function showPromptDialog(message, defaultValue = "Manual") {
// ─── Diff Modal ─────────────────────────────────────────────────────
function showDiffModal(baseLabel, targetLabel, diff, allNodes) {
function showDiffModal(baseLabel, targetLabel, diff, allNodes, baseGraphData, targetGraphData) {
// Overlay
const overlay = document.createElement("div");
overlay.className = "snap-diff-overlay";
@@ -763,8 +1051,104 @@ function showDiffModal(baseLabel, targetLabel, diff, allNodes) {
return s.length > 80 ? s.slice(0, 77) + "\u2026" : s;
}
// SVG comparison panel
let svgCompare = null;
if (baseGraphData && targetGraphData) {
const highlightNodesBase = new Map();
for (const n of diff.removedNodes) highlightNodesBase.set(n.id, "removed");
for (const n of diff.modifiedNodes) highlightNodesBase.set(n.id, "modified");
const highlightNodesTarget = new Map();
for (const n of diff.addedNodes) highlightNodesTarget.set(n.id, "added");
for (const n of diff.modifiedNodes) highlightNodesTarget.set(n.id, "modified");
const svgOpts = { width: 330, height: 220, showLabels: true, showLinks: true, showSlots: false, showGroups: true };
const baseSvg = renderGraphSVG(baseGraphData, { ...svgOpts, highlightNodes: highlightNodesBase });
const targetSvg = renderGraphSVG(targetGraphData, { ...svgOpts, highlightNodes: highlightNodesTarget });
if (baseSvg || targetSvg) {
svgCompare = document.createElement("div");
svgCompare.className = "snap-diff-svg-compare";
const basePanel = document.createElement("div");
basePanel.className = "snap-diff-svg-panel";
const baseLbl = document.createElement("div");
baseLbl.className = "snap-diff-svg-panel-label";
baseLbl.textContent = "Base";
basePanel.appendChild(baseLbl);
if (baseSvg) basePanel.appendChild(baseSvg);
const targetPanel = document.createElement("div");
targetPanel.className = "snap-diff-svg-panel";
const targetLbl = document.createElement("div");
targetLbl.className = "snap-diff-svg-panel-label";
targetLbl.textContent = "Target";
targetPanel.appendChild(targetLbl);
if (targetSvg) targetPanel.appendChild(targetSvg);
svgCompare.appendChild(basePanel);
svgCompare.appendChild(targetPanel);
}
}
modal.appendChild(hdr);
modal.appendChild(summaryBar);
if (svgCompare) modal.appendChild(svgCompare);
modal.appendChild(body);
overlay.appendChild(modal);
document.body.appendChild(overlay);
function dismiss() {
overlay.remove();
document.removeEventListener("keydown", onKey);
}
function onKey(e) {
if (e.key === "Escape") dismiss();
}
document.addEventListener("keydown", onKey);
overlay.addEventListener("click", (e) => {
if (e.target === overlay) dismiss();
});
}
// ─── Preview Modal ──────────────────────────────────────────────────
function showPreviewModal(record) {
const overlay = document.createElement("div");
overlay.className = "snap-preview-overlay";
const modal = document.createElement("div");
modal.className = "snap-preview-modal";
const hdr = document.createElement("div");
hdr.className = "snap-preview-header";
const hdrTitle = document.createElement("span");
hdrTitle.textContent = `${record.label} \u2014 ${formatTime(record.timestamp)}`;
const closeBtn = document.createElement("button");
closeBtn.textContent = "\u2715";
closeBtn.addEventListener("click", dismiss);
hdr.appendChild(hdrTitle);
hdr.appendChild(closeBtn);
const body = document.createElement("div");
body.className = "snap-preview-body";
const svg = renderGraphSVG(record.graphData, {
width: 860, height: 600,
showLabels: true, showLinks: true, showSlots: true, showGroups: true,
});
if (svg) {
body.appendChild(svg);
} else {
const fallback = document.createElement("div");
fallback.style.cssText = "color: #666; font-size: 13px; padding: 32px;";
fallback.textContent = "Unable to render preview";
body.appendChild(fallback);
}
modal.appendChild(hdr);
modal.appendChild(body);
overlay.appendChild(modal);
document.body.appendChild(overlay);
@@ -1551,6 +1935,102 @@ const CSS = `
.snap-btn-diff.snap-diff-base-active {
box-shadow: 0 0 6px rgba(109,40,217,0.6);
}
.snap-preview-tooltip {
position: fixed;
z-index: 10001;
pointer-events: none;
background: #1e1e2e;
border: 1px solid #444;
border-radius: 6px;
padding: 6px;
box-shadow: 0 4px 16px rgba(0,0,0,0.5);
opacity: 0;
transition: opacity 0.15s;
}
.snap-preview-tooltip.visible {
opacity: 1;
}
.snap-preview-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
}
.snap-preview-modal {
width: min(900px, 90vw);
max-height: 90vh;
background: #1e1e2e;
border: 1px solid #444;
border-radius: 8px;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
font-family: system-ui, -apple-system, sans-serif;
font-size: 13px;
color: #ccc;
}
.snap-preview-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 1px solid #444;
font-weight: 700;
font-size: 14px;
}
.snap-preview-header button {
background: none;
border: none;
color: #888;
font-size: 16px;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
}
.snap-preview-header button:hover {
background: #333;
color: #fff;
}
.snap-preview-body {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
overflow: auto;
padding: 16px;
}
.snap-diff-svg-compare {
display: flex;
gap: 8px;
padding: 8px 14px;
border-bottom: 1px solid #333;
justify-content: center;
}
.snap-diff-svg-panel {
flex: 1;
text-align: center;
}
.snap-diff-svg-panel-label {
font-size: 11px;
font-weight: 600;
color: #888;
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.snap-btn-preview {
background: #334155;
color: #fff;
font-size: 13px;
min-width: 28px;
text-align: center;
}
.snap-btn-preview:hover:not(:disabled) {
background: #475569;
}
`;
const CHANGE_TYPE_ICONS = {
@@ -1616,11 +2096,22 @@ function formatDate(ts) {
async function buildSidebar(el) {
injectStyles();
// Clean up previous tooltip if sidebar is being rebuilt
if (sidebarTooltipEl) {
sidebarTooltipEl.remove();
sidebarTooltipEl = null;
}
el.innerHTML = "";
const container = document.createElement("div");
container.className = "snap-sidebar";
// Shared hover tooltip
const tooltip = document.createElement("div");
tooltip.className = "snap-preview-tooltip";
document.body.appendChild(tooltip);
let tooltipTimer = null;
// Header
const header = document.createElement("div");
header.className = "snap-header";
@@ -1831,6 +2322,10 @@ async function buildSidebar(el) {
}
async function refresh(resetSearch = false) {
svgCache.clear();
// Hide tooltip — items are about to be destroyed so mouseleave won't fire
if (tooltipTimer) { clearTimeout(tooltipTimer); tooltipTimer = null; }
tooltip.classList.remove("visible");
const currentKey = getWorkflowKey();
const effKey = getEffectiveWorkflowKey();
const isViewingOther = viewingWorkflowKey != null && viewingWorkflowKey !== currentKey;
@@ -2086,16 +2581,50 @@ async function buildSidebar(el) {
}
const diff = computeDetailedDiff(baseGraph, targetGraph);
const allNodes = buildNodeLookup(baseGraph, targetGraph);
showDiffModal(baseLabel, targetLabel, diff, allNodes);
showDiffModal(baseLabel, targetLabel, diff, allNodes, baseGraph, targetGraph);
});
const previewBtn = document.createElement("button");
previewBtn.className = "snap-btn-preview";
previewBtn.textContent = "\uD83D\uDC41";
previewBtn.title = "Preview workflow graph";
previewBtn.addEventListener("click", (e) => {
e.stopPropagation();
showPreviewModal(rec);
});
actions.appendChild(noteBtn);
actions.appendChild(previewBtn);
actions.appendChild(diffBtn);
actions.appendChild(lockBtn);
actions.appendChild(swapBtn);
actions.appendChild(restoreBtn);
actions.appendChild(deleteBtn);
// Hover tooltip
item.addEventListener("mouseenter", () => {
tooltipTimer = setTimeout(() => {
const svg = getCachedSVG(rec.id, rec.graphData, { width: 240, height: 180 });
if (!svg) return;
tooltip.innerHTML = "";
tooltip.appendChild(svg);
const rect = item.getBoundingClientRect();
let left = rect.right + 8;
let top = rect.top;
// Clamp to viewport
if (left + 260 > window.innerWidth) left = rect.left - 260;
if (top + 200 > window.innerHeight) top = window.innerHeight - 200;
if (top < 0) top = 0;
tooltip.style.left = `${left}px`;
tooltip.style.top = `${top}px`;
tooltip.classList.add("visible");
}, 200);
});
item.addEventListener("mouseleave", () => {
if (tooltipTimer) { clearTimeout(tooltipTimer); tooltipTimer = null; }
tooltip.classList.remove("visible");
});
item.appendChild(info);
item.appendChild(actions);
list.appendChild(item);
@@ -2111,6 +2640,7 @@ async function buildSidebar(el) {
}
sidebarRefresh = refresh;
sidebarTooltipEl = tooltip;
await refresh(true);
}
@@ -2311,6 +2841,11 @@ if (window.__COMFYUI_FRONTEND_VERSION__) {
destroy: () => {
sidebarRefresh = null;
viewingWorkflowKey = null;
// Clean up tooltip
if (sidebarTooltipEl) {
sidebarTooltipEl.remove();
sidebarTooltipEl = null;
}
},
});
},