From ee4051ad72839defb29cf69ac65ad5ca5b473112 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Tue, 24 Feb 2026 23:40:03 +0100 Subject: [PATCH] Add timeline active marker, auto-save before swap, snapshot button & Ctrl+S - Track active (white ring) and current (green dot) snapshots on timeline - Auto-capture "Current" state before swapping so user can navigate back - Add "Snapshot" button to timeline bar for quick manual captures - Register Ctrl+S / Cmd+S shortcut for manual snapshots - Clear active/current markers on new captures and workflow switches - Return record.id from captureSnapshot (backward-compatible truthy value) Co-Authored-By: Claude Opus 4.6 --- js/snapshot_manager.js | 248 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 247 insertions(+), 1 deletion(-) diff --git a/js/snapshot_manager.js b/js/snapshot_manager.js index 62108c7..bd52be7 100644 --- a/js/snapshot_manager.js +++ b/js/snapshot_manager.js @@ -23,6 +23,7 @@ let debounceMs = 3000; let autoCaptureEnabled = true; let captureOnLoad = true; let maxNodeSnapshots = 5; +let showTimeline = false; // ─── State ─────────────────────────────────────────────────────────── @@ -32,6 +33,10 @@ let captureTimer = null; let sidebarRefresh = null; // callback set by sidebar render let viewingWorkflowKey = null; // null = follow active workflow; string = override let pickerDirty = true; // forces workflow picker to re-fetch on next expand +let timelineEl = null; // root DOM element for timeline bar +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 // ─── Server API Layer ─────────────────────────────────────────────── @@ -266,6 +271,9 @@ async function withRestoreLock(fn) { if (sidebarRefresh) { sidebarRefresh().catch(() => {}); } + if (timelineRefresh) { + timelineRefresh().catch(() => {}); + } }, RESTORE_GUARD_MS); } } @@ -341,11 +349,16 @@ async function captureSnapshot(label = "Auto") { lastCapturedHashMap.set(workflowKey, hash); pickerDirty = true; + currentSnapshotId = null; // new capture supersedes "current" bookmark + activeSnapshotId = null; // graph has changed, no snapshot is "active" if (sidebarRefresh) { sidebarRefresh().catch(() => {}); } - return true; + if (timelineRefresh) { + timelineRefresh().catch(() => {}); + } + return record.id; } async function captureNodeSnapshot(label = "Node Trigger") { @@ -378,10 +391,15 @@ async function captureNodeSnapshot(label = "Node Trigger") { } pickerDirty = true; + currentSnapshotId = null; + activeSnapshotId = null; if (sidebarRefresh) { sidebarRefresh().catch(() => {}); } + if (timelineRefresh) { + timelineRefresh().catch(() => {}); + } return true; } @@ -417,6 +435,12 @@ async function restoreSnapshot(record) { } async function swapSnapshot(record) { + // Auto-save current state before swapping (so user can get back) + const prevCurrentId = currentSnapshotId; + const capturedId = await captureSnapshot("Current"); + // captureSnapshot clears currentSnapshotId; restore or update it + currentSnapshotId = capturedId || prevCurrentId; + await withRestoreLock(async () => { if (!validateSnapshotData(record.graphData)) { showToast("Invalid snapshot data", "error"); @@ -426,6 +450,7 @@ async function swapSnapshot(record) { const workflow = app.extensionManager?.workflow?.activeWorkflow; await app.loadGraphData(record.graphData, true, true, workflow); lastCapturedHashMap.set(getWorkflowKey(), quickHash(JSON.stringify(record.graphData))); + activeSnapshotId = record.id; showToast("Snapshot swapped", "success"); } catch (err) { console.warn(`[${EXTENSION_NAME}] Swap failed:`, err); @@ -745,6 +770,86 @@ const CSS = ` .snap-workflow-viewing-banner button:hover { background: rgba(245, 158, 11, 0.2); } +.snap-timeline { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 32px; + background: rgba(15, 23, 42, 0.85); + border-top: 1px solid var(--border-color, #334155); + display: flex; + align-items: center; + padding: 0 16px; + z-index: 10; + pointer-events: auto; +} +.snap-timeline-track { + flex: 1; + height: 100%; + position: relative; +} +.snap-timeline-marker { + position: absolute; + top: 50%; + width: 10px; + height: 10px; + border-radius: 50%; + background: #3b82f6; + transform: translate(-50%, -50%); + cursor: pointer; + transition: transform 0.1s, box-shadow 0.1s; + border: 2px solid transparent; +} +.snap-timeline-marker:hover { + transform: translate(-50%, -50%) scale(1.5); + box-shadow: 0 0 6px rgba(59, 130, 246, 0.6); +} +.snap-timeline-marker-node { + background: #6d28d9; +} +.snap-timeline-marker-node:hover { + box-shadow: 0 0 6px rgba(109, 40, 217, 0.6); +} +.snap-timeline-marker-locked { + border-color: #facc15; +} +.snap-timeline-marker-active { + border-color: #fff; + transform: translate(-50%, -50%) scale(1.3); +} +.snap-timeline-marker-active:hover { + transform: translate(-50%, -50%) scale(1.5); +} +.snap-timeline-marker-current { + background: #10b981; +} +.snap-timeline-marker-current:hover { + box-shadow: 0 0 6px rgba(16, 185, 129, 0.6); +} +.snap-timeline-snap-btn { + background: none; + border: 1px solid var(--descrip-text, #64748b); + color: var(--descrip-text, #94a3b8); + border-radius: 4px; + padding: 2px 8px; + font-size: 11px; + cursor: pointer; + margin-left: 8px; + white-space: nowrap; + flex-shrink: 0; + font-family: system-ui, sans-serif; +} +.snap-timeline-snap-btn:hover { + border-color: #3b82f6; + color: #3b82f6; +} +.snap-timeline-empty { + color: var(--descrip-text, #64748b); + font-size: 11px; + font-family: system-ui, sans-serif; + line-height: 32px; +} `; function injectStyles() { @@ -1131,6 +1236,117 @@ async function buildSidebar(el) { await refresh(true); } +// ─── Timeline Bar ──────────────────────────────────────────────────── + +function buildTimeline() { + // Guard against duplicate calls + if (timelineEl) return; + + injectStyles(); + + const canvasParent = app.canvas?.canvas?.parentElement; + if (!canvasParent) { + console.warn(`[${EXTENSION_NAME}] Cannot build timeline: canvas parent not found`); + return; + } + + // Ensure parent is positioned so absolute children work + const parentPos = getComputedStyle(canvasParent).position; + if (parentPos === "static") { + canvasParent.style.position = "relative"; + } + + // Create root element + const bar = document.createElement("div"); + bar.className = "snap-timeline"; + bar.style.display = showTimeline ? "" : "none"; + + const track = document.createElement("div"); + track.className = "snap-timeline-track"; + + const snapBtn = document.createElement("button"); + snapBtn.className = "snap-timeline-snap-btn"; + snapBtn.textContent = "Snapshot"; + snapBtn.title = "Take a manual snapshot (Ctrl+S)"; + snapBtn.addEventListener("click", async () => { + snapBtn.disabled = true; + const saved = await captureSnapshot("Manual"); + if (saved) showToast("Snapshot saved", "success"); + snapBtn.disabled = false; + }); + + bar.appendChild(track); + bar.appendChild(snapBtn); + + canvasParent.appendChild(bar); + timelineEl = bar; + + async function refresh() { + if (!showTimeline) return; + + const records = await db_getAllForWorkflow(getWorkflowKey()); + records.sort((a, b) => a.timestamp - b.timestamp); + + track.innerHTML = ""; + + if (records.length === 0) { + const empty = document.createElement("span"); + empty.className = "snap-timeline-empty"; + empty.textContent = "No snapshots"; + track.appendChild(empty); + return; + } + + const minTs = records[0].timestamp; + const maxTs = records[records.length - 1].timestamp; + const range = maxTs - minTs; + + for (const rec of records) { + const marker = document.createElement("div"); + marker.className = "snap-timeline-marker"; + + // Position: spread evenly if all same timestamp, otherwise by time + const pct = range > 0 + ? ((rec.timestamp - minTs) / range) * 100 + : 50; + marker.style.left = `${pct}%`; + + // Node snapshot styling + if (rec.source === "node") { + marker.classList.add("snap-timeline-marker-node"); + } + + // Locked snapshot styling + if (rec.locked) { + marker.classList.add("snap-timeline-marker-locked"); + } + + // Active snapshot styling (the one swapped TO) + if (rec.id === activeSnapshotId) { + marker.classList.add("snap-timeline-marker-active"); + } + + // Current snapshot styling (auto-saved "you were here" bookmark) + if (rec.id === currentSnapshotId) { + marker.classList.add("snap-timeline-marker-current"); + } + + // Native tooltip + marker.title = `${rec.label} — ${formatTime(rec.timestamp)}`; + + // Click to swap + marker.addEventListener("click", () => { + swapSnapshot(rec); + }); + + track.appendChild(marker); + } + } + + timelineRefresh = refresh; + refresh().catch(() => {}); +} + // ─── Extension Registration ────────────────────────────────────────── if (window.__COMFYUI_FRONTEND_VERSION__) { @@ -1191,6 +1407,18 @@ if (window.__COMFYUI_FRONTEND_VERSION__) { maxNodeSnapshots = value; }, }, + { + id: "SnapshotManager.showTimeline", + name: "Show snapshot timeline on canvas", + type: "boolean", + defaultValue: false, + category: ["Snapshot Manager", "Timeline", "Show snapshot timeline on canvas"], + onChange(value) { + showTimeline = value; + if (timelineEl) timelineEl.style.display = value ? "" : "none"; + if (value && timelineRefresh) timelineRefresh().catch(() => {}); + }, + }, ], init() { @@ -1239,14 +1467,32 @@ if (window.__COMFYUI_FRONTEND_VERSION__) { captureTimer = null; } viewingWorkflowKey = null; + activeSnapshotId = null; + currentSnapshotId = null; if (sidebarRefresh) { sidebarRefresh(true).catch(() => {}); } + if (timelineRefresh) { + timelineRefresh().catch(() => {}); + } }); } }); } + // Ctrl+S / Cmd+S shortcut for manual snapshot + document.addEventListener("keydown", (e) => { + if ((e.ctrlKey || e.metaKey) && e.key === "s") { + captureSnapshot("Manual (Ctrl+S)").then((saved) => { + if (saved) showToast("Snapshot saved", "success"); + }).catch(() => {}); + // Don't preventDefault — let ComfyUI's own workflow save still fire + } + }); + + // Build the timeline bar on the canvas + buildTimeline(); + // Capture initial state after a short delay (decoupled from debounceMs) setTimeout(() => { if (!captureOnLoad) return;