Add Fusion 360-style change-type icons to timeline markers
Each snapshot now detects what kind of change it represents (node add, node remove, connection, parameter, move, mixed) and displays a distinct colored icon on the timeline. Sidebar meta line shows the change type. Existing snapshots without change data gracefully fall back to a faded "unknown" dot. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
44
README.md
44
README.md
@@ -5,7 +5,7 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://registry.comfy.org/publishers/ethanfel/nodes/comfyui-snapshot-manager"><img src="https://img.shields.io/badge/ComfyUI-Registry-blue?logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyIDJMMyA3djEwbDkgNSA5LTVWN2wtOS01eiIgZmlsbD0id2hpdGUiLz48L3N2Zz4=" alt="ComfyUI Registry"/></a>
|
<a href="https://registry.comfy.org/publishers/ethanfel/nodes/comfyui-snapshot-manager"><img src="https://img.shields.io/badge/ComfyUI-Registry-blue?logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyIDJMMyA3djEwbDkgNSA5LTVWN2wtOS01eiIgZmlsbD0id2hpdGUiLz48L3N2Zz4=" alt="ComfyUI Registry"/></a>
|
||||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="MIT License"/></a>
|
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="MIT License"/></a>
|
||||||
<img src="https://img.shields.io/badge/version-2.2.0-blue" alt="Version"/>
|
<img src="https://img.shields.io/badge/version-2.3.0-blue" alt="Version"/>
|
||||||
<img src="https://img.shields.io/badge/ComfyUI-Extension-purple" alt="ComfyUI Extension"/>
|
<img src="https://img.shields.io/badge/ComfyUI-Extension-purple" alt="ComfyUI Extension"/>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -28,7 +28,8 @@
|
|||||||
- **Theme-aware UI** — Adapts to light and dark ComfyUI themes
|
- **Theme-aware UI** — Adapts to light and dark ComfyUI themes
|
||||||
- **Toast notifications** — Visual feedback for save, restore, and error operations
|
- **Toast notifications** — Visual feedback for save, restore, and error operations
|
||||||
- **SaveSnapshot node** — Trigger snapshot captures from your workflow with a custom node; node snapshots are visually distinct (purple border + "Node" badge) and have their own rolling limit
|
- **SaveSnapshot node** — Trigger snapshot captures from your workflow with a custom node; node snapshots are visually distinct (purple border + "Node" badge) and have their own rolling limit
|
||||||
- **Timeline bar** — Optional visual timeline on the canvas showing all snapshots as dots, with a Snapshot button for quick captures
|
- **Change-type icons** — Timeline markers show what kind of change each snapshot represents (node add, remove, connection, parameter, move, mixed) with distinct colored icons — like Fusion 360's operation timeline
|
||||||
|
- **Timeline bar** — Optional visual timeline on the canvas showing all snapshots as iconic markers, with a Snapshot button for quick captures
|
||||||
- **Active & current markers** — When you swap to a snapshot, the timeline highlights where you came from (green dot) and where you are (white ring)
|
- **Active & current markers** — When you swap to a snapshot, the timeline highlights where you came from (green dot) and where you are (white ring)
|
||||||
- **Auto-save before swap** — Swapping to an older snapshot automatically saves your current state first, so you can always get back
|
- **Auto-save before swap** — Swapping to an older snapshot automatically saves your current state first, so you can always get back
|
||||||
- **Ctrl+S shortcut** — Press Ctrl+S (or Cmd+S on Mac) to take a manual snapshot alongside ComfyUI's own save
|
- **Ctrl+S shortcut** — Press Ctrl+S (or Cmd+S on Mac) to take a manual snapshot alongside ComfyUI's own save
|
||||||
@@ -97,17 +98,35 @@ This is especially useful for recovering snapshots from workflows that were rena
|
|||||||
|
|
||||||
### 8. Timeline Bar
|
### 8. Timeline Bar
|
||||||
|
|
||||||
Enable the timeline in **Settings > Snapshot Manager > Timeline > Show snapshot timeline on canvas**. A thin bar appears at the bottom of the canvas with a dot for each snapshot:
|
Enable the timeline in **Settings > Snapshot Manager > Timeline > Show snapshot timeline on canvas**. A thin bar appears at the bottom of the canvas with an iconic marker for each snapshot — each icon shows what kind of change the snapshot represents:
|
||||||
|
|
||||||
| Marker | Meaning |
|
<p align="center">
|
||||||
|--------|---------|
|
<img src="assets/timeline-icons.svg" alt="Timeline change-type icons" width="720"/>
|
||||||
| **Blue dot** | Regular snapshot |
|
</p>
|
||||||
| **Purple dot** | Node-triggered snapshot |
|
|
||||||
|
| Icon | Color | Change Type |
|
||||||
|
|------|-------|-------------|
|
||||||
|
| Filled circle | Blue | **Initial** — first snapshot after load |
|
||||||
|
| Plus **+** | Green | **Node Add** — nodes were added |
|
||||||
|
| Minus **−** | Red | **Node Remove** — nodes were removed |
|
||||||
|
| Zigzag | Amber | **Connection** — links/wires changed |
|
||||||
|
| Wave | Purple | **Param** — widget values changed |
|
||||||
|
| Arrows ↕ | Gray | **Move** — nodes repositioned |
|
||||||
|
| Star ✱ | Orange | **Mixed** — multiple change types |
|
||||||
|
| Faded dot | Gray | **Unknown** — legacy snapshot or no detected change |
|
||||||
|
|
||||||
|
Additional marker styles are layered on top of the change-type icon:
|
||||||
|
|
||||||
|
| Overlay | Meaning |
|
||||||
|
|---------|---------|
|
||||||
|
| **Purple background** | Node-triggered snapshot (overrides change-type color) |
|
||||||
| **Yellow border** | Locked snapshot |
|
| **Yellow border** | Locked snapshot |
|
||||||
| **White ring (larger)** | Active — the snapshot you swapped TO |
|
| **White ring (larger)** | Active — the snapshot you swapped TO |
|
||||||
| **Green dot** | Current — your auto-saved state before the swap |
|
| **Green background** | Current — your auto-saved state before the swap |
|
||||||
|
|
||||||
Click any dot to swap to that snapshot. The **Snapshot** button on the right side of the bar takes a quick manual snapshot.
|
Click any marker to swap to that snapshot. Hover to see a tooltip with the snapshot name, time, and change description. The **Snapshot** button on the right takes a quick manual snapshot.
|
||||||
|
|
||||||
|
The sidebar list also shows the change type in the meta line below each snapshot (e.g., "5 nodes · Parameters changed").
|
||||||
|
|
||||||
### 9. Auto-save Before Swap
|
### 9. Auto-save Before Swap
|
||||||
|
|
||||||
@@ -146,9 +165,10 @@ All settings are available in **ComfyUI Settings > Snapshot Manager**:
|
|||||||
1. **Graph edits** trigger a `graphChanged` event
|
1. **Graph edits** trigger a `graphChanged` event
|
||||||
2. A **debounce timer** prevents excessive writes
|
2. A **debounce timer** prevents excessive writes
|
||||||
3. The workflow is serialized and **hash-checked** against the last capture (per-workflow) to avoid duplicates
|
3. The workflow is serialized and **hash-checked** against the last capture (per-workflow) to avoid duplicates
|
||||||
4. New snapshots are sent to the **server** and stored as individual JSON files under `data/snapshots/`
|
4. The previous graph state is diffed against the current to **detect the change type** (node add/remove, connection, parameter, move, or mixed) — stored as a `changeType` field on the record
|
||||||
5. The **sidebar panel** fetches snapshots from the server and renders the snapshot list
|
5. New snapshots are sent to the **server** and stored as individual JSON files under `data/snapshots/`
|
||||||
6. **Restore/Swap** loads graph data back into ComfyUI with a lock guard to prevent concurrent operations
|
6. The **sidebar panel** and **timeline bar** fetch snapshots from the server and render them with change-type icons
|
||||||
|
7. **Restore/Swap** loads graph data back into ComfyUI with a lock guard to prevent concurrent operations, and updates the graph cache so the next diff is accurate
|
||||||
|
|
||||||
**Node-triggered capture flow:**
|
**Node-triggered capture flow:**
|
||||||
|
|
||||||
|
|||||||
106
assets/timeline-icons.svg
Normal file
106
assets/timeline-icons.svg
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="720" height="80" viewBox="0 0 720 80">
|
||||||
|
<!-- Background -->
|
||||||
|
<rect width="720" height="80" rx="8" fill="#1e293b"/>
|
||||||
|
|
||||||
|
<!-- Icon definitions -->
|
||||||
|
<defs>
|
||||||
|
<!-- Initial (blue) -->
|
||||||
|
<symbol id="icon-initial" viewBox="0 0 12 12">
|
||||||
|
<circle cx="6" cy="6" r="5" fill="currentColor"/>
|
||||||
|
</symbol>
|
||||||
|
|
||||||
|
<!-- Node Add (green) -->
|
||||||
|
<symbol id="icon-node-add" viewBox="0 0 12 12">
|
||||||
|
<path d="M6 2v8M2 6h8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</symbol>
|
||||||
|
|
||||||
|
<!-- Node Remove (red) -->
|
||||||
|
<symbol id="icon-node-remove" viewBox="0 0 12 12">
|
||||||
|
<path d="M2 6h8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</symbol>
|
||||||
|
|
||||||
|
<!-- Connection (amber) -->
|
||||||
|
<symbol id="icon-connection" viewBox="0 0 12 12">
|
||||||
|
<path d="M1 9L4 3L8 9L11 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||||
|
</symbol>
|
||||||
|
|
||||||
|
<!-- Param (purple) -->
|
||||||
|
<symbol id="icon-param" viewBox="0 0 12 12">
|
||||||
|
<path d="M0 6Q3 2 6 6Q9 10 12 6" stroke="currentColor" stroke-width="1.5" fill="none"/>
|
||||||
|
</symbol>
|
||||||
|
|
||||||
|
<!-- Move (gray) -->
|
||||||
|
<symbol id="icon-move" viewBox="0 0 12 12">
|
||||||
|
<path d="M6 1L3 4h6L6 1ZM6 11L3 8h6L6 11Z" fill="currentColor"/>
|
||||||
|
</symbol>
|
||||||
|
|
||||||
|
<!-- Mixed (orange) -->
|
||||||
|
<symbol id="icon-mixed" viewBox="0 0 12 12">
|
||||||
|
<path d="M6 1L7.5 4.5H11L8.25 6.75L9.5 10.5L6 8L2.5 10.5L3.75 6.75L1 4.5H4.5Z" fill="currentColor"/>
|
||||||
|
</symbol>
|
||||||
|
|
||||||
|
<!-- Unknown (gray) -->
|
||||||
|
<symbol id="icon-unknown" viewBox="0 0 12 12">
|
||||||
|
<circle cx="6" cy="6" r="3" fill="currentColor" opacity="0.5"/>
|
||||||
|
</symbol>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- 8 icons evenly spaced: center positions at 45, 141.43, 237.86, 334.29, 430.71, 527.14, 623.57, 720-45=675 -->
|
||||||
|
<!-- Spacing: (720 - 2*45) / 7 = 90 -->
|
||||||
|
|
||||||
|
<!-- 1. Initial -->
|
||||||
|
<g transform="translate(45, 0)">
|
||||||
|
<circle cx="0" cy="28" r="16" fill="#3b82f6"/>
|
||||||
|
<use href="#icon-initial" x="-8" y="20" width="16" height="16" color="white"/>
|
||||||
|
<text x="0" y="60" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="11" fill="#94a3b8">Initial</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- 2. Node Add -->
|
||||||
|
<g transform="translate(135, 0)">
|
||||||
|
<circle cx="0" cy="28" r="16" fill="#22c55e"/>
|
||||||
|
<use href="#icon-node-add" x="-8" y="20" width="16" height="16" color="white"/>
|
||||||
|
<text x="0" y="60" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="11" fill="#94a3b8">Node Add</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- 3. Node Remove -->
|
||||||
|
<g transform="translate(225, 0)">
|
||||||
|
<circle cx="0" cy="28" r="16" fill="#ef4444"/>
|
||||||
|
<use href="#icon-node-remove" x="-8" y="20" width="16" height="16" color="white"/>
|
||||||
|
<text x="0" y="60" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="11" fill="#94a3b8">Node Remove</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- 4. Connection -->
|
||||||
|
<g transform="translate(315, 0)">
|
||||||
|
<circle cx="0" cy="28" r="16" fill="#f59e0b"/>
|
||||||
|
<use href="#icon-connection" x="-8" y="20" width="16" height="16" color="white"/>
|
||||||
|
<text x="0" y="60" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="11" fill="#94a3b8">Connection</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- 5. Param -->
|
||||||
|
<g transform="translate(405, 0)">
|
||||||
|
<circle cx="0" cy="28" r="16" fill="#a78bfa"/>
|
||||||
|
<use href="#icon-param" x="-8" y="20" width="16" height="16" color="white"/>
|
||||||
|
<text x="0" y="60" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="11" fill="#94a3b8">Param</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- 6. Move -->
|
||||||
|
<g transform="translate(495, 0)">
|
||||||
|
<circle cx="0" cy="28" r="16" fill="#64748b"/>
|
||||||
|
<use href="#icon-move" x="-8" y="20" width="16" height="16" color="white"/>
|
||||||
|
<text x="0" y="60" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="11" fill="#94a3b8">Move</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- 7. Mixed -->
|
||||||
|
<g transform="translate(585, 0)">
|
||||||
|
<circle cx="0" cy="28" r="16" fill="#f97316"/>
|
||||||
|
<use href="#icon-mixed" x="-8" y="20" width="16" height="16" color="white"/>
|
||||||
|
<text x="0" y="60" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="11" fill="#94a3b8">Mixed</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- 8. Unknown -->
|
||||||
|
<g transform="translate(675, 0)">
|
||||||
|
<circle cx="0" cy="28" r="16" fill="#6b7280"/>
|
||||||
|
<use href="#icon-unknown" x="-8" y="20" width="16" height="16" color="white"/>
|
||||||
|
<text x="0" y="60" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="11" fill="#94a3b8">Unknown</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.5 KiB |
@@ -28,6 +28,7 @@ let showTimeline = false;
|
|||||||
// ─── State ───────────────────────────────────────────────────────────
|
// ─── State ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const lastCapturedHashMap = new Map();
|
const lastCapturedHashMap = new Map();
|
||||||
|
const lastGraphDataMap = new Map(); // workflowKey -> previous graphData for change-type detection
|
||||||
let restoreLock = null;
|
let restoreLock = null;
|
||||||
let captureTimer = null;
|
let captureTimer = null;
|
||||||
let sidebarRefresh = null; // callback set by sidebar render
|
let sidebarRefresh = null; // callback set by sidebar render
|
||||||
@@ -256,6 +257,73 @@ function validateSnapshotData(graphData) {
|
|||||||
return graphData != null && typeof graphData === "object" && Array.isArray(graphData.nodes);
|
return graphData != null && typeof graphData === "object" && Array.isArray(graphData.nodes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Change-Type Detection ──────────────────────────────────────────
|
||||||
|
|
||||||
|
function detectChangeType(prevGraph, currGraph) {
|
||||||
|
if (!prevGraph) return "initial";
|
||||||
|
|
||||||
|
const prevNodes = prevGraph.nodes || [];
|
||||||
|
const currNodes = currGraph.nodes || [];
|
||||||
|
const prevIds = new Set(prevNodes.map(n => n.id));
|
||||||
|
const currIds = new Set(currNodes.map(n => n.id));
|
||||||
|
|
||||||
|
const added = currNodes.filter(n => !prevIds.has(n.id));
|
||||||
|
const removed = prevNodes.filter(n => !currIds.has(n.id));
|
||||||
|
const nodesChanged = added.length > 0 || removed.length > 0;
|
||||||
|
|
||||||
|
// When nodes are added/removed, link changes are expected — don't flag separately
|
||||||
|
if (nodesChanged) {
|
||||||
|
if (added.length > 0 && removed.length > 0) return "mixed";
|
||||||
|
return added.length > 0 ? "node_add" : "node_remove";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Node sets identical — check links, params, positions
|
||||||
|
let flags = 0;
|
||||||
|
const FLAG_CONNECTION = 1;
|
||||||
|
const FLAG_PARAM = 2;
|
||||||
|
const FLAG_MOVE = 4;
|
||||||
|
|
||||||
|
// Compare links
|
||||||
|
const prevLinks = JSON.stringify(prevGraph.links || []);
|
||||||
|
const currLinks = JSON.stringify(currGraph.links || []);
|
||||||
|
if (prevLinks !== currLinks) flags |= FLAG_CONNECTION;
|
||||||
|
|
||||||
|
// Build lookup for prev nodes by id
|
||||||
|
const prevNodeMap = new Map(prevNodes.map(n => [n.id, n]));
|
||||||
|
|
||||||
|
for (const cn of currNodes) {
|
||||||
|
const pn = prevNodeMap.get(cn.id);
|
||||||
|
if (!pn) continue;
|
||||||
|
|
||||||
|
// Compare widget values
|
||||||
|
const cw = JSON.stringify(cn.widgets_values ?? null);
|
||||||
|
const pw = JSON.stringify(pn.widgets_values ?? null);
|
||||||
|
if (cw !== pw) flags |= FLAG_PARAM;
|
||||||
|
|
||||||
|
// Compare positions
|
||||||
|
const cPos = Array.isArray(cn.pos) ? cn.pos : [0, 0];
|
||||||
|
const pPos = Array.isArray(pn.pos) ? pn.pos : [0, 0];
|
||||||
|
if (cPos[0] !== pPos[0] || cPos[1] !== pPos[1]) flags |= FLAG_MOVE;
|
||||||
|
|
||||||
|
// Early exit if all flags set
|
||||||
|
if (flags === (FLAG_CONNECTION | FLAG_PARAM | FLAG_MOVE)) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flags === 0) return "unknown";
|
||||||
|
|
||||||
|
// Count set flags
|
||||||
|
const count = ((flags & FLAG_CONNECTION) ? 1 : 0)
|
||||||
|
+ ((flags & FLAG_PARAM) ? 1 : 0)
|
||||||
|
+ ((flags & FLAG_MOVE) ? 1 : 0);
|
||||||
|
if (count > 1) return "mixed";
|
||||||
|
|
||||||
|
if (flags & FLAG_CONNECTION) return "connection";
|
||||||
|
if (flags & FLAG_PARAM) return "param";
|
||||||
|
if (flags & FLAG_MOVE) return "move";
|
||||||
|
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Restore Lock ───────────────────────────────────────────────────
|
// ─── Restore Lock ───────────────────────────────────────────────────
|
||||||
|
|
||||||
async function withRestoreLock(fn) {
|
async function withRestoreLock(fn) {
|
||||||
@@ -330,6 +398,9 @@ async function captureSnapshot(label = "Auto") {
|
|||||||
const hash = quickHash(serialized);
|
const hash = quickHash(serialized);
|
||||||
if (hash === lastCapturedHashMap.get(workflowKey)) return false;
|
if (hash === lastCapturedHashMap.get(workflowKey)) return false;
|
||||||
|
|
||||||
|
const prevGraph = lastGraphDataMap.get(workflowKey);
|
||||||
|
const changeType = detectChangeType(prevGraph, graphData);
|
||||||
|
|
||||||
const record = {
|
const record = {
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
workflowKey,
|
workflowKey,
|
||||||
@@ -338,6 +409,7 @@ async function captureSnapshot(label = "Auto") {
|
|||||||
nodeCount: nodes.length,
|
nodeCount: nodes.length,
|
||||||
graphData,
|
graphData,
|
||||||
locked: false,
|
locked: false,
|
||||||
|
changeType,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -348,6 +420,7 @@ async function captureSnapshot(label = "Auto") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
lastCapturedHashMap.set(workflowKey, hash);
|
lastCapturedHashMap.set(workflowKey, hash);
|
||||||
|
lastGraphDataMap.set(workflowKey, graphData);
|
||||||
pickerDirty = true;
|
pickerDirty = true;
|
||||||
currentSnapshotId = null; // new capture supersedes "current" bookmark
|
currentSnapshotId = null; // new capture supersedes "current" bookmark
|
||||||
activeSnapshotId = null; // graph has changed, no snapshot is "active"
|
activeSnapshotId = null; // graph has changed, no snapshot is "active"
|
||||||
@@ -371,6 +444,8 @@ async function captureNodeSnapshot(label = "Node Trigger") {
|
|||||||
if (nodes.length === 0) return false;
|
if (nodes.length === 0) return false;
|
||||||
|
|
||||||
const workflowKey = getWorkflowKey();
|
const workflowKey = getWorkflowKey();
|
||||||
|
const prevGraph = lastGraphDataMap.get(workflowKey);
|
||||||
|
const changeType = detectChangeType(prevGraph, graphData);
|
||||||
|
|
||||||
const record = {
|
const record = {
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
@@ -381,6 +456,7 @@ async function captureNodeSnapshot(label = "Node Trigger") {
|
|||||||
graphData,
|
graphData,
|
||||||
locked: false,
|
locked: false,
|
||||||
source: "node",
|
source: "node",
|
||||||
|
changeType,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -390,6 +466,7 @@ async function captureNodeSnapshot(label = "Node Trigger") {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lastGraphDataMap.set(workflowKey, graphData);
|
||||||
pickerDirty = true;
|
pickerDirty = true;
|
||||||
currentSnapshotId = null;
|
currentSnapshotId = null;
|
||||||
activeSnapshotId = null;
|
activeSnapshotId = null;
|
||||||
@@ -426,6 +503,7 @@ async function restoreSnapshot(record) {
|
|||||||
try {
|
try {
|
||||||
await app.loadGraphData(record.graphData, true, true);
|
await app.loadGraphData(record.graphData, true, true);
|
||||||
lastCapturedHashMap.set(getWorkflowKey(), quickHash(JSON.stringify(record.graphData)));
|
lastCapturedHashMap.set(getWorkflowKey(), quickHash(JSON.stringify(record.graphData)));
|
||||||
|
lastGraphDataMap.set(getWorkflowKey(), record.graphData);
|
||||||
showToast("Snapshot restored", "success");
|
showToast("Snapshot restored", "success");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`[${EXTENSION_NAME}] Restore failed:`, err);
|
console.warn(`[${EXTENSION_NAME}] Restore failed:`, err);
|
||||||
@@ -450,6 +528,7 @@ async function swapSnapshot(record) {
|
|||||||
const workflow = app.extensionManager?.workflow?.activeWorkflow;
|
const workflow = app.extensionManager?.workflow?.activeWorkflow;
|
||||||
await app.loadGraphData(record.graphData, true, true, workflow);
|
await app.loadGraphData(record.graphData, true, true, workflow);
|
||||||
lastCapturedHashMap.set(getWorkflowKey(), quickHash(JSON.stringify(record.graphData)));
|
lastCapturedHashMap.set(getWorkflowKey(), quickHash(JSON.stringify(record.graphData)));
|
||||||
|
lastGraphDataMap.set(getWorkflowKey(), record.graphData);
|
||||||
activeSnapshotId = record.id;
|
activeSnapshotId = record.id;
|
||||||
showToast("Snapshot swapped", "success");
|
showToast("Snapshot swapped", "success");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -787,23 +866,32 @@ const CSS = `
|
|||||||
.snap-timeline-track {
|
.snap-timeline-track {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
position: relative;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
.snap-timeline-marker {
|
.snap-timeline-marker {
|
||||||
position: absolute;
|
width: 18px;
|
||||||
top: 50%;
|
height: 18px;
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #3b82f6;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 0.1s, box-shadow 0.1s;
|
transition: transform 0.1s, box-shadow 0.1s;
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--snap-marker-color, #3b82f6);
|
||||||
|
}
|
||||||
|
.snap-timeline-marker svg {
|
||||||
|
display: block;
|
||||||
|
color: #fff;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.snap-timeline-marker:hover {
|
.snap-timeline-marker:hover {
|
||||||
transform: translate(-50%, -50%) scale(1.5);
|
transform: scale(1.4);
|
||||||
box-shadow: 0 0 6px rgba(59, 130, 246, 0.6);
|
box-shadow: 0 0 8px var(--snap-marker-color, rgba(59,130,246,0.6));
|
||||||
}
|
}
|
||||||
.snap-timeline-marker-node {
|
.snap-timeline-marker-node {
|
||||||
background: #6d28d9;
|
background: #6d28d9;
|
||||||
@@ -816,10 +904,10 @@ const CSS = `
|
|||||||
}
|
}
|
||||||
.snap-timeline-marker-active {
|
.snap-timeline-marker-active {
|
||||||
border-color: #fff;
|
border-color: #fff;
|
||||||
transform: translate(-50%, -50%) scale(1.3);
|
transform: scale(1.3);
|
||||||
}
|
}
|
||||||
.snap-timeline-marker-active:hover {
|
.snap-timeline-marker-active:hover {
|
||||||
transform: translate(-50%, -50%) scale(1.5);
|
transform: scale(1.5);
|
||||||
}
|
}
|
||||||
.snap-timeline-marker-current {
|
.snap-timeline-marker-current {
|
||||||
background: #10b981;
|
background: #10b981;
|
||||||
@@ -852,6 +940,49 @@ const CSS = `
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const CHANGE_TYPE_ICONS = {
|
||||||
|
initial: {
|
||||||
|
svg: '<svg width="10" height="10" viewBox="0 0 12 12"><circle cx="6" cy="6" r="5" fill="currentColor"/></svg>',
|
||||||
|
color: "#3b82f6",
|
||||||
|
label: "Initial snapshot",
|
||||||
|
},
|
||||||
|
node_add: {
|
||||||
|
svg: '<svg width="10" height="10" viewBox="0 0 12 12"><path d="M6 2v8M2 6h8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>',
|
||||||
|
color: "#22c55e",
|
||||||
|
label: "Nodes added",
|
||||||
|
},
|
||||||
|
node_remove: {
|
||||||
|
svg: '<svg width="10" height="10" viewBox="0 0 12 12"><path d="M2 6h8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>',
|
||||||
|
color: "#ef4444",
|
||||||
|
label: "Nodes removed",
|
||||||
|
},
|
||||||
|
connection: {
|
||||||
|
svg: '<svg width="10" height="10" viewBox="0 0 12 12"><path d="M1 9L4 3L8 9L11 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>',
|
||||||
|
color: "#f59e0b",
|
||||||
|
label: "Connections changed",
|
||||||
|
},
|
||||||
|
param: {
|
||||||
|
svg: '<svg width="10" height="10" viewBox="0 0 12 12"><path d="M0 6Q3 2 6 6Q9 10 12 6" stroke="currentColor" stroke-width="1.5" fill="none"/></svg>',
|
||||||
|
color: "#a78bfa",
|
||||||
|
label: "Parameters changed",
|
||||||
|
},
|
||||||
|
move: {
|
||||||
|
svg: '<svg width="10" height="10" viewBox="0 0 12 12"><path d="M6 1L3 4h6L6 1ZM6 11L3 8h6L6 11Z" fill="currentColor"/></svg>',
|
||||||
|
color: "#64748b",
|
||||||
|
label: "Nodes repositioned",
|
||||||
|
},
|
||||||
|
mixed: {
|
||||||
|
svg: '<svg width="10" height="10" viewBox="0 0 12 12"><path d="M6 1L7.5 4.5H11L8.25 6.75L9.5 10.5L6 8L2.5 10.5L3.75 6.75L1 4.5H4.5Z" fill="currentColor"/></svg>',
|
||||||
|
color: "#f97316",
|
||||||
|
label: "Multiple changes",
|
||||||
|
},
|
||||||
|
unknown: {
|
||||||
|
svg: '<svg width="10" height="10" viewBox="0 0 12 12"><circle cx="6" cy="6" r="3" fill="currentColor" opacity="0.5"/></svg>',
|
||||||
|
color: "#6b7280",
|
||||||
|
label: "Unknown change",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
function injectStyles() {
|
function injectStyles() {
|
||||||
if (document.getElementById("snapshot-manager-styles")) return;
|
if (document.getElementById("snapshot-manager-styles")) return;
|
||||||
const style = document.createElement("style");
|
const style = document.createElement("style");
|
||||||
@@ -1161,7 +1292,8 @@ async function buildSidebar(el) {
|
|||||||
|
|
||||||
const meta = document.createElement("div");
|
const meta = document.createElement("div");
|
||||||
meta.className = "snap-item-meta";
|
meta.className = "snap-item-meta";
|
||||||
meta.textContent = `${rec.nodeCount} nodes`;
|
const changeLabel = (CHANGE_TYPE_ICONS[rec.changeType] || CHANGE_TYPE_ICONS.unknown).label;
|
||||||
|
meta.textContent = `${rec.nodeCount} nodes \u00b7 ${changeLabel}`;
|
||||||
|
|
||||||
info.appendChild(labelDiv);
|
info.appendChild(labelDiv);
|
||||||
info.appendChild(time);
|
info.appendChild(time);
|
||||||
@@ -1297,23 +1429,19 @@ function buildTimeline() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const minTs = records[0].timestamp;
|
|
||||||
const maxTs = records[records.length - 1].timestamp;
|
|
||||||
const range = maxTs - minTs;
|
|
||||||
|
|
||||||
for (const rec of records) {
|
for (const rec of records) {
|
||||||
const marker = document.createElement("div");
|
const marker = document.createElement("div");
|
||||||
marker.className = "snap-timeline-marker";
|
marker.className = "snap-timeline-marker";
|
||||||
|
|
||||||
// Position: spread evenly if all same timestamp, otherwise by time
|
// Change-type icon and color
|
||||||
const pct = range > 0
|
const iconInfo = CHANGE_TYPE_ICONS[rec.changeType] || CHANGE_TYPE_ICONS.unknown;
|
||||||
? ((rec.timestamp - minTs) / range) * 100
|
marker.style.setProperty("--snap-marker-color", iconInfo.color);
|
||||||
: 50;
|
marker.innerHTML = iconInfo.svg;
|
||||||
marker.style.left = `${pct}%`;
|
|
||||||
|
|
||||||
// Node snapshot styling
|
// Node snapshot styling — override color to purple but keep the SVG icon
|
||||||
if (rec.source === "node") {
|
if (rec.source === "node") {
|
||||||
marker.classList.add("snap-timeline-marker-node");
|
marker.classList.add("snap-timeline-marker-node");
|
||||||
|
marker.style.setProperty("--snap-marker-color", "#6d28d9");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Locked snapshot styling
|
// Locked snapshot styling
|
||||||
@@ -1329,10 +1457,11 @@ function buildTimeline() {
|
|||||||
// Current snapshot styling (auto-saved "you were here" bookmark)
|
// Current snapshot styling (auto-saved "you were here" bookmark)
|
||||||
if (rec.id === currentSnapshotId) {
|
if (rec.id === currentSnapshotId) {
|
||||||
marker.classList.add("snap-timeline-marker-current");
|
marker.classList.add("snap-timeline-marker-current");
|
||||||
|
marker.style.setProperty("--snap-marker-color", "#10b981");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Native tooltip
|
// Native tooltip with change-type description
|
||||||
marker.title = `${rec.label} — ${formatTime(rec.timestamp)}`;
|
marker.title = `${rec.label} — ${formatTime(rec.timestamp)}\n${iconInfo.label}`;
|
||||||
|
|
||||||
// Click to swap
|
// Click to swap
|
||||||
marker.addEventListener("click", () => {
|
marker.addEventListener("click", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user